Page MenuHomeMusing Studio

No OneTemporary

This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/Dockerfile b/Dockerfile
index 38cd2c7..d9e1269 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,36 +1,37 @@
# Build image
-FROM golang:1.15-alpine as build
+FROM golang:1.16-alpine as build
RUN apk add --update nodejs npm make g++ git
RUN npm install -g less less-plugin-clean-css
RUN mkdir -p /go/src/github.com/writefreely/writefreely
WORKDIR /go/src/github.com/writefreely/writefreely
COPY . .
ENV GO111MODULE=on
RUN make build \
&& make ui
RUN mkdir /stage && \
cp -R /go/bin \
/go/src/github.com/writefreely/writefreely/templates \
/go/src/github.com/writefreely/writefreely/static \
/go/src/github.com/writefreely/writefreely/pages \
/go/src/github.com/writefreely/writefreely/keys \
/go/src/github.com/writefreely/writefreely/cmd \
+ ./locales \
/stage
# Final image
FROM alpine:3
RUN apk add --no-cache openssl ca-certificates
COPY --from=build --chown=daemon:daemon /stage /go
WORKDIR /go
VOLUME /go/keys
EXPOSE 8080
USER daemon
ENTRYPOINT ["cmd/writefreely/writefreely"]
diff --git a/account.go b/account.go
index 91a8ace..9ee9492 100644
--- a/account.go
+++ b/account.go
@@ -1,1260 +1,1263 @@
/*
* Copyright © 2018-2021 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/csrf"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/guregu/null/zero"
"github.com/writeas/impart"
"github.com/writeas/web-core/auth"
"github.com/writeas/web-core/data"
"github.com/writeas/web-core/log"
"github.com/writefreely/writefreely/author"
"github.com/writefreely/writefreely/config"
"github.com/writefreely/writefreely/page"
)
type (
userSettings struct {
Username string `schema:"username" json:"username"`
Email string `schema:"email" json:"email"`
NewPass string `schema:"new-pass" json:"new_pass"`
OldPass string `schema:"current-pass" json:"current_pass"`
IsLogOut bool `schema:"logout" json:"logout"`
}
UserPage struct {
page.StaticPage
PageTitle string
Separator template.HTML
IsAdmin bool
CanInvite bool
CollAlias string
}
)
func NewUserPage(app *App, r *http.Request, u *User, title string, flashes []string) *UserPage {
up := &UserPage{
StaticPage: pageForReq(app, r),
PageTitle: title,
}
up.Username = u.Username
up.Flashes = flashes
up.Path = r.URL.Path
up.IsAdmin = u.IsAdmin()
up.CanInvite = canUserInvite(app.cfg, up.IsAdmin)
return up
}
func canUserInvite(cfg *config.Config, isAdmin bool) bool {
return cfg.App.UserInvites != "" &&
(isAdmin || cfg.App.UserInvites != "admin")
}
func (up *UserPage) SetMessaging(u *User) {
// up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID)
}
const (
loginAttemptExpiration = 3 * time.Second
)
var actuallyUsernameReg = regexp.MustCompile("username is actually ([a-z0-9\\-]+)\\. Please try that, instead")
func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error {
_, err := signup(app, w, r)
return err
}
func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) {
if app.cfg.App.DisablePasswordAuth {
err := ErrDisabledPasswordAuth
return nil, err
}
reqJSON := IsJSON(r)
// Get params
var ur userRegistration
if reqJSON {
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&ur)
if err != nil {
log.Error("Couldn't parse signup JSON request: %v\n", err)
return nil, ErrBadJSON
}
} else {
// Check if user is already logged in
u := getUserSession(app, r)
if u != nil {
return &AuthUser{User: u}, nil
}
err := r.ParseForm()
if err != nil {
log.Error("Couldn't parse signup form request: %v\n", err)
return nil, ErrBadFormData
}
err = app.formDecoder.Decode(&ur, r.PostForm)
if err != nil {
log.Error("Couldn't decode signup form request: %v\n", err)
return nil, ErrBadFormData
}
}
return signupWithRegistration(app, ur, w, r)
}
func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWriter, r *http.Request) (*AuthUser, error) {
reqJSON := IsJSON(r)
// Validate required params (alias)
if signup.Alias == "" {
return nil, impart.HTTPError{http.StatusBadRequest, "A username is required."}
}
if signup.Pass == "" {
return nil, impart.HTTPError{http.StatusBadRequest, "A password is required."}
}
var desiredUsername string
if signup.Normalize {
// With this option we simply conform the username to what we expect
// without complaining. Since they might've done something funny, like
// enter: write.as/Way Out There, we'll use their raw input for the new
// collection name and sanitize for the slug / username.
desiredUsername = signup.Alias
signup.Alias = getSlug(signup.Alias, "")
}
if !author.IsValidUsername(app.cfg, signup.Alias) {
// Ensure the username is syntactically correct.
return nil, impart.HTTPError{http.StatusPreconditionFailed, "Username is reserved or isn't valid. It must be at least 3 characters long, and can only include letters, numbers, and hyphens."}
}
// Handle empty optional params
hashedPass, err := auth.HashPass([]byte(signup.Pass))
if err != nil {
return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
}
// Create struct to insert
u := &User{
Username: signup.Alias,
HashedPass: hashedPass,
HasPass: true,
Email: prepareUserEmail(signup.Email, app.keys.EmailKey),
Created: time.Now().Truncate(time.Second).UTC(),
}
// Create actual user
if err := app.db.CreateUser(app.cfg, u, desiredUsername, signup.Description); err != nil {
return nil, err
}
// Log invite if needed
if signup.InviteCode != "" {
err = app.db.CreateInvitedUser(signup.InviteCode, u.ID)
if err != nil {
return nil, err
}
}
// Add back unencrypted data for response
if signup.Email != "" {
u.Email.String = signup.Email
}
resUser := &AuthUser{
User: u,
}
title := signup.Alias
if signup.Normalize {
title = desiredUsername
}
resUser.Collections = &[]Collection{
{
Alias: signup.Alias,
Title: title,
Description: signup.Description,
},
}
var coll *Collection
if signup.Monetization != "" {
if coll == nil {
coll, err = app.db.GetCollection(signup.Alias)
if err != nil {
log.Error("Unable to get new collection '%s' for monetization on signup: %v", signup.Alias, err)
return nil, err
}
}
err = app.db.SetCollectionAttribute(coll.ID, "monetization_pointer", signup.Monetization)
if err != nil {
log.Error("Unable to add monetization on signup: %v", err)
return nil, err
}
coll.Monetization = signup.Monetization
}
var token string
if reqJSON && !signup.Web {
token, err = app.db.GetAccessToken(u.ID)
if err != nil {
return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."}
}
resUser.AccessToken = token
} else {
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {
// The cookie should still save, even if there's an error.
// Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144
log.Error("Session: %v; ignoring", err)
}
session.Values[cookieUserVal] = resUser.User.Cookie()
err = session.Save(r, w)
if err != nil {
log.Error("Couldn't save session: %v", err)
return nil, err
}
}
if reqJSON {
return resUser, impart.WriteSuccess(w, resUser, http.StatusCreated)
}
return resUser, nil
}
func viewLogout(app *App, w http.ResponseWriter, r *http.Request) error {
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {
return ErrInternalCookieSession
}
// Ensure user has an email or password set before they go, so they don't
// lose access to their account.
val := session.Values[cookieUserVal]
var u = &User{}
var ok bool
if u, ok = val.(*User); !ok {
log.Error("Error casting user object on logout. Vals: %+v Resetting cookie.", session.Values)
err = session.Save(r, w)
if err != nil {
log.Error("Couldn't save session on logout: %v", err)
return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."}
}
return impart.HTTPError{http.StatusFound, "/"}
}
u, err = app.db.GetUserByID(u.ID)
if err != nil && err != ErrUserNotFound {
return impart.HTTPError{http.StatusInternalServerError, "Unable to fetch user information."}
}
session.Options.MaxAge = -1
err = session.Save(r, w)
if err != nil {
log.Error("Couldn't save session on logout: %v", err)
return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."}
}
return impart.HTTPError{http.StatusFound, "/"}
}
func handleAPILogout(app *App, w http.ResponseWriter, r *http.Request) error {
accessToken := r.Header.Get("Authorization")
if accessToken == "" {
return ErrNoAccessToken
}
t := auth.GetToken(accessToken)
if len(t) == 0 {
return ErrNoAccessToken
}
err := app.db.DeleteToken(t)
if err != nil {
return err
}
return impart.HTTPError{Status: http.StatusNoContent}
}
func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
var earlyError string
oneTimeToken := r.FormValue("with")
if oneTimeToken != "" {
log.Info("Calling login with one-time token.")
err := login(app, w, r)
if err != nil {
log.Info("Received error: %v", err)
earlyError = fmt.Sprintf("%s", err)
}
}
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {
// Ignore this
log.Error("Unable to get session; ignoring: %v", err)
}
+ var setLang = localize(app.cfg.App.Lang)
+
p := &struct {
page.StaticPage
*OAuthButtons
To string
Message template.HTML
Flashes []template.HTML
LoginUsername string
}{
StaticPage: pageForReq(app, r),
OAuthButtons: NewOAuthButtons(app.Config()),
To: r.FormValue("to"),
Message: template.HTML(""),
Flashes: []template.HTML{},
LoginUsername: getTempInfo(app, "login-user", r, w),
}
if earlyError != "" {
p.Flashes = append(p.Flashes, template.HTML(earlyError))
}
// Display any error messages
flashes, _ := getSessionFlashes(app, w, r, session)
for _, flash := range flashes {
- p.Flashes = append(p.Flashes, template.HTML(flash))
+ fmt.Printf("%T\\n", flash)
+ p.Flashes = append(p.Flashes, template.HTML(setLang.Get(flash)))
}
err = pages["login.tmpl"].ExecuteTemplate(w, "base", p)
if err != nil {
log.Error("Unable to render login: %v", err)
return err
}
return nil
}
func webLogin(app *App, w http.ResponseWriter, r *http.Request) error {
err := login(app, w, r)
if err != nil {
username := r.FormValue("alias")
// Login request was unsuccessful; save the error in the session and redirect them
if err, ok := err.(impart.HTTPError); ok {
session, _ := app.sessionStore.Get(r, cookieName)
if session != nil {
session.AddFlash(err.Message)
session.Save(r, w)
}
if m := actuallyUsernameReg.FindStringSubmatch(err.Message); len(m) > 0 {
// Retain fixed username recommendation for the login form
username = m[1]
}
}
// Pass along certain information
saveTempInfo(app, "login-user", username, r, w)
// Retain post-login URL if one was given
redirectTo := "/login"
postLoginRedirect := r.FormValue("to")
if postLoginRedirect != "" {
redirectTo += "?to=" + postLoginRedirect
}
log.Error("Unable to login: %v", err)
return impart.HTTPError{http.StatusTemporaryRedirect, redirectTo}
}
return nil
}
var loginAttemptUsers = sync.Map{}
func login(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r)
oneTimeToken := r.FormValue("with")
verbose := r.FormValue("all") == "true" || r.FormValue("verbose") == "1" || r.FormValue("verbose") == "true" || (reqJSON && oneTimeToken != "")
redirectTo := r.FormValue("to")
if redirectTo == "" {
if app.cfg.App.SingleUser {
redirectTo = "/me/new"
} else {
redirectTo = "/"
}
}
var u *User
var err error
var signin userCredentials
if app.cfg.App.DisablePasswordAuth {
err := ErrDisabledPasswordAuth
return err
}
// Log in with one-time token if one is given
if oneTimeToken != "" {
log.Info("Login: Logging user in via token.")
userID := app.db.GetUserID(oneTimeToken)
if userID == -1 {
log.Error("Login: Got user -1 from token")
err := ErrBadAccessToken
err.Message = "Expired or invalid login code."
return err
}
log.Info("Login: Found user %d.", userID)
u, err = app.db.GetUserByID(userID)
if err != nil {
log.Error("Unable to fetch user on one-time token login: %v", err)
return impart.HTTPError{http.StatusInternalServerError, "There was an error retrieving the user you want."}
}
log.Info("Login: Got user via token")
} else {
// Get params
if reqJSON {
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&signin)
if err != nil {
log.Error("Couldn't parse signin JSON request: %v\n", err)
return ErrBadJSON
}
} else {
err := r.ParseForm()
if err != nil {
log.Error("Couldn't parse signin form request: %v\n", err)
return ErrBadFormData
}
err = app.formDecoder.Decode(&signin, r.PostForm)
if err != nil {
log.Error("Couldn't decode signin form request: %v\n", err)
return ErrBadFormData
}
}
log.Info("Login: Attempting login for '%s'", signin.Alias)
// Validate required params (all)
if signin.Alias == "" {
msg := "Parameter `alias` required."
if signin.Web {
msg = "A username is required."
}
return impart.HTTPError{http.StatusBadRequest, msg}
}
if !signin.EmailLogin && signin.Pass == "" {
msg := "Parameter `pass` required."
if signin.Web {
msg = "A password is required."
}
return impart.HTTPError{http.StatusBadRequest, msg}
}
// Prevent excessive login attempts on the same account
// Skip this check in dev environment
if !app.cfg.Server.Dev {
now := time.Now()
attemptExp, att := loginAttemptUsers.LoadOrStore(signin.Alias, now.Add(loginAttemptExpiration))
if att {
if attemptExpTime, ok := attemptExp.(time.Time); ok {
if attemptExpTime.After(now) {
// This user attempted previously, and the period hasn't expired yet
return impart.HTTPError{http.StatusTooManyRequests, "You're doing that too much."}
} else {
// This user attempted previously, but the time expired; free up space
loginAttemptUsers.Delete(signin.Alias)
}
} else {
log.Error("Unable to cast expiration to time")
}
}
}
// Retrieve password
u, err = app.db.GetUserForAuth(signin.Alias)
if err != nil {
log.Info("Unable to getUserForAuth on %s: %v", signin.Alias, err)
if strings.IndexAny(signin.Alias, "@") > 0 {
log.Info("Suggesting: %s", ErrUserNotFoundEmail.Message)
return ErrUserNotFoundEmail
}
return err
}
// Authenticate
if u.Email.String == "" {
// User has no email set, so check if they haven't added a password, either,
// so we can return a more helpful error message.
if hasPass, _ := app.db.IsUserPassSet(u.ID); !hasPass {
log.Info("Tried logging in to %s, but no password or email.", signin.Alias)
return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."}
}
}
if len(u.HashedPass) == 0 {
return impart.HTTPError{http.StatusUnauthorized, "This user never set a password. Perhaps try logging in via OAuth?"}
}
if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) {
return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
}
}
if reqJSON && !signin.Web {
var token string
if r.Header.Get("User-Agent") == "" {
// Get last created token when User-Agent is empty
token = app.db.FetchLastAccessToken(u.ID)
if token == "" {
token, err = app.db.GetAccessToken(u.ID)
}
} else {
token, err = app.db.GetAccessToken(u.ID)
}
if err != nil {
log.Error("Login: Unable to create access token: %v", err)
return impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."}
}
resUser := getVerboseAuthUser(app, token, u, verbose)
return impart.WriteSuccess(w, resUser, http.StatusOK)
}
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {
// The cookie should still save, even if there's an error.
log.Error("Login: Session: %v; ignoring", err)
}
// Remove unwanted data
session.Values[cookieUserVal] = u.Cookie()
err = session.Save(r, w)
if err != nil {
log.Error("Login: Couldn't save session: %v", err)
// TODO: return error
}
// Send success
if reqJSON {
return impart.WriteSuccess(w, &AuthUser{User: u}, http.StatusOK)
}
log.Info("Login: Redirecting to %s", redirectTo)
w.Header().Set("Location", redirectTo)
w.WriteHeader(http.StatusFound)
return nil
}
func getVerboseAuthUser(app *App, token string, u *User, verbose bool) *AuthUser {
resUser := &AuthUser{
AccessToken: token,
User: u,
}
// Fetch verbose user data if requested
if verbose {
posts, err := app.db.GetUserPosts(u)
if err != nil {
log.Error("Login: Unable to get user posts: %v", err)
}
colls, err := app.db.GetCollections(u, app.cfg.App.Host)
if err != nil {
log.Error("Login: Unable to get user collections: %v", err)
}
passIsSet, err := app.db.IsUserPassSet(u.ID)
if err != nil {
// TODO: correct error meesage
log.Error("Login: Unable to get user collections: %v", err)
}
resUser.Posts = posts
resUser.Collections = colls
resUser.User.HasPass = passIsSet
}
return resUser
}
func viewExportOptions(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
// Fetch extra user data
p := NewUserPage(app, r, u, "Export", nil)
showUserPage(w, "export", p)
return nil
}
func viewExportPosts(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
var filename string
var u = &User{}
reqJSON := IsJSON(r)
if reqJSON {
// Use given Authorization header
accessToken := r.Header.Get("Authorization")
if accessToken == "" {
return nil, filename, ErrNoAccessToken
}
userID := app.db.GetUserID(accessToken)
if userID == -1 {
return nil, filename, ErrBadAccessToken
}
var err error
u, err = app.db.GetUserByID(userID)
if err != nil {
return nil, filename, impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve requested user."}
}
} else {
// Use user cookie
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {
// The cookie should still save, even if there's an error.
log.Error("Session: %v; ignoring", err)
}
val := session.Values[cookieUserVal]
var ok bool
if u, ok = val.(*User); !ok {
return nil, filename, ErrNotLoggedIn
}
}
filename = u.Username + "-posts-" + time.Now().Truncate(time.Second).UTC().Format("200601021504")
// Fetch data we're exporting
var err error
var data []byte
posts, err := app.db.GetUserPosts(u)
if err != nil {
return data, filename, err
}
// Export as CSV
if strings.HasSuffix(r.URL.Path, ".csv") {
data = exportPostsCSV(app.cfg.App.Host, u, posts)
return data, filename, err
}
if strings.HasSuffix(r.URL.Path, ".zip") {
data = exportPostsZip(u, posts)
return data, filename, err
}
if r.FormValue("pretty") == "1" {
data, err = json.MarshalIndent(posts, "", "\t")
} else {
data, err = json.Marshal(posts)
}
return data, filename, err
}
func viewExportFull(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
var err error
filename := ""
u := getUserSession(app, r)
if u == nil {
return nil, filename, ErrNotLoggedIn
}
filename = u.Username + "-" + time.Now().Truncate(time.Second).UTC().Format("200601021504")
exportUser := compileFullExport(app, u)
var data []byte
if r.FormValue("pretty") == "1" {
data, err = json.MarshalIndent(exportUser, "", "\t")
} else {
data, err = json.Marshal(exportUser)
}
return data, filename, err
}
func viewMeAPI(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r)
uObj := struct {
ID int64 `json:"id,omitempty"`
Username string `json:"username,omitempty"`
}{}
var err error
if reqJSON {
_, uObj.Username, err = app.db.GetUserDataFromToken(r.Header.Get("Authorization"))
if err != nil {
return err
}
} else {
u := getUserSession(app, r)
if u == nil {
return impart.WriteSuccess(w, uObj, http.StatusOK)
}
uObj.Username = u.Username
}
return impart.WriteSuccess(w, uObj, http.StatusOK)
}
func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r)
if !reqJSON {
return ErrBadRequestedType
}
isAnonPosts := r.FormValue("anonymous") == "1"
if isAnonPosts {
pageStr := r.FormValue("page")
pg, err := strconv.Atoi(pageStr)
if err != nil {
log.Error("Error parsing page parameter '%s': %s", pageStr, err)
pg = 1
}
p, err := app.db.GetAnonymousPosts(u, pg)
if err != nil {
return err
}
return impart.WriteSuccess(w, p, http.StatusOK)
}
var err error
p := GetPostsCache(u.ID)
if p == nil {
userPostsCache.Lock()
if userPostsCache.users[u.ID].ready == nil {
userPostsCache.users[u.ID] = postsCacheItem{ready: make(chan struct{})}
userPostsCache.Unlock()
p, err = app.db.GetUserPosts(u)
if err != nil {
return err
}
CachePosts(u.ID, p)
} else {
userPostsCache.Unlock()
<-userPostsCache.users[u.ID].ready
p = GetPostsCache(u.ID)
}
}
return impart.WriteSuccess(w, p, http.StatusOK)
}
func viewMyCollectionsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r)
if !reqJSON {
return ErrBadRequestedType
}
p, err := app.db.GetCollections(u, app.cfg.App.Host)
if err != nil {
return err
}
return impart.WriteSuccess(w, p, http.StatusOK)
}
func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p, err := app.db.GetAnonymousPosts(u, 1)
if err != nil {
log.Error("unable to fetch anon posts: %v", err)
}
// nil-out AnonymousPosts slice for easy detection in the template
if p != nil && len(*p) == 0 {
p = nil
}
f, err := getSessionFlashes(app, w, r, nil)
if err != nil {
log.Error("unable to fetch flashes: %v", err)
}
c, err := app.db.GetPublishableCollections(u, app.cfg.App.Host)
if err != nil {
log.Error("unable to fetch collections: %v", err)
}
silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil {
if err == ErrUserNotFound {
return err
}
log.Error("view articles: %v", err)
}
d := struct {
*UserPage
AnonymousPosts *[]PublicPost
Collections *[]Collection
Silenced bool
}{
UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f),
AnonymousPosts: p,
Collections: c,
Silenced: silenced,
}
d.UserPage.SetMessaging(u)
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Expires", "Thu, 04 Oct 1990 20:00:00 GMT")
showUserPage(w, "articles", d)
return nil
}
func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
c, err := app.db.GetCollections(u, app.cfg.App.Host)
if err != nil {
log.Error("unable to fetch collections: %v", err)
return fmt.Errorf("No collections")
}
f, _ := getSessionFlashes(app, w, r, nil)
uc, _ := app.db.GetUserCollectionCount(u.ID)
// TODO: handle any errors
silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil {
if err == ErrUserNotFound {
return err
}
log.Error("view collections: %v", err)
return fmt.Errorf("view collections: %v", err)
}
d := struct {
*UserPage
Collections *[]Collection
UsedCollections, TotalCollections int
NewBlogsDisabled bool
Silenced bool
}{
UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f),
Collections: c,
UsedCollections: int(uc),
NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc),
Silenced: silenced,
}
d.UserPage.SetMessaging(u)
showUserPage(w, "collections", d)
return nil
}
func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
c, err := app.db.GetCollection(vars["collection"])
if err != nil {
return err
}
if c.OwnerID != u.ID {
return ErrCollectionNotFound
}
// Add collection properties
c.Monetization = app.db.GetCollectionAttribute(c.ID, "monetization_pointer")
silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil {
if err == ErrUserNotFound {
return err
}
log.Error("view edit collection %v", err)
return fmt.Errorf("view edit collection: %v", err)
}
flashes, _ := getSessionFlashes(app, w, r, nil)
obj := struct {
*UserPage
*Collection
Silenced bool
}{
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
Collection: c,
Silenced: silenced,
}
obj.UserPage.CollAlias = c.Alias
showUserPage(w, "collection", obj)
return nil
}
func updateSettings(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r)
var s userSettings
var u *User
var sess *sessions.Session
var err error
if reqJSON {
accessToken := r.Header.Get("Authorization")
if accessToken == "" {
return ErrNoAccessToken
}
u, err = app.db.GetAPIUser(accessToken)
if err != nil {
return ErrBadAccessToken
}
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&s)
if err != nil {
log.Error("Couldn't parse settings JSON request: %v\n", err)
return ErrBadJSON
}
// Prevent all username updates
// TODO: support changing username via JSON API request
s.Username = ""
} else {
u, sess = getUserAndSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
err := r.ParseForm()
if err != nil {
log.Error("Couldn't parse settings form request: %v\n", err)
return ErrBadFormData
}
err = app.formDecoder.Decode(&s, r.PostForm)
if err != nil {
log.Error("Couldn't decode settings form request: %v\n", err)
return ErrBadFormData
}
}
// Do update
postUpdateReturn := r.FormValue("return")
redirectTo := "/me/settings"
if s.IsLogOut {
redirectTo += "?logout=1"
} else if postUpdateReturn != "" {
redirectTo = postUpdateReturn
}
// Only do updates on values we need
if s.Username != "" && s.Username == u.Username {
// Username hasn't actually changed; blank it out
s.Username = ""
}
err = app.db.ChangeSettings(app, u, &s)
if err != nil {
if reqJSON {
return err
}
if err, ok := err.(impart.HTTPError); ok {
addSessionFlash(app, w, r, err.Message, nil)
}
} else {
// Successful update.
if reqJSON {
return impart.WriteSuccess(w, u, http.StatusOK)
}
if s.IsLogOut {
redirectTo = "/me/logout"
} else {
sess.Values[cookieUserVal] = u.Cookie()
addSessionFlash(app, w, r, "Account updated.", nil)
}
}
w.Header().Set("Location", redirectTo)
w.WriteHeader(http.StatusFound)
return nil
}
func updatePassphrase(app *App, w http.ResponseWriter, r *http.Request) error {
accessToken := r.Header.Get("Authorization")
if accessToken == "" {
return ErrNoAccessToken
}
curPass := r.FormValue("current")
newPass := r.FormValue("new")
// Ensure a new password is given (always required)
if newPass == "" {
return impart.HTTPError{http.StatusBadRequest, "Provide a new password."}
}
userID, sudo := app.db.GetUserIDPrivilege(accessToken)
if userID == -1 {
return ErrBadAccessToken
}
// Ensure a current password is given if the access token doesn't have sudo
// privileges.
if !sudo && curPass == "" {
return impart.HTTPError{http.StatusBadRequest, "Provide current password."}
}
// Hash the new password
hashedPass, err := auth.HashPass([]byte(newPass))
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
}
// Do update
err = app.db.ChangePassphrase(userID, sudo, curPass, hashedPass)
if err != nil {
return err
}
return impart.WriteSuccess(w, struct{}{}, http.StatusOK)
}
func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
var c *Collection
var err error
vars := mux.Vars(r)
alias := vars["collection"]
if alias != "" {
c, err = app.db.GetCollection(alias)
if err != nil {
return err
}
if c.OwnerID != u.ID {
return ErrCollectionNotFound
}
c.hostName = app.cfg.App.Host
}
topPosts, err := app.db.GetTopPosts(u, alias, c.hostName)
if err != nil {
log.Error("Unable to get top posts: %v", err)
return err
}
flashes, _ := getSessionFlashes(app, w, r, nil)
titleStats := ""
if c != nil {
titleStats = c.DisplayTitle() + " "
}
silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil {
if err == ErrUserNotFound {
return err
}
log.Error("view stats: %v", err)
return err
}
obj := struct {
*UserPage
VisitsBlog string
Collection *Collection
TopPosts *[]PublicPost
APFollowers int
Silenced bool
}{
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
VisitsBlog: alias,
Collection: c,
TopPosts: topPosts,
Silenced: silenced,
}
obj.UserPage.CollAlias = c.Alias
if app.cfg.App.Federation {
folls, err := app.db.GetAPFollowers(c)
if err != nil {
return err
}
obj.APFollowers = len(*folls)
}
showUserPage(w, "stats", obj)
return nil
}
func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
fullUser, err := app.db.GetUserByID(u.ID)
if err != nil {
if err == ErrUserNotFound {
return err
}
log.Error("Unable to get user for settings: %s", err)
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
}
passIsSet, err := app.db.IsUserPassSet(u.ID)
if err != nil {
log.Error("Unable to get isUserPassSet for settings: %s", err)
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
}
flashes, _ := getSessionFlashes(app, w, r, nil)
enableOauthSlack := app.Config().SlackOauth.ClientID != ""
enableOauthWriteAs := app.Config().WriteAsOauth.ClientID != ""
enableOauthGitLab := app.Config().GitlabOauth.ClientID != ""
enableOauthGeneric := app.Config().GenericOauth.ClientID != ""
enableOauthGitea := app.Config().GiteaOauth.ClientID != ""
oauthAccounts, err := app.db.GetOauthAccounts(r.Context(), u.ID)
if err != nil {
log.Error("Unable to get oauth accounts for settings: %s", err)
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
}
for idx, oauthAccount := range oauthAccounts {
switch oauthAccount.Provider {
case "slack":
enableOauthSlack = false
case "write.as":
enableOauthWriteAs = false
case "gitlab":
enableOauthGitLab = false
case "generic":
oauthAccounts[idx].DisplayName = app.Config().GenericOauth.DisplayName
oauthAccounts[idx].AllowDisconnect = app.Config().GenericOauth.AllowDisconnect
enableOauthGeneric = false
case "gitea":
enableOauthGitea = false
}
}
displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || enableOauthGeneric || enableOauthGitea || len(oauthAccounts) > 0
obj := struct {
*UserPage
Email string
HasPass bool
IsLogOut bool
Silenced bool
CSRFField template.HTML
OauthSection bool
OauthAccounts []oauthAccountInfo
OauthSlack bool
OauthWriteAs bool
OauthGitLab bool
GitLabDisplayName string
OauthGeneric bool
OauthGenericDisplayName string
OauthGitea bool
GiteaDisplayName string
}{
UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
Email: fullUser.EmailClear(app.keys),
HasPass: passIsSet,
IsLogOut: r.FormValue("logout") == "1",
Silenced: fullUser.IsSilenced(),
CSRFField: csrf.TemplateField(r),
OauthSection: displayOauthSection,
OauthAccounts: oauthAccounts,
OauthSlack: enableOauthSlack,
OauthWriteAs: enableOauthWriteAs,
OauthGitLab: enableOauthGitLab,
GitLabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName),
OauthGeneric: enableOauthGeneric,
OauthGenericDisplayName: config.OrDefaultString(app.Config().GenericOauth.DisplayName, genericOauthDisplayName),
OauthGitea: enableOauthGitea,
GiteaDisplayName: config.OrDefaultString(app.Config().GiteaOauth.DisplayName, giteaDisplayName),
}
showUserPage(w, "settings", obj)
return nil
}
func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error {
session, err := app.sessionStore.Get(r, "t")
if err != nil {
return ErrInternalCookieSession
}
session.Values[key] = val
err = session.Save(r, w)
if err != nil {
log.Error("Couldn't saveTempInfo for key-val (%s:%s): %v", key, val, err)
}
return err
}
func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) string {
session, err := app.sessionStore.Get(r, "t")
if err != nil {
return ""
}
// Get the information
var s = ""
var ok bool
if s, ok = session.Values[key].(string); !ok {
return ""
}
// Delete cookie
session.Options.MaxAge = -1
err = session.Save(r, w)
if err != nil {
log.Error("Couldn't erase temp data for key %s: %v", key, err)
}
// Return value
return s
}
func handleUserDelete(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
if !app.cfg.App.OpenDeletion {
return impart.HTTPError{http.StatusForbidden, "Open account deletion is disabled on this instance."}
}
confirmUsername := r.PostFormValue("confirm-username")
if u.Username != confirmUsername {
return impart.HTTPError{http.StatusBadRequest, "Confirmation username must match your username exactly."}
}
// Check for account deletion safeguards in place
if u.IsAdmin() {
return impart.HTTPError{http.StatusForbidden, "Cannot delete admin."}
}
err := app.db.DeleteAccount(u.ID)
if err != nil {
log.Error("user delete account: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not delete account: %v", err)}
}
// FIXME: This doesn't ever appear to the user, as (I believe) the value is erased when the session cookie is reset
_ = addSessionFlash(app, w, r, "Thanks for writing with us! You account was deleted successfully.", nil)
return impart.HTTPError{http.StatusFound, "/me/logout"}
}
func removeOauth(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
provider := r.FormValue("provider")
clientID := r.FormValue("client_id")
remoteUserID := r.FormValue("remote_user_id")
err := app.db.RemoveOauth(r.Context(), u.ID, provider, clientID, remoteUserID)
if err != nil {
return impart.HTTPError{Status: http.StatusInternalServerError, Message: err.Error()}
}
return impart.HTTPError{Status: http.StatusFound, Message: "/me/settings"}
}
func prepareUserEmail(input string, emailKey []byte) zero.String {
email := zero.NewString("", input != "")
if len(input) > 0 {
encEmail, err := data.Encrypt(emailKey, input)
if err != nil {
log.Error("Unable to encrypt email: %s\n", err)
} else {
email.String = string(encEmail)
}
}
return email
}
diff --git a/admin.go b/admin.go
index 6408cfe..8b48cd8 100644
--- a/admin.go
+++ b/admin.go
@@ -1,675 +1,675 @@
/*
* Copyright © 2018-2021 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"database/sql"
"fmt"
"net/http"
"runtime"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/writeas/impart"
"github.com/writeas/web-core/auth"
"github.com/writeas/web-core/log"
"github.com/writeas/web-core/passgen"
"github.com/writefreely/writefreely/appstats"
"github.com/writefreely/writefreely/config"
)
var (
appStartTime = time.Now()
sysStatus systemStatus
)
const adminUsersPerPage = 30
type systemStatus struct {
Uptime string
NumGoroutine int
// General statistics.
MemAllocated string // bytes allocated and still in use
MemTotal string // bytes allocated (even if freed)
MemSys string // bytes obtained from system (sum of XxxSys below)
Lookups uint64 // number of pointer lookups
MemMallocs uint64 // number of mallocs
MemFrees uint64 // number of frees
// Main allocation heap statistics.
HeapAlloc string // bytes allocated and still in use
HeapSys string // bytes obtained from system
HeapIdle string // bytes in idle spans
HeapInuse string // bytes in non-idle span
HeapReleased string // bytes released to the OS
HeapObjects uint64 // total number of allocated objects
// Low-level fixed-size structure allocator statistics.
// Inuse is bytes used now.
// Sys is bytes obtained from system.
StackInuse string // bootstrap stacks
StackSys string
MSpanInuse string // mspan structures
MSpanSys string
MCacheInuse string // mcache structures
MCacheSys string
BuckHashSys string // profiling bucket hash table
GCSys string // GC metadata
OtherSys string // other system allocations
// Garbage collector statistics.
NextGC string // next run in HeapAlloc time (bytes)
LastGC string // last run in absolute time (ns)
PauseTotalNs string
PauseNs string // circular buffer of recent GC pause times, most recent at [(NumGC+255)%256]
NumGC uint32
}
type inspectedCollection struct {
CollectionObj
Followers int
LastPost string
}
type instanceContent struct {
ID string
Type string
Title sql.NullString
Content string
Updated time.Time
}
type AdminPage struct {
UpdateAvailable bool
}
func NewAdminPage(app *App) *AdminPage {
ap := &AdminPage{}
if app.updates != nil {
ap.UpdateAvailable = app.updates.AreAvailableNoCheck()
}
return ap
}
func (c instanceContent) UpdatedFriendly() string {
/*
// TODO: accept a locale in this method and use that for the format
var loc monday.Locale = monday.LocaleEnUS
return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc)
*/
return c.Updated.Format("January 2, 2006, 3:04 PM")
}
func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
*AdminPage
Message string
UsersCount, CollectionsCount, PostsCount int64
}{
UserPage: NewUserPage(app, r, u, "Admin", nil),
AdminPage: NewAdminPage(app),
Message: r.FormValue("m"),
}
// Get user stats
p.UsersCount = app.db.GetAllUsersCount()
var err error
p.CollectionsCount, err = app.db.GetTotalCollections()
if err != nil {
return err
}
p.PostsCount, err = app.db.GetTotalPosts()
if err != nil {
return err
}
showUserPage(w, "admin", p)
return nil
}
func handleViewAdminMonitor(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
updateAppStats()
p := struct {
*UserPage
*AdminPage
SysStatus systemStatus
Config config.AppCfg
Message, ConfigMessage string
}{
UserPage: NewUserPage(app, r, u, "Admin", nil),
AdminPage: NewAdminPage(app),
SysStatus: sysStatus,
Config: app.cfg.App,
Message: r.FormValue("m"),
ConfigMessage: r.FormValue("cm"),
}
showUserPage(w, "monitor", p)
return nil
}
func handleViewAdminSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
*AdminPage
Config config.AppCfg
Message, ConfigMessage string
}{
UserPage: NewUserPage(app, r, u, "Admin", nil),
AdminPage: NewAdminPage(app),
Config: app.cfg.App,
Message: r.FormValue("m"),
ConfigMessage: r.FormValue("cm"),
}
showUserPage(w, "app-settings", p)
return nil
}
func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
*AdminPage
Config config.AppCfg
Message string
Flashes []string
Users *[]User
CurPage int
TotalUsers int64
TotalPages []int
}{
UserPage: NewUserPage(app, r, u, "Users", nil),
AdminPage: NewAdminPage(app),
Config: app.cfg.App,
Message: r.FormValue("m"),
}
p.Flashes, _ = getSessionFlashes(app, w, r, nil)
p.TotalUsers = app.db.GetAllUsersCount()
ttlPages := p.TotalUsers / adminUsersPerPage
p.TotalPages = []int{}
for i := 1; i <= int(ttlPages); i++ {
p.TotalPages = append(p.TotalPages, i)
}
var err error
p.CurPage, err = strconv.Atoi(r.FormValue("p"))
if err != nil || p.CurPage < 1 {
p.CurPage = 1
} else if p.CurPage > int(ttlPages) {
p.CurPage = int(ttlPages)
}
p.Users, err = app.db.GetAllUsers(uint(p.CurPage))
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get users: %v", err)}
}
showUserPage(w, "users", p)
return nil
}
func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
username := vars["username"]
if username == "" {
return impart.HTTPError{http.StatusFound, "/admin/users"}
}
p := struct {
*UserPage
*AdminPage
Config config.AppCfg
Message string
User *User
Colls []inspectedCollection
LastPost string
NewPassword string
TotalPosts int64
ClearEmail string
}{
AdminPage: NewAdminPage(app),
Config: app.cfg.App,
Message: r.FormValue("m"),
Colls: []inspectedCollection{},
}
var err error
p.User, err = app.db.GetUserForAuth(username)
if err != nil {
if err == ErrUserNotFound {
return err
}
log.Error("Could not get user: %v", err)
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
}
flashes, _ := getSessionFlashes(app, w, r, nil)
for _, flash := range flashes {
if strings.HasPrefix(flash, "SUCCESS: ") {
p.NewPassword = strings.TrimPrefix(flash, "SUCCESS: ")
p.ClearEmail = p.User.EmailClear(app.keys)
}
}
p.UserPage = NewUserPage(app, r, u, p.User.Username, nil)
p.TotalPosts = app.db.GetUserPostsCount(p.User.ID)
lp, err := app.db.GetUserLastPostTime(p.User.ID)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's last post time: %v", err)}
}
if lp != nil {
p.LastPost = lp.Format("January 2, 2006, 3:04 PM")
}
colls, err := app.db.GetCollections(p.User, app.cfg.App.Host)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's collections: %v", err)}
}
for _, c := range *colls {
ic := inspectedCollection{
CollectionObj: CollectionObj{Collection: c},
}
if app.cfg.App.Federation {
folls, err := app.db.GetAPFollowers(&c)
if err == nil {
// TODO: handle error here (at least log it)
ic.Followers = len(*folls)
}
}
app.db.GetPostsCount(&ic.CollectionObj, true)
lp, err := app.db.GetCollectionLastPostTime(c.ID)
if err != nil {
log.Error("Didn't get last post time for collection %d: %v", c.ID, err)
}
if lp != nil {
ic.LastPost = lp.Format("January 2, 2006, 3:04 PM")
}
p.Colls = append(p.Colls, ic)
}
showUserPage(w, "view-user", p)
return nil
}
func handleAdminDeleteUser(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
if !u.IsAdmin() {
return impart.HTTPError{http.StatusForbidden, "Administrator privileges required for this action"}
}
vars := mux.Vars(r)
username := vars["username"]
confirmUsername := r.PostFormValue("confirm-username")
if confirmUsername != username {
return impart.HTTPError{http.StatusBadRequest, "Username was not confirmed"}
}
user, err := app.db.GetUserForAuth(username)
if err == ErrUserNotFound {
return impart.HTTPError{http.StatusNotFound, fmt.Sprintf("User '%s' was not found", username)}
} else if err != nil {
log.Error("get user for deletion: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user with username '%s': %v", username, err)}
}
err = app.db.DeleteAccount(user.ID)
if err != nil {
log.Error("delete user %s: %v", user.Username, err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not delete user account for '%s': %v", username, err)}
}
_ = addSessionFlash(app, w, r, fmt.Sprintf("User \"%s\" was deleted successfully.", username), nil)
return impart.HTTPError{http.StatusFound, "/admin/users"}
}
func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
username := vars["username"]
if username == "" {
return impart.HTTPError{http.StatusFound, "/admin/users"}
}
user, err := app.db.GetUserForAuth(username)
if err != nil {
log.Error("failed to get user: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user from username: %v", err)}
}
if user.IsSilenced() {
err = app.db.SetUserStatus(user.ID, UserActive)
} else {
err = app.db.SetUserStatus(user.ID, UserSilenced)
// reset the cache to removed silence user posts
updateTimelineCache(app.timeline, true)
}
if err != nil {
log.Error("toggle user silenced: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v", err)}
}
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)}
}
func handleAdminResetUserPass(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
username := vars["username"]
if username == "" {
return impart.HTTPError{http.StatusFound, "/admin/users"}
}
// Generate new random password since none supplied
pass := passgen.NewWordish()
hashedPass, err := auth.HashPass([]byte(pass))
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)}
}
userIDVal := r.FormValue("user")
log.Info("ADMIN: Changing user %s password", userIDVal)
id, err := strconv.Atoi(userIDVal)
if err != nil {
return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Invalid user ID: %v", err)}
}
err = app.db.ChangePassphrase(int64(id), true, "", hashedPass)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)}
}
log.Info("ADMIN: Successfully changed.")
addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: %s", pass), nil)
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s", username)}
}
func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
*AdminPage
Config config.AppCfg
Message string
Pages []*instanceContent
}{
UserPage: NewUserPage(app, r, u, "Pages", nil),
AdminPage: NewAdminPage(app),
Config: app.cfg.App,
Message: r.FormValue("m"),
}
var err error
p.Pages, err = app.db.GetInstancePages()
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get pages: %v", err)}
}
// Add in default pages
var hasAbout, hasPrivacy bool
for i, c := range p.Pages {
if hasAbout && hasPrivacy {
break
}
if c.ID == "about" {
hasAbout = true
if !c.Title.Valid {
p.Pages[i].Title = defaultAboutTitle(app.cfg)
}
} else if c.ID == "privacy" {
hasPrivacy = true
if !c.Title.Valid {
- p.Pages[i].Title = defaultPrivacyTitle()
+ p.Pages[i].Title = defaultPrivacyTitle(app.cfg)
}
}
}
if !hasAbout {
p.Pages = append(p.Pages, &instanceContent{
ID: "about",
Title: defaultAboutTitle(app.cfg),
Content: defaultAboutPage(app.cfg),
Updated: defaultPageUpdatedTime,
})
}
if !hasPrivacy {
p.Pages = append(p.Pages, &instanceContent{
ID: "privacy",
- Title: defaultPrivacyTitle(),
+ Title: defaultPrivacyTitle(app.cfg),
Content: defaultPrivacyPolicy(app.cfg),
Updated: defaultPageUpdatedTime,
})
}
showUserPage(w, "pages", p)
return nil
}
func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
slug := vars["slug"]
if slug == "" {
return impart.HTTPError{http.StatusFound, "/admin/pages"}
}
p := struct {
*UserPage
*AdminPage
Config config.AppCfg
Message string
Banner *instanceContent
Content *instanceContent
}{
AdminPage: NewAdminPage(app),
Config: app.cfg.App,
Message: r.FormValue("m"),
}
var err error
// Get pre-defined pages, or select slug
if slug == "about" {
p.Content, err = getAboutPage(app)
} else if slug == "privacy" {
p.Content, err = getPrivacyPage(app)
} else if slug == "landing" {
p.Banner, err = getLandingBanner(app)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)}
}
p.Content, err = getLandingBody(app)
p.Content.ID = "landing"
} else if slug == "reader" {
p.Content, err = getReaderSection(app)
} else {
p.Content, err = app.db.GetDynamicContent(slug)
}
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get page: %v", err)}
}
title := "New page"
if p.Content != nil {
title = "Edit " + p.Content.ID
} else {
p.Content = &instanceContent{}
}
p.UserPage = NewUserPage(app, r, u, title, nil)
showUserPage(w, "view-page", p)
return nil
}
func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
id := vars["page"]
// Validate
if id != "about" && id != "privacy" && id != "landing" && id != "reader" {
return impart.HTTPError{http.StatusNotFound, "No such page."}
}
var err error
m := ""
if id == "landing" {
// Handle special landing page
err = app.db.UpdateDynamicContent("landing-banner", "", r.FormValue("banner"), "section")
if err != nil {
m = "?m=" + err.Error()
return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m}
}
err = app.db.UpdateDynamicContent("landing-body", "", r.FormValue("content"), "section")
} else if id == "reader" {
// Update sections with titles
err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "section")
} else {
// Update page
err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "page")
}
if err != nil {
m = "?m=" + err.Error()
}
return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m}
}
func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error {
apper.App().cfg.App.SiteName = r.FormValue("site_name")
apper.App().cfg.App.SiteDesc = r.FormValue("site_desc")
apper.App().cfg.App.Landing = r.FormValue("landing")
apper.App().cfg.App.OpenRegistration = r.FormValue("open_registration") == "on"
apper.App().cfg.App.OpenDeletion = r.FormValue("open_deletion") == "on"
mul, err := strconv.Atoi(r.FormValue("min_username_len"))
if err == nil {
apper.App().cfg.App.MinUsernameLen = mul
}
mb, err := strconv.Atoi(r.FormValue("max_blogs"))
if err == nil {
apper.App().cfg.App.MaxBlogs = mb
}
apper.App().cfg.App.Federation = r.FormValue("federation") == "on"
apper.App().cfg.App.PublicStats = r.FormValue("public_stats") == "on"
apper.App().cfg.App.Monetization = r.FormValue("monetization") == "on"
apper.App().cfg.App.Private = r.FormValue("private") == "on"
apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on"
if apper.App().cfg.App.LocalTimeline && apper.App().timeline == nil {
log.Info("Initializing local timeline...")
initLocalTimeline(apper.App())
}
apper.App().cfg.App.UserInvites = r.FormValue("user_invites")
if apper.App().cfg.App.UserInvites == "none" {
apper.App().cfg.App.UserInvites = ""
}
apper.App().cfg.App.DefaultVisibility = r.FormValue("default_visibility")
m := "?cm=Configuration+saved."
err = apper.SaveConfig(apper.App().cfg)
if err != nil {
m = "?cm=" + err.Error()
}
return impart.HTTPError{http.StatusFound, "/admin/settings" + m + "#config"}
}
func updateAppStats() {
sysStatus.Uptime = appstats.TimeSincePro(appStartTime)
m := new(runtime.MemStats)
runtime.ReadMemStats(m)
sysStatus.NumGoroutine = runtime.NumGoroutine()
sysStatus.MemAllocated = appstats.FileSize(int64(m.Alloc))
sysStatus.MemTotal = appstats.FileSize(int64(m.TotalAlloc))
sysStatus.MemSys = appstats.FileSize(int64(m.Sys))
sysStatus.Lookups = m.Lookups
sysStatus.MemMallocs = m.Mallocs
sysStatus.MemFrees = m.Frees
sysStatus.HeapAlloc = appstats.FileSize(int64(m.HeapAlloc))
sysStatus.HeapSys = appstats.FileSize(int64(m.HeapSys))
sysStatus.HeapIdle = appstats.FileSize(int64(m.HeapIdle))
sysStatus.HeapInuse = appstats.FileSize(int64(m.HeapInuse))
sysStatus.HeapReleased = appstats.FileSize(int64(m.HeapReleased))
sysStatus.HeapObjects = m.HeapObjects
sysStatus.StackInuse = appstats.FileSize(int64(m.StackInuse))
sysStatus.StackSys = appstats.FileSize(int64(m.StackSys))
sysStatus.MSpanInuse = appstats.FileSize(int64(m.MSpanInuse))
sysStatus.MSpanSys = appstats.FileSize(int64(m.MSpanSys))
sysStatus.MCacheInuse = appstats.FileSize(int64(m.MCacheInuse))
sysStatus.MCacheSys = appstats.FileSize(int64(m.MCacheSys))
sysStatus.BuckHashSys = appstats.FileSize(int64(m.BuckHashSys))
sysStatus.GCSys = appstats.FileSize(int64(m.GCSys))
sysStatus.OtherSys = appstats.FileSize(int64(m.OtherSys))
sysStatus.NextGC = appstats.FileSize(int64(m.NextGC))
sysStatus.LastGC = fmt.Sprintf("%.1fs", float64(time.Now().UnixNano()-int64(m.LastGC))/1000/1000/1000)
sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000)
sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000)
sysStatus.NumGC = m.NumGC
}
func adminResetPassword(app *App, u *User, newPass string) error {
hashedPass, err := auth.HashPass([]byte(newPass))
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)}
}
err = app.db.ChangePassphrase(u.ID, true, "", hashedPass)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)}
}
return nil
}
func handleViewAdminUpdates(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
check := r.URL.Query().Get("check")
if check == "now" && app.cfg.App.UpdateChecks {
app.updates.CheckNow()
}
p := struct {
*UserPage
*AdminPage
CurReleaseNotesURL string
LastChecked string
LastChecked8601 string
LatestVersion string
LatestReleaseURL string
LatestReleaseNotesURL string
CheckFailed bool
}{
UserPage: NewUserPage(app, r, u, "Updates", nil),
AdminPage: NewAdminPage(app),
}
p.CurReleaseNotesURL = wfReleaseNotesURL(p.Version)
if app.cfg.App.UpdateChecks {
p.LastChecked = app.updates.lastCheck.Format("January 2, 2006, 3:04 PM")
p.LastChecked8601 = app.updates.lastCheck.Format("2006-01-02T15:04:05Z")
p.LatestVersion = app.updates.LatestVersion()
p.LatestReleaseURL = app.updates.ReleaseURL()
p.LatestReleaseNotesURL = app.updates.ReleaseNotesURL()
p.UpdateAvailable = app.updates.AreAvailable()
p.CheckFailed = app.updates.checkError != nil
}
showUserPage(w, "app-updates", p)
return nil
}
diff --git a/app.go b/app.go
index f86472c..6e193ce 100644
--- a/app.go
+++ b/app.go
@@ -1,944 +1,1079 @@
/*
* Copyright © 2018-2021 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"crypto/tls"
"database/sql"
_ "embed"
"fmt"
"html/template"
"io/ioutil"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
+ "encoding/json"
"github.com/gorilla/mux"
"github.com/gorilla/schema"
"github.com/gorilla/sessions"
"github.com/manifoldco/promptui"
stripmd "github.com/writeas/go-strip-markdown/v2"
"github.com/writeas/impart"
"github.com/writeas/web-core/auth"
"github.com/writeas/web-core/converter"
"github.com/writeas/web-core/log"
"golang.org/x/crypto/acme/autocert"
"github.com/writefreely/writefreely/author"
"github.com/writefreely/writefreely/config"
"github.com/writefreely/writefreely/key"
"github.com/writefreely/writefreely/migrations"
"github.com/writefreely/writefreely/page"
+ "github.com/leonelquinteros/gotext"
)
const (
staticDir = "static"
assumedTitleLen = 80
postsPerPage = 10
serverSoftware = "WriteFreely"
softwareURL = "https://writefreely.org"
)
var (
debugging bool
// Software version can be set from git env using -ldflags
softwareVer = "0.13.2"
// DEPRECATED VARS
isSingleUser bool
)
// App holds data and configuration for an individual WriteFreely instance.
type App struct {
router *mux.Router
shttp *http.ServeMux
db *datastore
cfg *config.Config
cfgFile string
keys *key.Keychain
sessionStore sessions.Store
formDecoder *schema.Decoder
updates *updatesCache
+ locales string
+ tr func (str string, ParamsToTranslate ...interface{}) interface{}
timeline *localTimeline
}
// DB returns the App's datastore
func (app *App) DB() *datastore {
return app.db
}
// Router returns the App's router
func (app *App) Router() *mux.Router {
return app.router
}
// Config returns the App's current configuration.
func (app *App) Config() *config.Config {
return app.cfg
}
// SetConfig updates the App's Config to the given value.
func (app *App) SetConfig(cfg *config.Config) {
app.cfg = cfg
}
// SetKeys updates the App's Keychain to the given value.
func (app *App) SetKeys(k *key.Keychain) {
app.keys = k
}
func (app *App) SessionStore() sessions.Store {
return app.sessionStore
}
func (app *App) SetSessionStore(s sessions.Store) {
app.sessionStore = s
}
// Apper is the interface for getting data into and out of a WriteFreely
// instance (or "App").
//
// App returns the App for the current instance.
//
// LoadConfig reads an app configuration into the App, returning any error
// encountered.
//
// SaveConfig persists the current App configuration.
//
// LoadKeys reads the App's encryption keys and loads them into its
// key.Keychain.
type Apper interface {
App() *App
LoadConfig() error
SaveConfig(*config.Config) error
LoadKeys() error
+ LoadLocales() error
+
ReqLog(r *http.Request, status int, timeSince time.Duration) string
}
// App returns the App
func (app *App) App() *App {
+ //softwareVer = "0.13.1"
+ //softwareVer = os.Getenv("VERSION")
return app
}
// LoadConfig loads and parses a config file.
func (app *App) LoadConfig() error {
log.Info("Loading %s configuration...", app.cfgFile)
cfg, err := config.Load(app.cfgFile)
if err != nil {
log.Error("Unable to load configuration: %v", err)
os.Exit(1)
return err
}
app.cfg = cfg
return nil
}
// SaveConfig saves the given Config to disk -- namely, to the App's cfgFile.
func (app *App) SaveConfig(c *config.Config) error {
return config.Save(c, app.cfgFile)
}
+// LoadLocales reads "json" locales file created from "po" locales.
+func (app *App) LoadLocales() error {
+ var err error
+ log.Info("Reading %s locales...", app.cfg.App.Lang)
+
+// ###############################################################################3
+type translator interface {}
+
+app.tr = func(str string, ParamsToTranslate ...interface{}) interface{} {
+ //var str string
+ n := 1
+ md := false
+ var res translator
+ var output []interface{}
+ var iString []interface{}
+
+ setLang := gotext.NewLocale("./locales", app.cfg.App.Lang);
+ setLang.AddDomain("base");
+
+ for _, item := range ParamsToTranslate {
+ switch item.(type) {
+ case int: // n > 1 for plural
+ n = item.(int)
+ case bool: // true for apply markdown
+ md = item.(bool)
+ case []interface{}: // variables passed for combined translations
+ var s string
+ var arr []string
+ plural := false // true if passed variable needs to be pluralized
+ for _, vars := range item.([]interface{}) {
+ switch vars.(type) {
+ case bool: // true if passed variable needs to be pluralized
+ plural = vars.(bool)
+ case int:
+ iString = append(iString, vars.(int))
+ case int64:
+ iString = append(iString, int(vars.(int64)))
+ case string:
+ s = vars.(string)
+ if(strings.Contains(s, ";")){ // links inside translation
+ var link [] string
+ for j:= 0; j<=strings.Count(s,";"); j++ {
+ link = append(link, strings.Split(s, ";")[j])
+ }
+ if(plural == true){
+ link[0] = setLang.GetN(link[0], link[0], 2)
+ }else{
+ link[0] = setLang.Get(link[0])
+ }
+ iString = append(iString, "[" + link[0] + "](" + link[1] + ")")
+ }else{ // simple string
+ if(plural == true){
+ fmt.Println("PLURAL")
+ if(len(iString) == 0){
+ iString = append(iString, setLang.GetN(s, s, 2))
+ }else{
+ iString = append(iString, setLang.GetN(s, s, 2))
+ }
+ }else{
+ if(len(iString) == 0){
+ iString = append(iString, setLang.Get(s))
+ }else{
+ iString = append(iString, setLang.Get(s))
+ }
+ }
+ }
+ case []string: // not used, templates don't support [] string type as function arguments
+ arr = vars.([]string)
+ iString = append(iString, "[" + arr[0] + "](" + arr[1] + ")")
+ }
+ output = iString
+ }
+
+ default:
+ fmt.Println("invalid parameters")
+ }
+ }
+
+ if(output != nil){ // if output for combined translations is not null
+ if(md == true){
+ res = template.HTML(applyBasicMarkdown([]byte(setLang.Get(str, output...))))
+ }else{
+ res = setLang.Get(str, output...)
+ }
+ return res
+ }
+ if(md == true){
+ res = template.HTML(applyBasicMarkdown([]byte(setLang.Get(str))))
+ }else if(n > 1){
+ res = setLang.GetN(str, str, n)
+ }else{
+ res = setLang.Get(str)
+ }
+
+ return res
+
+}
+
+ inputFile := "./static/js/"+app.cfg.App.Lang+".json"
+ file, err := ioutil.ReadFile(inputFile)
+ if err != nil {
+ log.Error(err.Error())
+ os.Exit(1)
+ }
+
+ var mfile map[string]interface{}
+ err = json.Unmarshal(file, &mfile)
+
+ var res []byte
+ res, err = json.Marshal(mfile[app.cfg.App.Lang])
+ if err != nil {
+ log.Error(err.Error())
+ os.Exit(1)
+ }
+ app.locales = string(res)
+ return nil
+}
+
// LoadKeys reads all needed keys from disk into the App. In order to use the
// configured `Server.KeysParentDir`, you must call initKeyPaths(App) before
// this.
func (app *App) LoadKeys() error {
var err error
app.keys = &key.Keychain{}
if debugging {
log.Info(" %s", emailKeyPath)
}
executable, err := os.Executable()
if err != nil {
executable = "writefreely"
} else {
executable = filepath.Base(executable)
}
app.keys.EmailKey, err = ioutil.ReadFile(emailKeyPath)
if err != nil {
return err
}
if debugging {
log.Info(" %s", cookieAuthKeyPath)
}
app.keys.CookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath)
if err != nil {
return err
}
if debugging {
log.Info(" %s", cookieKeyPath)
}
app.keys.CookieKey, err = ioutil.ReadFile(cookieKeyPath)
if err != nil {
return err
}
if debugging {
log.Info(" %s", csrfKeyPath)
}
app.keys.CSRFKey, err = ioutil.ReadFile(csrfKeyPath)
if err != nil {
if os.IsNotExist(err) {
log.Error(`Missing key: %s.
Run this command to generate missing keys:
%s keys generate
`, csrfKeyPath, executable)
}
return err
}
return nil
}
func (app *App) ReqLog(r *http.Request, status int, timeSince time.Duration) string {
return fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, timeSince, r.UserAgent())
}
// handleViewHome shows page at root path. It checks the configuration and
// authentication state to show the correct page.
func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
if app.cfg.App.SingleUser {
// Render blog index
return handleViewCollection(app, w, r)
}
// Multi-user instance
forceLanding := r.FormValue("landing") == "1"
if !forceLanding {
// Show correct page based on user auth status and configured landing path
u := getUserSession(app, r)
if app.cfg.App.Chorus {
// This instance is focused on reading, so show Reader on home route if not
// private or a private-instance user is logged in.
if !app.cfg.App.Private || u != nil {
return viewLocalTimeline(app, w, r)
}
}
if u != nil {
// User is logged in, so show the Pad
return handleViewPad(app, w, r)
}
if app.cfg.App.Private {
return viewLogin(app, w, r)
}
if land := app.cfg.App.LandingPath(); land != "/" {
return impart.HTTPError{http.StatusFound, land}
}
}
return handleViewLanding(app, w, r)
}
func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
forceLanding := r.FormValue("landing") == "1"
+ var setLang = localize(app.cfg.App.Lang)
+
p := struct {
page.StaticPage
*OAuthButtons
Flashes []template.HTML
Banner template.HTML
Content template.HTML
ForcedLanding bool
}{
StaticPage: pageForReq(app, r),
OAuthButtons: NewOAuthButtons(app.Config()),
ForcedLanding: forceLanding,
}
banner, err := getLandingBanner(app)
if err != nil {
log.Error("unable to get landing banner: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)}
}
p.Banner = template.HTML(applyMarkdown([]byte(banner.Content), "", app.cfg))
content, err := getLandingBody(app)
if err != nil {
log.Error("unable to get landing content: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get content: %v", err)}
}
p.Content = template.HTML(applyMarkdown([]byte(content.Content), "", app.cfg))
// Get error messages
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {
// Ignore this
log.Error("Unable to get session in handleViewHome; ignoring: %v", err)
}
flashes, _ := getSessionFlashes(app, w, r, session)
for _, flash := range flashes {
- p.Flashes = append(p.Flashes, template.HTML(flash))
+ p.Flashes = append(p.Flashes, template.HTML(setLang.Get(flash)))
}
// Show landing page
return renderPage(w, "landing.tmpl", p)
}
func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *template.Template) error {
p := struct {
page.StaticPage
ContentTitle string
Content template.HTML
PlainContent string
Updated string
AboutStats *InstanceStats
}{
StaticPage: pageForReq(app, r),
}
if r.URL.Path == "/about" || r.URL.Path == "/privacy" {
var c *instanceContent
var err error
if r.URL.Path == "/about" {
c, err = getAboutPage(app)
// Fetch stats
p.AboutStats = &InstanceStats{}
p.AboutStats.NumPosts, _ = app.db.GetTotalPosts()
p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections()
} else {
c, err = getPrivacyPage(app)
}
if err != nil {
return err
}
p.ContentTitle = c.Title.String
p.Content = template.HTML(applyMarkdown([]byte(c.Content), "", app.cfg))
p.PlainContent = shortPostDescription(stripmd.Strip(c.Content))
if !c.Updated.IsZero() {
p.Updated = c.Updated.Format("January 2, 2006")
}
}
// Serve templated page
err := t.ExecuteTemplate(w, "base", p)
if err != nil {
log.Error("Unable to render page: %v", err)
}
return nil
}
func pageForReq(app *App, r *http.Request) page.StaticPage {
p := page.StaticPage{
AppCfg: app.cfg.App,
Path: r.URL.Path,
Version: "v" + softwareVer,
+ Tr: app.tr,
}
// Use custom style, if file exists
if _, err := os.Stat(filepath.Join(staticDir, "local", "custom.css")); err == nil {
p.CustomCSS = true
}
// Add user information, if given
var u *User
accessToken := r.FormValue("t")
if accessToken != "" {
userID := app.db.GetUserID(accessToken)
if userID != -1 {
var err error
u, err = app.db.GetUserByID(userID)
if err == nil {
p.Username = u.Username
}
}
} else {
u = getUserSession(app, r)
if u != nil {
p.Username = u.Username
p.IsAdmin = u != nil && u.IsAdmin()
p.CanInvite = canUserInvite(app.cfg, p.IsAdmin)
}
}
p.CanViewReader = !app.cfg.App.Private || u != nil
+ p.Locales = app.locales
+
return p
}
var fileRegex = regexp.MustCompile("/([^/]*\\.[^/]*)$")
// Initialize loads the app configuration and initializes templates, keys,
// session, route handlers, and the database connection.
func Initialize(apper Apper, debug bool) (*App, error) {
debugging = debug
apper.LoadConfig()
+ // Generate JSON format locales
+ apper.App().GenJsonFiles()
+ apper.LoadLocales()
+
// Load templates
err := InitTemplates(apper.App().Config())
if err != nil {
return nil, fmt.Errorf("load templates: %s", err)
}
// Load keys and set up session
initKeyPaths(apper.App()) // TODO: find a better way to do this, since it's unneeded in all Apper implementations
err = InitKeys(apper)
if err != nil {
return nil, fmt.Errorf("init keys: %s", err)
}
apper.App().InitUpdates()
apper.App().InitSession()
apper.App().InitDecoder()
err = ConnectToDatabase(apper.App())
if err != nil {
return nil, fmt.Errorf("connect to DB: %s", err)
}
initActivityPub(apper.App())
// Handle local timeline, if enabled
if apper.App().cfg.App.LocalTimeline {
log.Info("Initializing local timeline...")
initLocalTimeline(apper.App())
}
return apper.App(), nil
}
func Serve(app *App, r *mux.Router) {
log.Info("Going to serve...")
isSingleUser = app.cfg.App.SingleUser
app.cfg.Server.Dev = debugging
// Handle shutdown
c := make(chan os.Signal, 2)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
log.Info("Shutting down...")
shutdown(app)
log.Info("Done.")
os.Exit(0)
}()
// Start gopher server
if app.cfg.Server.GopherPort > 0 && !app.cfg.App.Private {
go initGopher(app)
}
// Start web application server
var bindAddress = app.cfg.Server.Bind
if bindAddress == "" {
bindAddress = "localhost"
}
var err error
if app.cfg.IsSecureStandalone() {
if app.cfg.Server.Autocert {
m := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Cache: autocert.DirCache(app.cfg.Server.TLSCertPath),
}
host, err := url.Parse(app.cfg.App.Host)
if err != nil {
log.Error("[WARNING] Unable to parse configured host! %s", err)
log.Error(`[WARNING] ALL hosts are allowed, which can open you to an attack where
clients connect to a server by IP address and pretend to be asking for an
incorrect host name, and cause you to reach the CA's rate limit for certificate
requests. We recommend supplying a valid host name.`)
log.Info("Using autocert on ANY host")
} else {
log.Info("Using autocert on host %s", host.Host)
m.HostPolicy = autocert.HostWhitelist(host.Host)
}
s := &http.Server{
Addr: ":https",
Handler: r,
TLSConfig: &tls.Config{
GetCertificate: m.GetCertificate,
},
}
s.SetKeepAlivesEnabled(false)
go func() {
log.Info("Serving redirects on http://%s:80", bindAddress)
err = http.ListenAndServe(":80", m.HTTPHandler(nil))
log.Error("Unable to start redirect server: %v", err)
}()
log.Info("Serving on https://%s:443", bindAddress)
log.Info("---")
err = s.ListenAndServeTLS("", "")
} else {
go func() {
log.Info("Serving redirects on http://%s:80", bindAddress)
err = http.ListenAndServe(fmt.Sprintf("%s:80", bindAddress), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, app.cfg.App.Host, http.StatusMovedPermanently)
}))
log.Error("Unable to start redirect server: %v", err)
}()
log.Info("Serving on https://%s:443", bindAddress)
log.Info("Using manual certificates")
log.Info("---")
err = http.ListenAndServeTLS(fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, r)
}
} else {
log.Info("Serving on http://%s:%d\n", bindAddress, app.cfg.Server.Port)
log.Info("---")
err = http.ListenAndServe(fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port), r)
}
if err != nil {
log.Error("Unable to start: %v", err)
os.Exit(1)
}
}
func (app *App) InitDecoder() {
// TODO: do this at the package level, instead of the App level
// Initialize modules
app.formDecoder = schema.NewDecoder()
app.formDecoder.RegisterConverter(converter.NullJSONString{}, converter.ConvertJSONNullString)
app.formDecoder.RegisterConverter(converter.NullJSONBool{}, converter.ConvertJSONNullBool)
app.formDecoder.RegisterConverter(sql.NullString{}, converter.ConvertSQLNullString)
app.formDecoder.RegisterConverter(sql.NullBool{}, converter.ConvertSQLNullBool)
app.formDecoder.RegisterConverter(sql.NullInt64{}, converter.ConvertSQLNullInt64)
app.formDecoder.RegisterConverter(sql.NullFloat64{}, converter.ConvertSQLNullFloat64)
}
// ConnectToDatabase validates and connects to the configured database, then
// tests the connection.
func ConnectToDatabase(app *App) error {
// Check database configuration
if app.cfg.Database.Type == driverMySQL && (app.cfg.Database.User == "" || app.cfg.Database.Password == "") {
return fmt.Errorf("Database user or password not set.")
}
if app.cfg.Database.Host == "" {
app.cfg.Database.Host = "localhost"
}
if app.cfg.Database.Database == "" {
app.cfg.Database.Database = "writefreely"
}
// TODO: check err
connectToDatabase(app)
// Test database connection
err := app.db.Ping()
if err != nil {
return fmt.Errorf("Database ping failed: %s", err)
}
return nil
}
// FormatVersion constructs the version string for the application
func FormatVersion() string {
return serverSoftware + " " + softwareVer
}
// OutputVersion prints out the version of the application.
func OutputVersion() {
fmt.Println(FormatVersion())
}
// NewApp creates a new app instance.
func NewApp(cfgFile string) *App {
return &App{
cfgFile: cfgFile,
}
}
// CreateConfig creates a default configuration and saves it to the app's cfgFile.
func CreateConfig(app *App) error {
log.Info("Creating configuration...")
c := config.New()
log.Info("Saving configuration %s...", app.cfgFile)
err := config.Save(c, app.cfgFile)
if err != nil {
return fmt.Errorf("Unable to save configuration: %v", err)
}
return nil
}
// DoConfig runs the interactive configuration process.
func DoConfig(app *App, configSections string) {
if configSections == "" {
configSections = "server db app"
}
// let's check there aren't any garbage in the list
configSectionsArray := strings.Split(configSections, " ")
for _, element := range configSectionsArray {
if element != "server" && element != "db" && element != "app" {
log.Error("Invalid argument to --sections. Valid arguments are only \"server\", \"db\" and \"app\"")
os.Exit(1)
}
}
d, err := config.Configure(app.cfgFile, configSections)
if err != nil {
log.Error("Unable to configure: %v", err)
os.Exit(1)
}
app.cfg = d.Config
connectToDatabase(app)
defer shutdown(app)
if !app.db.DatabaseInitialized() {
err = adminInitDatabase(app)
if err != nil {
log.Error(err.Error())
os.Exit(1)
}
} else {
log.Info("Database already initialized.")
}
if d.User != nil {
u := &User{
Username: d.User.Username,
HashedPass: d.User.HashedPass,
Created: time.Now().Truncate(time.Second).UTC(),
}
// Create blog
log.Info("Creating user %s...\n", u.Username)
err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName, "")
if err != nil {
log.Error("Unable to create user: %s", err)
os.Exit(1)
}
log.Info("Done!")
}
os.Exit(0)
}
// GenerateKeyFiles creates app encryption keys and saves them into the configured KeysParentDir.
func GenerateKeyFiles(app *App) error {
// Read keys path from config
app.LoadConfig()
// Create keys dir if it doesn't exist yet
fullKeysDir := filepath.Join(app.cfg.Server.KeysParentDir, keysDir)
if _, err := os.Stat(fullKeysDir); os.IsNotExist(err) {
err = os.Mkdir(fullKeysDir, 0700)
if err != nil {
return err
}
}
// Generate keys
initKeyPaths(app)
// TODO: use something like https://github.com/hashicorp/go-multierror to return errors
var keyErrs error
err := generateKey(emailKeyPath)
if err != nil {
keyErrs = err
}
err = generateKey(cookieAuthKeyPath)
if err != nil {
keyErrs = err
}
err = generateKey(cookieKeyPath)
if err != nil {
keyErrs = err
}
err = generateKey(csrfKeyPath)
if err != nil {
keyErrs = err
}
return keyErrs
}
// CreateSchema creates all database tables needed for the application.
func CreateSchema(apper Apper) error {
apper.LoadConfig()
connectToDatabase(apper.App())
defer shutdown(apper.App())
err := adminInitDatabase(apper.App())
if err != nil {
return err
}
return nil
}
// Migrate runs all necessary database migrations.
func Migrate(apper Apper) error {
apper.LoadConfig()
connectToDatabase(apper.App())
defer shutdown(apper.App())
err := migrations.Migrate(migrations.NewDatastore(apper.App().db.DB, apper.App().db.driverName))
if err != nil {
return fmt.Errorf("migrate: %s", err)
}
return nil
}
// ResetPassword runs the interactive password reset process.
func ResetPassword(apper Apper, username string) error {
// Connect to the database
apper.LoadConfig()
connectToDatabase(apper.App())
defer shutdown(apper.App())
// Fetch user
u, err := apper.App().db.GetUserForAuth(username)
if err != nil {
log.Error("Get user: %s", err)
os.Exit(1)
}
// Prompt for new password
prompt := promptui.Prompt{
Templates: &promptui.PromptTemplates{
Success: "{{ . | bold | faint }}: ",
},
Label: "New password",
Mask: '*',
}
newPass, err := prompt.Run()
if err != nil {
log.Error("%s", err)
os.Exit(1)
}
// Do the update
log.Info("Updating...")
err = adminResetPassword(apper.App(), u, newPass)
if err != nil {
log.Error("%s", err)
os.Exit(1)
}
log.Info("Success.")
return nil
}
// DoDeleteAccount runs the confirmation and account delete process.
func DoDeleteAccount(apper Apper, username string) error {
// Connect to the database
apper.LoadConfig()
connectToDatabase(apper.App())
defer shutdown(apper.App())
// check user exists
u, err := apper.App().db.GetUserForAuth(username)
if err != nil {
log.Error("%s", err)
os.Exit(1)
}
userID := u.ID
// do not delete the admin account
// TODO: check for other admins and skip?
if u.IsAdmin() {
log.Error("Can not delete admin account")
os.Exit(1)
}
// confirm deletion, w/ w/out posts
prompt := promptui.Prompt{
Templates: &promptui.PromptTemplates{
Success: "{{ . | bold | faint }}: ",
},
Label: fmt.Sprintf("Really delete user : %s", username),
IsConfirm: true,
}
_, err = prompt.Run()
if err != nil {
log.Info("Aborted...")
os.Exit(0)
}
log.Info("Deleting...")
err = apper.App().db.DeleteAccount(userID)
if err != nil {
log.Error("%s", err)
os.Exit(1)
}
log.Info("Success.")
return nil
}
func connectToDatabase(app *App) {
log.Info("Connecting to %s database...", app.cfg.Database.Type)
var db *sql.DB
var err error
if app.cfg.Database.Type == driverMySQL {
db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s&tls=%t", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String()), app.cfg.Database.TLS))
db.SetMaxOpenConns(50)
} else if app.cfg.Database.Type == driverSQLite {
if !SQLiteEnabled {
log.Error("Invalid database type '%s'. Binary wasn't compiled with SQLite3 support.", app.cfg.Database.Type)
os.Exit(1)
}
if app.cfg.Database.FileName == "" {
log.Error("SQLite database filename value in config.ini is empty.")
os.Exit(1)
}
db, err = sql.Open("sqlite3_with_regex", app.cfg.Database.FileName+"?parseTime=true&cached=shared")
db.SetMaxOpenConns(2)
} else {
log.Error("Invalid database type '%s'. Only 'mysql' and 'sqlite3' are supported right now.", app.cfg.Database.Type)
os.Exit(1)
}
if err != nil {
log.Error("%s", err)
os.Exit(1)
}
app.db = &datastore{db, app.cfg.Database.Type}
}
func shutdown(app *App) {
log.Info("Closing database connection...")
app.db.Close()
}
// CreateUser creates a new admin or normal user from the given credentials.
func CreateUser(apper Apper, username, password string, isAdmin bool) error {
// Create an admin user with --create-admin
apper.LoadConfig()
connectToDatabase(apper.App())
defer shutdown(apper.App())
// Ensure an admin / first user doesn't already exist
firstUser, _ := apper.App().db.GetUserByID(1)
if isAdmin {
// Abort if trying to create admin user, but one already exists
if firstUser != nil {
return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely --create-user", firstUser.Username)
}
} else {
// Abort if trying to create regular user, but no admin exists yet
if firstUser == nil {
return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely --create-admin")
}
}
// Create the user
// Normalize and validate username
desiredUsername := username
username = getSlug(username, "")
usernameDesc := username
if username != desiredUsername {
usernameDesc += " (originally: " + desiredUsername + ")"
}
if !author.IsValidUsername(apper.App().cfg, username) {
return fmt.Errorf("Username %s is invalid, reserved, or shorter than configured minimum length (%d characters).", usernameDesc, apper.App().cfg.App.MinUsernameLen)
}
// Hash the password
hashedPass, err := auth.HashPass([]byte(password))
if err != nil {
return fmt.Errorf("Unable to hash password: %v", err)
}
u := &User{
Username: username,
HashedPass: hashedPass,
Created: time.Now().Truncate(time.Second).UTC(),
}
userType := "user"
if isAdmin {
userType = "admin"
}
log.Info("Creating %s %s...", userType, usernameDesc)
err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername, "")
if err != nil {
return fmt.Errorf("Unable to create user: %s", err)
}
log.Info("Done!")
return nil
}
//go:embed schema.sql
var schemaSql string
//go:embed sqlite.sql
var sqliteSql string
func adminInitDatabase(app *App) error {
var schema string
if app.cfg.Database.Type == driverSQLite {
schema = sqliteSql
} else {
schema = schemaSql
}
tblReg := regexp.MustCompile("CREATE TABLE (IF NOT EXISTS )?`([a-z_]+)`")
queries := strings.Split(string(schema), ";\n")
for _, q := range queries {
if strings.TrimSpace(q) == "" {
continue
}
parts := tblReg.FindStringSubmatch(q)
if len(parts) >= 3 {
log.Info("Creating table %s...", parts[2])
} else {
log.Info("Creating table ??? (Weird query) No match in: %v", parts)
}
_, err := app.db.Exec(q)
if err != nil {
log.Error("%s", err)
} else {
log.Info("Created.")
}
}
// Set up migrations table
log.Info("Initializing appmigrations table...")
err := migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName))
if err != nil {
return fmt.Errorf("Unable to set initial migrations: %v", err)
}
log.Info("Running migrations...")
err = migrations.Migrate(migrations.NewDatastore(app.db.DB, app.db.driverName))
if err != nil {
return fmt.Errorf("migrate: %s", err)
}
log.Info("Done.")
return nil
}
// ServerUserAgent returns a User-Agent string to use in external requests. The
// hostName parameter may be left empty.
func ServerUserAgent(hostName string) string {
hostUAStr := ""
if hostName != "" {
hostUAStr = "; +" + hostName
}
return "Go (" + serverSoftware + "/" + softwareVer + hostUAStr + ")"
}
diff --git a/collections.go b/collections.go
index fc225a2..406caa4 100644
--- a/collections.go
+++ b/collections.go
@@ -1,1250 +1,1252 @@
/*
* Copyright © 2018-2022 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"database/sql"
"encoding/json"
"fmt"
"html/template"
"math"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"unicode"
"github.com/gorilla/mux"
stripmd "github.com/writeas/go-strip-markdown/v2"
"github.com/writeas/impart"
"github.com/writeas/web-core/activitystreams"
"github.com/writeas/web-core/auth"
"github.com/writeas/web-core/bots"
"github.com/writeas/web-core/log"
"github.com/writeas/web-core/posts"
"github.com/writefreely/writefreely/author"
"github.com/writefreely/writefreely/config"
"github.com/writefreely/writefreely/page"
"golang.org/x/net/idna"
)
type (
// TODO: add Direction to db
// TODO: add Language to db
Collection struct {
ID int64 `datastore:"id" json:"-"`
Alias string `datastore:"alias" schema:"alias" json:"alias"`
Title string `datastore:"title" schema:"title" json:"title"`
Description string `datastore:"description" schema:"description" json:"description"`
Direction string `schema:"dir" json:"dir,omitempty"`
Language string `schema:"lang" json:"lang,omitempty"`
StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"`
Script string `datastore:"script" schema:"script" json:"script,omitempty"`
Signature string `datastore:"post_signature" schema:"signature" json:"-"`
Public bool `datastore:"public" json:"public"`
Visibility collVisibility `datastore:"private" json:"-"`
Format string `datastore:"format" json:"format,omitempty"`
Views int64 `json:"views"`
OwnerID int64 `datastore:"owner_id" json:"-"`
PublicOwner bool `datastore:"public_owner" json:"-"`
URL string `json:"url,omitempty"`
Monetization string `json:"monetization_pointer,omitempty"`
db *datastore
hostName string
}
CollectionObj struct {
Collection
TotalPosts int `json:"total_posts"`
Owner *User `json:"owner,omitempty"`
Posts *[]PublicPost `json:"posts,omitempty"`
Format *CollectionFormat
}
DisplayCollection struct {
*CollectionObj
Prefix string
IsTopLevel bool
CurrentPage int
TotalPages int
Silenced bool
}
SubmittedCollection struct {
// Data used for updating a given collection
ID int64
OwnerID uint64
// Form helpers
PreferURL string `schema:"prefer_url" json:"prefer_url"`
Privacy int `schema:"privacy" json:"privacy"`
Pass string `schema:"password" json:"password"`
MathJax bool `schema:"mathjax" json:"mathjax"`
Handle string `schema:"handle" json:"handle"`
// Actual collection values updated in the DB
Alias *string `schema:"alias" json:"alias"`
Title *string `schema:"title" json:"title"`
Description *string `schema:"description" json:"description"`
StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"`
Script *sql.NullString `schema:"script" json:"script"`
Signature *sql.NullString `schema:"signature" json:"signature"`
Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"`
Visibility *int `schema:"visibility" json:"public"`
Format *sql.NullString `schema:"format" json:"format"`
}
CollectionFormat struct {
Format string
}
collectionReq struct {
// Information about the collection request itself
prefix, alias, domain string
isCustomDomain bool
// User-related fields
isCollOwner bool
isAuthorized bool
}
)
func (sc *SubmittedCollection) FediverseHandle() string {
if sc.Handle == "" {
return apCustomHandleDefault
}
return getSlug(sc.Handle, "")
}
// collVisibility represents the visibility level for the collection.
type collVisibility int
// Visibility levels. Values are bitmasks, stored in the database as
// decimal numbers. If adding types, append them to this list. If removing,
// replace the desired visibility with a new value.
const CollUnlisted collVisibility = 0
const (
CollPublic collVisibility = 1 << iota
CollPrivate
CollProtected
)
var collVisibilityStrings = map[string]collVisibility{
"unlisted": CollUnlisted,
"public": CollPublic,
"private": CollPrivate,
"protected": CollProtected,
}
func defaultVisibility(cfg *config.Config) collVisibility {
vis, ok := collVisibilityStrings[cfg.App.DefaultVisibility]
if !ok {
vis = CollUnlisted
}
return vis
}
func (cf *CollectionFormat) Ascending() bool {
return cf.Format == "novel"
}
func (cf *CollectionFormat) ShowDates() bool {
return cf.Format == "blog"
}
func (cf *CollectionFormat) PostsPerPage() int {
if cf.Format == "novel" {
return postsPerPage
}
return postsPerPage
}
// Valid returns whether or not a format value is valid.
func (cf *CollectionFormat) Valid() bool {
return cf.Format == "blog" ||
cf.Format == "novel" ||
cf.Format == "notebook"
}
// NewFormat creates a new CollectionFormat object from the Collection.
func (c *Collection) NewFormat() *CollectionFormat {
cf := &CollectionFormat{Format: c.Format}
// Fill in default format
if cf.Format == "" {
cf.Format = "blog"
}
return cf
}
func (c *Collection) IsInstanceColl() bool {
ur, _ := url.Parse(c.hostName)
return c.Alias == ur.Host
}
func (c *Collection) IsUnlisted() bool {
return c.Visibility == 0
}
func (c *Collection) IsPrivate() bool {
return c.Visibility&CollPrivate != 0
}
func (c *Collection) IsProtected() bool {
return c.Visibility&CollProtected != 0
}
func (c *Collection) IsPublic() bool {
return c.Visibility&CollPublic != 0
}
func (c *Collection) FriendlyVisibility() string {
if c.IsPrivate() {
return "Private"
}
if c.IsPublic() {
return "Public"
}
if c.IsProtected() {
return "Password-protected"
}
return "Unlisted"
}
func (c *Collection) ShowFooterBranding() bool {
// TODO: implement this setting
return true
}
// CanonicalURL returns a fully-qualified URL to the collection.
func (c *Collection) CanonicalURL() string {
return c.RedirectingCanonicalURL(false)
}
func (c *Collection) DisplayCanonicalURL() string {
us := c.CanonicalURL()
u, err := url.Parse(us)
if err != nil {
return us
}
p := u.Path
if p == "/" {
p = ""
}
d := u.Hostname()
d, _ = idna.ToUnicode(d)
return d + p
}
// RedirectingCanonicalURL returns the fully-qualified canonical URL for the Collection, with a trailing slash. The
// hostName field needs to be populated for this to work correctly.
func (c *Collection) RedirectingCanonicalURL(isRedir bool) string {
if c.hostName == "" {
// If this is true, the human programmers screwed up. So ask for a bug report and fail, fail, fail
log.Error("[PROGRAMMER ERROR] WARNING: Collection.hostName is empty! Federation and many other things will fail! If you're seeing this in the wild, please report this bug and let us know what you were doing just before this: https://github.com/writefreely/writefreely/issues/new?template=bug_report.md")
}
if isSingleUser {
return c.hostName + "/"
}
return fmt.Sprintf("%s/%s/", c.hostName, c.Alias)
}
// PrevPageURL provides a full URL for the previous page of collection posts,
// returning a /page/N result for pages >1
func (c *Collection) PrevPageURL(prefix string, n int, tl bool) string {
u := ""
if n == 2 {
// Previous page is 1; no need for /page/ prefix
if prefix == "" {
u = "/"
}
// Else leave off trailing slash
} else {
u = fmt.Sprintf("/page/%d", n-1)
}
if tl {
return u
}
return "/" + prefix + c.Alias + u
}
// NextPageURL provides a full URL for the next page of collection posts
func (c *Collection) NextPageURL(prefix string, n int, tl bool) string {
if tl {
return fmt.Sprintf("/page/%d", n+1)
}
return fmt.Sprintf("/%s%s/page/%d", prefix, c.Alias, n+1)
}
func (c *Collection) DisplayTitle() string {
if c.Title != "" {
return c.Title
}
return c.Alias
}
func (c *Collection) StyleSheetDisplay() template.CSS {
return template.CSS(c.StyleSheet)
}
// ForPublic modifies the Collection for public consumption, such as via
// the API.
func (c *Collection) ForPublic() {
c.URL = c.CanonicalURL()
}
var isAvatarChar = regexp.MustCompile("[a-z0-9]").MatchString
func (c *Collection) PersonObject(ids ...int64) *activitystreams.Person {
accountRoot := c.FederatedAccount()
p := activitystreams.NewPerson(accountRoot)
p.URL = c.CanonicalURL()
uname := c.Alias
p.PreferredUsername = uname
p.Name = c.DisplayTitle()
p.Summary = c.Description
if p.Name != "" {
if av := c.AvatarURL(); av != "" {
p.Icon = activitystreams.Image{
Type: "Image",
MediaType: "image/png",
URL: av,
}
}
}
collID := c.ID
if len(ids) > 0 {
collID = ids[0]
}
pub, priv := c.db.GetAPActorKeys(collID)
if pub != nil {
p.AddPubKey(pub)
p.SetPrivKey(priv)
}
return p
}
func (c *Collection) AvatarURL() string {
fl := string(unicode.ToLower([]rune(c.DisplayTitle())[0]))
if !isAvatarChar(fl) {
return ""
}
return c.hostName + "/img/avatars/" + fl + ".png"
}
func (c *Collection) FederatedAPIBase() string {
return c.hostName + "/"
}
func (c *Collection) FederatedAccount() string {
accountUser := c.Alias
return c.FederatedAPIBase() + "api/collections/" + accountUser
}
func (c *Collection) RenderMathJax() bool {
return c.db.CollectionHasAttribute(c.ID, "render_mathjax")
}
func (c *Collection) MonetizationURL() string {
if c.Monetization == "" {
return ""
}
return strings.Replace(c.Monetization, "$", "https://", 1)
}
// DisplayDescription returns the description with rendered Markdown and HTML.
func (c *Collection) DisplayDescription() *template.HTML {
if c.Description == "" {
s := template.HTML("")
return &s
}
t := template.HTML(posts.ApplyBasicAccessibleMarkdown([]byte(c.Description)))
return &t
}
// PlainDescription returns the description with all Markdown and HTML removed.
func (c *Collection) PlainDescription() string {
if c.Description == "" {
return ""
}
desc := stripHTMLWithoutEscaping(c.Description)
desc = stripmd.Strip(desc)
return desc
}
func (c CollectionPage) DisplayMonetization() string {
return displayMonetization(c.Monetization, c.Alias)
}
func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r)
alias := r.FormValue("alias")
title := r.FormValue("title")
var missingParams, accessToken string
var u *User
c := struct {
Alias string `json:"alias" schema:"alias"`
Title string `json:"title" schema:"title"`
Web bool `json:"web" schema:"web"`
}{}
if reqJSON {
// Decode JSON request
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&c)
if err != nil {
log.Error("Couldn't parse post update JSON request: %v\n", err)
return ErrBadJSON
}
} else {
// TODO: move form parsing to formDecoder
c.Alias = alias
c.Title = title
}
if c.Alias == "" {
if c.Title != "" {
// If only a title was given, just use it to generate the alias.
c.Alias = getSlug(c.Title, "")
} else {
missingParams += "`alias` "
}
}
if c.Title == "" {
missingParams += "`title` "
}
if missingParams != "" {
return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Parameter(s) %srequired.", missingParams)}
}
var userID int64
var err error
if reqJSON && !c.Web {
accessToken = r.Header.Get("Authorization")
if accessToken == "" {
return ErrNoAccessToken
}
userID = app.db.GetUserID(accessToken)
if userID == -1 {
return ErrBadAccessToken
}
} else {
u = getUserSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
userID = u.ID
}
silenced, err := app.db.IsUserSilenced(userID)
if err != nil {
log.Error("new collection: %v", err)
return ErrInternalGeneral
}
if silenced {
return ErrUserSilenced
}
if !author.IsValidUsername(app.cfg, c.Alias) {
return impart.HTTPError{http.StatusPreconditionFailed, "Collection alias isn't valid."}
}
coll, err := app.db.CreateCollection(app.cfg, c.Alias, c.Title, userID)
if err != nil {
// TODO: handle this
return err
}
res := &CollectionObj{Collection: *coll}
if reqJSON {
return impart.WriteSuccess(w, res, http.StatusCreated)
}
redirectTo := "/me/c/"
// TODO: redirect to pad when necessary
return impart.HTTPError{http.StatusFound, redirectTo}
}
func apiCheckCollectionPermissions(app *App, r *http.Request, c *Collection) (int64, error) {
accessToken := r.Header.Get("Authorization")
var userID int64 = -1
if accessToken != "" {
userID = app.db.GetUserID(accessToken)
}
isCollOwner := userID == c.OwnerID
if c.IsPrivate() && !isCollOwner {
// Collection is private, but user isn't authenticated
return -1, ErrCollectionNotFound
}
if c.IsProtected() {
// TODO: check access token
return -1, ErrCollectionUnauthorizedRead
}
return userID, nil
}
// fetchCollection handles the API endpoint for retrieving collection data.
func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
accept := r.Header.Get("Accept")
if strings.Contains(accept, "application/activity+json") {
return handleFetchCollectionActivities(app, w, r)
}
vars := mux.Vars(r)
alias := vars["alias"]
// TODO: move this logic into a common getCollection function
// Get base Collection data
c, err := app.db.GetCollection(alias)
if err != nil {
return err
}
c.hostName = app.cfg.App.Host
// Redirect users who aren't requesting JSON
reqJSON := IsJSON(r)
if !reqJSON {
return impart.HTTPError{http.StatusFound, c.CanonicalURL()}
}
// Check permissions
userID, err := apiCheckCollectionPermissions(app, r, c)
if err != nil {
return err
}
isCollOwner := userID == c.OwnerID
// Fetch extra data about the Collection
res := &CollectionObj{Collection: *c}
if c.PublicOwner {
u, err := app.db.GetUserByID(res.OwnerID)
if err != nil {
// Log the error and just continue
log.Error("Error getting user for collection: %v", err)
} else {
res.Owner = u
}
}
// TODO: check status for silenced
app.db.GetPostsCount(res, isCollOwner)
// Strip non-public information
res.Collection.ForPublic()
return impart.WriteSuccess(w, res, http.StatusOK)
}
// fetchCollectionPosts handles an API endpoint for retrieving a collection's
// posts.
func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
alias := vars["alias"]
c, err := app.db.GetCollection(alias)
if err != nil {
return err
}
c.hostName = app.cfg.App.Host
// Check permissions
userID, err := apiCheckCollectionPermissions(app, r, c)
if err != nil {
return err
}
isCollOwner := userID == c.OwnerID
// Get page
page := 1
if p := r.FormValue("page"); p != "" {
pInt, _ := strconv.Atoi(p)
if pInt > 0 {
page = pInt
}
}
ps, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false)
if err != nil {
return err
}
coll := &CollectionObj{Collection: *c, Posts: ps}
app.db.GetPostsCount(coll, isCollOwner)
// Strip non-public information
coll.Collection.ForPublic()
// Transform post bodies if needed
if r.FormValue("body") == "html" {
for _, p := range *coll.Posts {
p.Content = posts.ApplyMarkdown([]byte(p.Content))
}
}
return impart.WriteSuccess(w, coll, http.StatusOK)
}
type CollectionPage struct {
page.StaticPage
*DisplayCollection
IsCustomDomain bool
IsWelcome bool
IsOwner bool
IsCollLoggedIn bool
CanPin bool
Username string
Monetization string
Collections *[]Collection
PinnedPosts *[]PublicPost
IsAdmin bool
CanInvite bool
// Helper field for Chorus mode
CollAlias string
}
func NewCollectionObj(c *Collection) *CollectionObj {
return &CollectionObj{
Collection: *c,
Format: c.NewFormat(),
}
}
func (c *CollectionObj) ScriptDisplay() template.JS {
return template.JS(c.Script)
}
var jsSourceCommentReg = regexp.MustCompile("(?m)^// src:(.+)$")
func (c *CollectionObj) ExternalScripts() []template.URL {
scripts := []template.URL{}
if c.Script == "" {
return scripts
}
matches := jsSourceCommentReg.FindAllStringSubmatch(c.Script, -1)
for _, m := range matches {
scripts = append(scripts, template.URL(strings.TrimSpace(m[1])))
}
return scripts
}
func (c *CollectionObj) CanShowScript() bool {
return false
}
func processCollectionRequest(cr *collectionReq, vars map[string]string, w http.ResponseWriter, r *http.Request) error {
cr.prefix = vars["prefix"]
cr.alias = vars["collection"]
// Normalize the URL, redirecting user to consistent post URL
if cr.alias != strings.ToLower(cr.alias) {
return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", strings.ToLower(cr.alias))}
}
return nil
}
// processCollectionPermissions checks the permissions for the given
// collectionReq, returning a Collection if access is granted; otherwise this
// renders any necessary collection pages, for example, if requesting a custom
// domain that doesn't yet have a collection associated, or if a collection
// requires a password. In either case, this will return nil, nil -- thus both
// values should ALWAYS be checked to determine whether or not to continue.
func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.ResponseWriter, r *http.Request) (*Collection, error) {
// Display collection if this is a collection
var c *Collection
var err error
if app.cfg.App.SingleUser {
c, err = app.db.GetCollectionByID(1)
} else {
c, err = app.db.GetCollection(cr.alias)
}
// TODO: verify we don't reveal the existence of a private collection with redirection
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
if err.Status == http.StatusNotFound {
if cr.isCustomDomain {
// User is on the site from a custom domain
//tErr := pages["404-domain.tmpl"].ExecuteTemplate(w, "base", pageForHost(page.StaticPage{}, r))
//if tErr != nil {
//log.Error("Unable to render 404-domain page: %v", err)
//}
return nil, nil
}
if len(cr.alias) >= minIDLen && len(cr.alias) <= maxIDLen {
// Alias is within post ID range, so just be sure this isn't a post
if app.db.PostIDExists(cr.alias) {
// TODO: use StatusFound for vanity post URLs when we implement them
return nil, impart.HTTPError{http.StatusMovedPermanently, "/" + cr.alias}
}
}
// Redirect if necessary
newAlias := app.db.GetCollectionRedirect(cr.alias)
if newAlias != "" {
return nil, impart.HTTPError{http.StatusFound, "/" + newAlias + "/"}
}
}
}
return nil, err
}
c.hostName = app.cfg.App.Host
// Update CollectionRequest to reflect owner status
cr.isCollOwner = u != nil && u.ID == c.OwnerID
// Check permissions
if !cr.isCollOwner {
if c.IsPrivate() {
return nil, ErrCollectionNotFound
} else if c.IsProtected() {
uname := ""
if u != nil {
uname = u.Username
}
// TODO: move this to all permission checks?
suspended, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil {
log.Error("process protected collection permissions: %v", err)
return nil, err
}
if suspended {
return nil, ErrCollectionNotFound
}
+ var setLang = localize(app.cfg.App.Lang)
+
// See if we've authorized this collection
cr.isAuthorized = isAuthorizedForCollection(app, c.Alias, r)
if !cr.isAuthorized {
p := struct {
page.StaticPage
*CollectionObj
Username string
Next string
Flashes []template.HTML
}{
StaticPage: pageForReq(app, r),
CollectionObj: &CollectionObj{Collection: *c},
Username: uname,
Next: r.FormValue("g"),
Flashes: []template.HTML{},
}
// Get owner information
p.CollectionObj.Owner, err = app.db.GetUserByID(c.OwnerID)
if err != nil {
// Log the error and just continue
log.Error("Error getting user for collection: %v", err)
}
flashes, _ := getSessionFlashes(app, w, r, nil)
for _, flash := range flashes {
- p.Flashes = append(p.Flashes, template.HTML(flash))
+ p.Flashes = append(p.Flashes, template.HTML(setLang.Get(flash)))
}
err = templates["password-collection"].ExecuteTemplate(w, "password-collection", p)
if err != nil {
log.Error("Unable to render password-collection: %v", err)
return nil, err
}
return nil, nil
}
}
}
return c, nil
}
func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPostReq bool) (*User, error) {
u := getUserSession(app, r)
return u, nil
}
func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection {
coll := &DisplayCollection{
CollectionObj: NewCollectionObj(c),
CurrentPage: page,
Prefix: cr.prefix,
IsTopLevel: isSingleUser,
}
c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
return coll
}
// getCollectionPage returns the collection page as an int. If the parsed page value is not
// greater than 0 then the default value of 1 is returned.
func getCollectionPage(vars map[string]string) int {
if p, _ := strconv.Atoi(vars["page"]); p > 0 {
return p
}
return 1
}
// handleViewCollection displays the requested Collection
func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
cr := &collectionReq{}
err := processCollectionRequest(cr, vars, w, r)
if err != nil {
return err
}
u, err := checkUserForCollection(app, cr, r, false)
if err != nil {
return err
}
page := getCollectionPage(vars)
c, err := processCollectionPermissions(app, cr, u, w, r)
if c == nil || err != nil {
return err
}
c.hostName = app.cfg.App.Host
silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil {
log.Error("view collection: %v", err)
return ErrInternalGeneral
}
// Serve ActivityStreams data now, if requested
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
ac := c.PersonObject()
ac.Context = []interface{}{activitystreams.Namespace}
setCacheControl(w, apCacheTime)
return impart.RenderActivityJSON(w, ac, http.StatusOK)
}
// Fetch extra data about the Collection
// TODO: refactor out this logic, shared in collection.go:fetchCollection()
coll := newDisplayCollection(c, cr, page)
coll.TotalPages = int(math.Ceil(float64(coll.TotalPosts) / float64(coll.Format.PostsPerPage())))
if coll.TotalPages > 0 && page > coll.TotalPages {
redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
if !app.cfg.App.SingleUser {
redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL)
}
return impart.HTTPError{http.StatusFound, redirURL}
}
coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false)
// Serve collection
displayPage := CollectionPage{
DisplayCollection: coll,
IsCollLoggedIn: cr.isAuthorized,
StaticPage: pageForReq(app, r),
IsCustomDomain: cr.isCustomDomain,
IsWelcome: r.FormValue("greeting") != "",
CollAlias: c.Alias,
}
displayPage.IsAdmin = u != nil && u.IsAdmin()
displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin)
var owner *User
if u != nil {
displayPage.Username = u.Username
displayPage.IsOwner = u.ID == coll.OwnerID
if displayPage.IsOwner {
// Add in needed information for users viewing their own collection
owner = u
displayPage.CanPin = true
pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
if err != nil {
log.Error("unable to fetch collections: %v", err)
}
displayPage.Collections = pubColls
}
}
isOwner := owner != nil
if !isOwner {
// Current user doesn't own collection; retrieve owner information
owner, err = app.db.GetUserByID(coll.OwnerID)
if err != nil {
// Log the error and just continue
log.Error("Error getting user for collection: %v", err)
}
}
if !isOwner && silenced {
return ErrCollectionNotFound
}
displayPage.Silenced = isOwner && silenced
displayPage.Owner = owner
coll.Owner = displayPage.Owner
// Add more data
// TODO: fix this mess of collections inside collections
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
collTmpl := "collection"
if app.cfg.App.Chorus {
collTmpl = "chorus-collection"
}
err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage)
if err != nil {
log.Error("Unable to render collection index: %v", err)
}
// Update collection view count
go func() {
// Don't update if owner is viewing the collection.
if u != nil && u.ID == coll.OwnerID {
return
}
// Only update for human views
if r.Method == "HEAD" || bots.IsBot(r.UserAgent()) {
return
}
_, err := app.db.Exec("UPDATE collections SET view_count = view_count + 1 WHERE id = ?", coll.ID)
if err != nil {
log.Error("Unable to update collections count: %v", err)
}
}()
return err
}
func handleViewMention(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
handle := vars["handle"]
remoteUser, err := app.db.GetProfilePageFromHandle(app, handle)
if err != nil || remoteUser == "" {
log.Error("Couldn't find user %s: %v", handle, err)
return ErrRemoteUserNotFound
}
return impart.HTTPError{Status: http.StatusFound, Message: remoteUser}
}
func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
tag := vars["tag"]
cr := &collectionReq{}
err := processCollectionRequest(cr, vars, w, r)
if err != nil {
return err
}
u, err := checkUserForCollection(app, cr, r, false)
if err != nil {
return err
}
page := getCollectionPage(vars)
c, err := processCollectionPermissions(app, cr, u, w, r)
if c == nil || err != nil {
return err
}
coll := newDisplayCollection(c, cr, page)
coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, page, cr.isCollOwner)
if coll.Posts != nil && len(*coll.Posts) == 0 {
return ErrCollectionPageNotFound
}
// Serve collection
displayPage := struct {
CollectionPage
Tag string
}{
CollectionPage: CollectionPage{
DisplayCollection: coll,
StaticPage: pageForReq(app, r),
IsCustomDomain: cr.isCustomDomain,
},
Tag: tag,
}
var owner *User
if u != nil {
displayPage.Username = u.Username
displayPage.IsOwner = u.ID == coll.OwnerID
if displayPage.IsOwner {
// Add in needed information for users viewing their own collection
owner = u
displayPage.CanPin = true
pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
if err != nil {
log.Error("unable to fetch collections: %v", err)
}
displayPage.Collections = pubColls
}
}
isOwner := owner != nil
if !isOwner {
// Current user doesn't own collection; retrieve owner information
owner, err = app.db.GetUserByID(coll.OwnerID)
if err != nil {
// Log the error and just continue
log.Error("Error getting user for collection: %v", err)
}
if owner.IsSilenced() {
return ErrCollectionNotFound
}
}
displayPage.Silenced = owner != nil && owner.IsSilenced()
displayPage.Owner = owner
coll.Owner = displayPage.Owner
// Add more data
// TODO: fix this mess of collections inside collections
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage)
if err != nil {
log.Error("Unable to render collection tag page: %v", err)
}
return nil
}
func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
slug := vars["slug"]
cr := &collectionReq{}
err := processCollectionRequest(cr, vars, w, r)
if err != nil {
return err
}
// Normalize the URL, redirecting user to consistent post URL
loc := fmt.Sprintf("/%s", slug)
if !app.cfg.App.SingleUser {
loc = fmt.Sprintf("/%s/%s", cr.alias, slug)
}
return impart.HTTPError{http.StatusFound, loc}
}
func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r)
vars := mux.Vars(r)
collAlias := vars["alias"]
isWeb := r.FormValue("web") == "1"
u := &User{}
if reqJSON && !isWeb {
// Ensure an access token was given
accessToken := r.Header.Get("Authorization")
u.ID = app.db.GetUserID(accessToken)
if u.ID == -1 {
return ErrBadAccessToken
}
} else {
u = getUserSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
}
silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil {
log.Error("existing collection: %v", err)
return ErrInternalGeneral
}
if silenced {
return ErrUserSilenced
}
if r.Method == "DELETE" {
err := app.db.DeleteCollection(collAlias, u.ID)
if err != nil {
// TODO: if not HTTPError, report error to admin
log.Error("Unable to delete collection: %s", err)
return err
}
addSessionFlash(app, w, r, "Deleted your blog, "+collAlias+".", nil)
return impart.HTTPError{Status: http.StatusNoContent}
}
c := SubmittedCollection{OwnerID: uint64(u.ID)}
if reqJSON {
// Decode JSON request
decoder := json.NewDecoder(r.Body)
err = decoder.Decode(&c)
if err != nil {
log.Error("Couldn't parse collection update JSON request: %v\n", err)
return ErrBadJSON
}
} else {
err = r.ParseForm()
if err != nil {
log.Error("Couldn't parse collection update form request: %v\n", err)
return ErrBadFormData
}
err = app.formDecoder.Decode(&c, r.PostForm)
if err != nil {
log.Error("Couldn't decode collection update form request: %v\n", err)
return ErrBadFormData
}
}
err = app.db.UpdateCollection(&c, collAlias)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
if reqJSON {
return err
}
addSessionFlash(app, w, r, err.Message, nil)
return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias}
} else {
log.Error("Couldn't update collection: %v\n", err)
return err
}
}
if reqJSON {
return impart.WriteSuccess(w, struct {
}{}, http.StatusOK)
}
addSessionFlash(app, w, r, "Blog updated!", nil)
return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias}
}
// collectionAliasFromReq takes a request and returns the collection alias
// if it can be ascertained, as well as whether or not the collection uses a
// custom domain.
func collectionAliasFromReq(r *http.Request) string {
vars := mux.Vars(r)
alias := vars["subdomain"]
isSubdomain := alias != ""
if !isSubdomain {
// Fall back to write.as/{collection} since this isn't a custom domain
alias = vars["collection"]
}
return alias
}
func handleWebCollectionUnlock(app *App, w http.ResponseWriter, r *http.Request) error {
var readReq struct {
Alias string `schema:"alias" json:"alias"`
Pass string `schema:"password" json:"password"`
Next string `schema:"to" json:"to"`
}
// Get params
if impart.ReqJSON(r) {
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&readReq)
if err != nil {
log.Error("Couldn't parse readReq JSON request: %v\n", err)
return ErrBadJSON
}
} else {
err := r.ParseForm()
if err != nil {
log.Error("Couldn't parse readReq form request: %v\n", err)
return ErrBadFormData
}
err = app.formDecoder.Decode(&readReq, r.PostForm)
if err != nil {
log.Error("Couldn't decode readReq form request: %v\n", err)
return ErrBadFormData
}
}
if readReq.Alias == "" {
return impart.HTTPError{http.StatusBadRequest, "Need a collection `alias` to read."}
}
if readReq.Pass == "" {
return impart.HTTPError{http.StatusBadRequest, "Please supply a password."}
}
var collHashedPass []byte
err := app.db.QueryRow("SELECT password FROM collectionpasswords INNER JOIN collections ON id = collection_id WHERE alias = ?", readReq.Alias).Scan(&collHashedPass)
if err != nil {
if err == sql.ErrNoRows {
log.Error("No collectionpassword found when trying to read collection %s", readReq.Alias)
return impart.HTTPError{http.StatusInternalServerError, "Something went very wrong. The humans have been alerted."}
}
return err
}
if !auth.Authenticated(collHashedPass, []byte(readReq.Pass)) {
return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
}
// Success; set cookie
session, err := app.sessionStore.Get(r, blogPassCookieName)
if err == nil {
session.Values[readReq.Alias] = true
err = session.Save(r, w)
if err != nil {
log.Error("Didn't save unlocked blog '%s': %v", readReq.Alias, err)
}
}
next := "/" + readReq.Next
if !app.cfg.App.SingleUser {
next = "/" + readReq.Alias + next
}
return impart.HTTPError{http.StatusFound, next}
}
func isAuthorizedForCollection(app *App, alias string, r *http.Request) bool {
authd := false
session, err := app.sessionStore.Get(r, blogPassCookieName)
if err == nil {
_, authd = session.Values[alias]
}
return authd
}
func logOutCollection(app *App, alias string, w http.ResponseWriter, r *http.Request) error {
session, err := app.sessionStore.Get(r, blogPassCookieName)
if err != nil {
return err
}
// Remove this from map of blogs logged into
delete(session.Values, alias)
// If not auth'd with any blog, delete entire cookie
if len(session.Values) == 0 {
session.Options.MaxAge = -1
}
return session.Save(r, w)
}
func handleLogOutCollection(app *App, w http.ResponseWriter, r *http.Request) error {
alias := collectionAliasFromReq(r)
var c *Collection
var err error
if app.cfg.App.SingleUser {
c, err = app.db.GetCollectionByID(1)
} else {
c, err = app.db.GetCollection(alias)
}
if err != nil {
return err
}
if !c.IsProtected() {
// Invalid to log out of this collection
return ErrCollectionPageNotFound
}
err = logOutCollection(app, c.Alias, w, r)
if err != nil {
addSessionFlash(app, w, r, "Logging out failed. Try clearing cookies for this site, instead.", nil)
}
return impart.HTTPError{http.StatusFound, c.CanonicalURL()}
}
diff --git a/config/config.go b/config/config.go
index 2065ddf..833de4c 100644
--- a/config/config.go
+++ b/config/config.go
@@ -1,295 +1,296 @@
/*
* Copyright © 2018-2021 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
// Package config holds and assists in the configuration of a writefreely instance.
package config
import (
"net/url"
"strings"
"github.com/go-ini/ini"
"github.com/writeas/web-core/log"
"golang.org/x/net/idna"
)
const (
// FileName is the default configuration file name
FileName = "config.ini"
UserNormal UserType = "user"
UserAdmin = "admin"
)
type (
UserType string
// ServerCfg holds values that affect how the HTTP server runs
ServerCfg struct {
HiddenHost string `ini:"hidden_host"`
Port int `ini:"port"`
Bind string `ini:"bind"`
TLSCertPath string `ini:"tls_cert_path"`
TLSKeyPath string `ini:"tls_key_path"`
Autocert bool `ini:"autocert"`
TemplatesParentDir string `ini:"templates_parent_dir"`
StaticParentDir string `ini:"static_parent_dir"`
PagesParentDir string `ini:"pages_parent_dir"`
KeysParentDir string `ini:"keys_parent_dir"`
HashSeed string `ini:"hash_seed"`
GopherPort int `ini:"gopher_port"`
Dev bool `ini:"-"`
}
// DatabaseCfg holds values that determine how the application connects to a datastore
DatabaseCfg struct {
Type string `ini:"type"`
FileName string `ini:"filename"`
User string `ini:"username"`
Password string `ini:"password"`
Database string `ini:"database"`
Host string `ini:"host"`
Port int `ini:"port"`
TLS bool `ini:"tls"`
}
WriteAsOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
AuthLocation string `ini:"auth_location"`
TokenLocation string `ini:"token_location"`
InspectLocation string `ini:"inspect_location"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
GitlabOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
Host string `ini:"host"`
DisplayName string `ini:"display_name"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
GiteaOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
Host string `ini:"host"`
DisplayName string `ini:"display_name"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
SlackOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
TeamID string `ini:"team_id"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
GenericOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
Host string `ini:"host"`
DisplayName string `ini:"display_name"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
TokenEndpoint string `ini:"token_endpoint"`
InspectEndpoint string `ini:"inspect_endpoint"`
AuthEndpoint string `ini:"auth_endpoint"`
Scope string `ini:"scope"`
AllowDisconnect bool `ini:"allow_disconnect"`
MapUserID string `ini:"map_user_id"`
MapUsername string `ini:"map_username"`
MapDisplayName string `ini:"map_display_name"`
MapEmail string `ini:"map_email"`
}
// AppCfg holds values that affect how the application functions
AppCfg struct {
SiteName string `ini:"site_name"`
SiteDesc string `ini:"site_description"`
Host string `ini:"host"`
+ Lang string `ini:"language"`
// Site appearance
Theme string `ini:"theme"`
Editor string `ini:"editor"`
JSDisabled bool `ini:"disable_js"`
WebFonts bool `ini:"webfonts"`
Landing string `ini:"landing"`
SimpleNav bool `ini:"simple_nav"`
WFModesty bool `ini:"wf_modesty"`
// Site functionality
Chorus bool `ini:"chorus"`
Forest bool `ini:"forest"` // The admin cares about the forest, not the trees. Hide unnecessary technical info.
DisableDrafts bool `ini:"disable_drafts"`
// Users
SingleUser bool `ini:"single_user"`
OpenRegistration bool `ini:"open_registration"`
OpenDeletion bool `ini:"open_deletion"`
MinUsernameLen int `ini:"min_username_len"`
MaxBlogs int `ini:"max_blogs"`
// Options for public instances
// Federation
Federation bool `ini:"federation"`
PublicStats bool `ini:"public_stats"`
Monetization bool `ini:"monetization"`
NotesOnly bool `ini:"notes_only"`
// Access
Private bool `ini:"private"`
// Additional functions
LocalTimeline bool `ini:"local_timeline"`
UserInvites string `ini:"user_invites"`
// Defaults
DefaultVisibility string `ini:"default_visibility"`
// Check for Updates
UpdateChecks bool `ini:"update_checks"`
// Disable password authentication if use only Oauth
DisablePasswordAuth bool `ini:"disable_password_auth"`
}
// Config holds the complete configuration for running a writefreely instance
Config struct {
Server ServerCfg `ini:"server"`
Database DatabaseCfg `ini:"database"`
App AppCfg `ini:"app"`
SlackOauth SlackOauthCfg `ini:"oauth.slack"`
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"`
GiteaOauth GiteaOauthCfg `ini:"oauth.gitea"`
GenericOauth GenericOauthCfg `ini:"oauth.generic"`
}
)
// New creates a new Config with sane defaults
func New() *Config {
c := &Config{
Server: ServerCfg{
Port: 8080,
Bind: "localhost", /* IPV6 support when not using localhost? */
},
App: AppCfg{
Host: "http://localhost:8080",
Theme: "write",
WebFonts: true,
SingleUser: true,
MinUsernameLen: 3,
MaxBlogs: 1,
Federation: true,
PublicStats: true,
},
}
c.UseMySQL(true)
return c
}
// UseMySQL resets the Config's Database to use default values for a MySQL setup.
func (cfg *Config) UseMySQL(fresh bool) {
cfg.Database.Type = "mysql"
if fresh {
cfg.Database.Host = "localhost"
cfg.Database.Port = 3306
}
}
// UseSQLite resets the Config's Database to use default values for a SQLite setup.
func (cfg *Config) UseSQLite(fresh bool) {
cfg.Database.Type = "sqlite3"
if fresh {
cfg.Database.FileName = "writefreely.db"
}
}
// IsSecureStandalone returns whether or not the application is running as a
// standalone server with TLS enabled.
func (cfg *Config) IsSecureStandalone() bool {
return cfg.Server.Port == 443 && cfg.Server.TLSCertPath != "" && cfg.Server.TLSKeyPath != ""
}
func (ac *AppCfg) LandingPath() string {
if !strings.HasPrefix(ac.Landing, "/") {
return "/" + ac.Landing
}
return ac.Landing
}
func (ac AppCfg) SignupPath() string {
if !ac.OpenRegistration {
return ""
}
if ac.Chorus || ac.Private || (ac.Landing != "" && ac.Landing != "/") {
return "/signup"
}
return "/"
}
// Load reads the given configuration file, then parses and returns it as a Config.
func Load(fname string) (*Config, error) {
if fname == "" {
fname = FileName
}
cfg, err := ini.Load(fname)
if err != nil {
return nil, err
}
// Parse INI file
uc := &Config{}
err = cfg.MapTo(uc)
if err != nil {
return nil, err
}
// Do any transformations
u, err := url.Parse(uc.App.Host)
if err != nil {
return nil, err
}
d, err := idna.ToASCII(u.Hostname())
if err != nil {
log.Error("idna.ToASCII for %s: %s", u.Hostname(), err)
return nil, err
}
uc.App.Host = u.Scheme + "://" + d
if u.Port() != "" {
uc.App.Host += ":" + u.Port()
}
return uc, nil
}
// Save writes the given Config to the given file.
func Save(uc *Config, fname string) error {
cfg := ini.Empty()
err := ini.ReflectFrom(cfg, uc)
if err != nil {
return err
}
if fname == "" {
fname = FileName
}
return cfg.SaveTo(fname)
}
diff --git a/errors.go b/errors.go
index f0d3099..6bde32c 100644
--- a/errors.go
+++ b/errors.go
@@ -1,62 +1,63 @@
/*
* Copyright © 2018-2020 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"net/http"
"github.com/writeas/impart"
)
// Commonly returned HTTP errors
+
var (
ErrBadFormData = impart.HTTPError{http.StatusBadRequest, "Expected valid form data."}
ErrBadJSON = impart.HTTPError{http.StatusBadRequest, "Expected valid JSON object."}
ErrBadJSONArray = impart.HTTPError{http.StatusBadRequest, "Expected valid JSON array."}
ErrBadAccessToken = impart.HTTPError{http.StatusUnauthorized, "Invalid access token."}
ErrNoAccessToken = impart.HTTPError{http.StatusBadRequest, "Authorization token required."}
ErrNotLoggedIn = impart.HTTPError{http.StatusUnauthorized, "Not logged in."}
ErrForbiddenCollection = impart.HTTPError{http.StatusForbidden, "You don't have permission to add to this collection."}
ErrForbiddenEditPost = impart.HTTPError{http.StatusForbidden, "You don't have permission to update this post."}
ErrUnauthorizedEditPost = impart.HTTPError{http.StatusUnauthorized, "Invalid editing credentials."}
ErrUnauthorizedGeneral = impart.HTTPError{http.StatusUnauthorized, "You don't have permission to do that."}
ErrBadRequestedType = impart.HTTPError{http.StatusNotAcceptable, "Bad requested Content-Type."}
ErrCollectionUnauthorizedRead = impart.HTTPError{http.StatusUnauthorized, "You don't have permission to access this collection."}
ErrNoPublishableContent = impart.HTTPError{http.StatusBadRequest, "Supply something to publish."}
ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."}
ErrInternalCookieSession = impart.HTTPError{http.StatusInternalServerError, "Could not get cookie session."}
ErrUnavailable = impart.HTTPError{http.StatusServiceUnavailable, "Service temporarily unavailable due to high load."}
ErrCollectionNotFound = impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
ErrCollectionGone = impart.HTTPError{http.StatusGone, "This blog was unpublished."}
ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."}
ErrPostNotFound = impart.HTTPError{Status: http.StatusNotFound, Message: "Post not found."}
ErrPostBanned = impart.HTTPError{Status: http.StatusGone, Message: "Post removed."}
ErrPostUnpublished = impart.HTTPError{Status: http.StatusGone, Message: "Post unpublished by author."}
ErrPostFetchError = impart.HTTPError{Status: http.StatusInternalServerError, Message: "We encountered an error getting the post. The humans have been alerted."}
ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."}
ErrRemoteUserNotFound = impart.HTTPError{http.StatusNotFound, "Remote user not found."}
ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."}
ErrUserSilenced = impart.HTTPError{http.StatusForbidden, "Account is silenced."}
ErrDisabledPasswordAuth = impart.HTTPError{http.StatusForbidden, "Password authentication is disabled."}
)
// Post operation errors
var (
ErrPostNoUpdatableVals = impart.HTTPError{http.StatusBadRequest, "Supply some properties to update."}
)
diff --git a/invites.go b/invites.go
index 72f4890..a622a9b 100644
--- a/invites.go
+++ b/invites.go
@@ -1,206 +1,208 @@
/*
* Copyright © 2019-2021 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"database/sql"
"html/template"
"net/http"
"strconv"
"time"
"github.com/gorilla/mux"
"github.com/writeas/impart"
"github.com/writeas/web-core/id"
"github.com/writeas/web-core/log"
"github.com/writefreely/writefreely/page"
)
type Invite struct {
ID string
MaxUses sql.NullInt64
Created time.Time
Expires *time.Time
Inactive bool
uses int64
}
func (i Invite) Uses() int64 {
return i.uses
}
func (i Invite) Expired() bool {
return i.Expires != nil && i.Expires.Before(time.Now())
}
func (i Invite) Active(db *datastore) bool {
if i.Expired() {
return false
}
if i.MaxUses.Valid && i.MaxUses.Int64 > 0 {
if c := db.GetUsersInvitedCount(i.ID); c >= i.MaxUses.Int64 {
return false
}
}
return true
}
func (i Invite) ExpiresFriendly() string {
return i.Expires.Format("January 2, 2006, 3:04 PM")
}
func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
// Don't show page if instance doesn't allow it
if !(app.cfg.App.UserInvites != "" && (u.IsAdmin() || app.cfg.App.UserInvites != "admin")) {
return impart.HTTPError{http.StatusNotFound, ""}
}
f, _ := getSessionFlashes(app, w, r, nil)
p := struct {
*UserPage
Invites *[]Invite
Silenced bool
}{
UserPage: NewUserPage(app, r, u, "Invite People", f),
}
var err error
p.Silenced, err = app.db.IsUserSilenced(u.ID)
if err != nil {
if err == ErrUserNotFound {
return err
}
log.Error("view invites: %v", err)
}
p.Invites, err = app.db.GetUserInvites(u.ID)
if err != nil {
return err
}
for i := range *p.Invites {
(*p.Invites)[i].uses = app.db.GetUsersInvitedCount((*p.Invites)[i].ID)
}
showUserPage(w, "invite", p)
return nil
}
func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
muVal := r.FormValue("uses")
expVal := r.FormValue("expires")
if u.IsSilenced() {
return ErrUserSilenced
}
var err error
var maxUses int
if muVal != "0" {
maxUses, err = strconv.Atoi(muVal)
if err != nil {
return impart.HTTPError{http.StatusBadRequest, "Invalid value for 'max_uses'"}
}
}
var expDate *time.Time
var expires int
if expVal != "0" {
expires, err = strconv.Atoi(expVal)
if err != nil {
return impart.HTTPError{http.StatusBadRequest, "Invalid value for 'expires'"}
}
ed := time.Now().Add(time.Duration(expires) * time.Minute)
expDate = &ed
}
inviteID := id.GenerateRandomString("0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz", 6)
err = app.db.CreateUserInvite(inviteID, u.ID, maxUses, expDate)
if err != nil {
return err
}
return impart.HTTPError{http.StatusFound, "/me/invites"}
}
func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
inviteCode := mux.Vars(r)["code"]
i, err := app.db.GetUserInvite(inviteCode)
if err != nil {
return err
}
expired := i.Expired()
if !expired && i.MaxUses.Valid && i.MaxUses.Int64 > 0 {
// Invite has a max-use number, so check if we're past that limit
i.uses = app.db.GetUsersInvitedCount(inviteCode)
expired = i.uses >= i.MaxUses.Int64
}
if u := getUserSession(app, r); u != nil {
// check if invite belongs to another user
// error can be ignored as not important in this case
if ownInvite, _ := app.db.IsUsersInvite(inviteCode, u.ID); !ownInvite {
addSessionFlash(app, w, r, "You're already registered and logged in.", nil)
// show homepage
return impart.HTTPError{http.StatusFound, "/me/settings"}
}
// show invite instructions
p := struct {
*UserPage
Invite *Invite
Expired bool
}{
UserPage: NewUserPage(app, r, u, "Invite to "+app.cfg.App.SiteName, nil),
Invite: i,
Expired: expired,
}
showUserPage(w, "invite-help", p)
return nil
}
+ var setLang = localize(app.cfg.App.Lang)
+
p := struct {
page.StaticPage
*OAuthButtons
Error string
Flashes []template.HTML
Invite string
}{
StaticPage: pageForReq(app, r),
OAuthButtons: NewOAuthButtons(app.cfg),
Invite: inviteCode,
}
if expired {
p.Error = "This invite link has expired."
}
// Tell search engines not to index invite links
w.Header().Set("X-Robots-Tag", "noindex")
// Get error messages
session, err := app.sessionStore.Get(r, cookieName)
if err != nil {
// Ignore this
log.Error("Unable to get session in handleViewInvite; ignoring: %v", err)
}
flashes, _ := getSessionFlashes(app, w, r, session)
for _, flash := range flashes {
- p.Flashes = append(p.Flashes, template.HTML(flash))
+ p.Flashes = append(p.Flashes, template.HTML(setLang.Get(flash)))
}
// Show landing page
return renderPage(w, "signup.tmpl", p)
}
diff --git a/less/core.less b/less/core.less
index 709ba1e..792cdec 100644
--- a/less/core.less
+++ b/less/core.less
@@ -1,1621 +1,1622 @@
body {
font-family: @serifFont;
font-size-adjust: 0.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: white;
color: #111;
h1, header h2 {
a {
color: @headerTextColor;
.transition-duration(0.2s);
&:hover {
color: #303030;
text-decoration: none;
}
}
}
h1, h2, h3 {
line-height: 1.2;
}
&#post article, &#collection article p, &#subpage article p {
display: block;
unicode-bidi: embed;
white-space: pre;
}
&#post {
#wrapper, pre {
max-width: 40em;
margin: 0 auto;
a:hover {
text-decoration: underline;
}
}
blockquote {
p + p {
margin: -2em 0 0.5em;
}
}
article {
margin-bottom: 2em !important;
h1, h2, h3, h4, h5, h6, p, ul, ol, code {
display: inline;
margin: 0;
}
hr + p, ol, ul {
display: block;
margin-top: -1rem;
margin-bottom: -1rem;
}
ol, ul {
margin: 2rem 0 -1rem;
ol, ul {
margin: 1.25rem 0 -0.5rem;
}
}
li {
margin-top: -0.5rem;
margin-bottom: -0.5rem;
}
h2#title {
.article-title;
}
h1 {
font-size: 1.5em;
}
h2 {
font-size: 1.4em;
}
}
header {
nav {
span, a {
&.pinned {
&.selected {
font-weight: bold;
}
&+.views {
margin-left: 2em;
}
}
}
}
}
.owner-visible {
display: none;
}
}
&#post, &#collection, &#subpage {
code {
.article-code;
}
img, video, audio {
max-width: 100%;
}
audio {
width: 100%;
white-space: initial;
}
pre {
.code-block;
code {
background: transparent;
border: 0;
padding: 0;
font-size: 1em;
white-space: pre-wrap; /* CSS 3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
white-space: -o-pre-wrap; /* Opera 7 */
word-wrap: break-word; /* Internet Explorer 5.5+ */
}
}
blockquote {
.article-blockquote;
}
article {
hr {
margin-top: 0;
margin-bottom: 0;
}
p.badge {
background-color: #aaa;
display: inline-block;
padding: 0.25em 0.5em;
margin: 0;
float: right;
color: white;
.rounded(.25em);
}
}
header {
nav {
span, a {
&.pinned {
&+.pinned {
margin-left: 1.5em;
}
}
}
}
}
footer {
nav {
a {
margin-top: 0;
}
}
}
}
&#collection {
#welcome, .access {
margin: 0 auto;
max-width: 35em;
h2 {
font-weight: normal;
margin-bottom: 1em;
}
p {
font-size: 1.2em;
line-height: 1.6;
}
}
.access {
margin: 8em auto;
text-align: center;
h2, ul.errors {
font-size: 1.2em;
margin-bottom: 1.5em !important;
}
}
header {
padding: 0 1em;
text-align: center;
max-width: 50em;
margin: 3em auto 4em;
.writeas-prefix {
a {
color: #aaa;
}
display: block;
margin-bottom: 0.5em;
}
nav {
display: block;
margin: 1em 0;
a:first-child {
margin: 0;
}
}
}
nav#manage {
position: absolute;
top: 1em;
left: 1.5em;
li a.write {
font-family: @serifFont;
padding-top: 0.2em;
padding-bottom: 0.2em;
}
}
pre {
line-height: 1.5;
}
}
&#subpage {
#wrapper {
h1 {
font-size: 2.5em;
letter-spacing: -2px;
padding: 0 2rem 2rem;
}
}
}
&#post {
pre {
font-size: 0.75em;
}
}
&#collection, &#subpage {
#wrapper {
margin-left: auto;
margin-right: auto;
article {
margin-bottom: 4em;
&:hover {
.hidden {
.opacity(1);
}
}
}
h2 {
margin-top: 0em;
margin-bottom: 0.25em;
&+time {
display: block;
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
time {
font-size: 1.1em;
&+p {
margin-top: 0.25em;
}
}
footer {
text-align: left;
padding: 0;
}
}
#paging {
overflow: visible;
padding: 1em 6em 0;
}
a.read-more {
color: #666;
}
}
&#me #official-writing {
h2 {
font-weight: normal;
a {
font-size: 0.6em;
margin-left: 1em;
}
a[name] {
margin-left: 0;
}
a:link, a:visited {
color: @textLinkColor;
}
a:hover {
text-decoration: underline;
}
}
}
&#promo {
div.heading {
margin: 8em 0;
}
div.heading, div.attention-form {
h1 {
font-size: 3.5em;
}
input {
padding-left: 0.75em;
padding-right: 0.75em;
&[type=email] {
max-width: 16em;
}
&[type=submit] {
padding-left: 1.5em;
padding-right: 1.5em;
}
}
}
h2 {
margin-bottom: 0;
font-size: 1.8em;
font-weight: normal;
span.write-as {
color: black;
}
&.soon {
color: lighten(@subheaders, 50%);
span {
&.write-as {
color: lighten(#000, 50%);
}
&.note {
color: lighten(#333, 50%);
font-variant: small-caps;
margin-left: 0.5em;
}
}
}
}
.half-col a {
margin-left: 1em;
margin-right: 1em;
}
}
nav#top-nav {
display: inline;
position: absolute;
top: 1.5em;
right: 1.5em;
font-size: 0.95rem;
font-family: @sansFont;
text-transform: uppercase;
a {
color: #777;
}
a + a {
margin-left: 1em;
}
}
footer {
nav, ul {
a {
display: inline-block;
margin-top: 0.8em;
+ text-transform: lowercase;
.transition-duration(0.1s);
text-decoration: none;
+ a {
margin-left: 0.8em;
}
&:link, &:visited {
color: #999;
}
&:hover {
color: #666;
text-decoration: none;
}
}
}
a.home {
&:link, &:visited {
color: #333;
}
font-weight: bold;
text-decoration: none;
&:hover {
color: #000;
}
}
ul {
list-style: none;
text-align: left;
padding-left: 0 !important;
margin-left: 0 !important;
.icons img {
height: 16px;
width: 16px;
fill: #999;
}
}
}
}
img {
&.paid {
height: 0.86em;
vertical-align: middle;
margin-bottom: 0.1em;
}
}
nav#full-nav {
margin: 0;
.left-side {
display: inline-block;
a:first-child {
margin-left: 0;
}
}
.right-side {
float: right;
}
}
nav#full-nav a.simple-btn, .tool button {
font-family: @sansFont;
border: 1px solid #ccc !important;
padding: .5rem 1rem;
margin: 0;
.rounded(.25em);
text-decoration: none;
}
.post-title {
a {
&:link {
color: #333;
}
&:visited {
color: #444;
}
}
time, time a:link, time a:visited, &+.time {
color: #999;
}
}
.hidden {
-moz-transition-property: opacity;
-webkit-transition-property: opacity;
-o-transition-property: opacity;
transition-property: opacity;
.transition-duration(0.4s);
.opacity(0);
}
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
&.subdued {
color: #999;
&:hover {
border-bottom: 1px solid #999;
text-decoration: none;
}
}
&.danger {
color: @dangerCol;
font-size: 0.86em;
}
&.simple-cta {
text-decoration: none;
border-bottom: 1px solid #ccc;
color: #333;
padding-bottom: 2px;
&:hover {
text-decoration: none;
}
}
&.action-btn {
font-family: @sansFont;
text-transform: uppercase;
.rounded(.25em);
background-color: red;
color: white;
font-weight: bold;
padding: 0.5em 0.75em;
&:hover {
background-color: lighten(#f00, 5%);
text-decoration: none;
}
}
&.hashtag:hover {
text-decoration: none;
span + span {
text-decoration: underline;
}
}
&.hashtag {
span:first-child {
color: #999;
margin-right: 0.1em;
font-size: 0.86em;
text-decoration: none;
}
}
}
abbr {
border-bottom: 1px dotted #999;
text-decoration: none;
cursor: help;
}
body#collection article p, body#subpage article p {
.article-p;
}
pre, body#post article, #post .alert, #subpage .alert, body#collection article, body#subpage article, body#subpage #wrapper h1 {
max-width: 40rem;
margin: 0 auto;
}
#collection header .alert, #post .alert, #subpage .alert {
margin-bottom: 1em;
p {
text-align: left;
line-height: 1.5;
}
}
textarea, input#title, pre, body#post article, body#collection article p {
&.norm, &.sans, &.wrap {
line-height: 1.5;
white-space: pre-wrap; /* CSS 3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
white-space: -o-pre-wrap; /* Opera 7 */
word-wrap: break-word; /* Internet Explorer 5.5+ */
}
}
textarea, input#title, pre, body#post article, body#collection article, body#subpage article, span, .font {
&.norm {
font-family: @serifFont;
}
&.sans {
font-family: @sansFont;
}
&.mono, &.wrap, &.code {
font-family: @monoFont;
}
&.mono, &.code {
max-width: none !important;
}
}
textarea {
&.section {
border: 1px solid #ccc;
padding: 0.65em 0.75em;
.rounded(.25em);
&.codable {
height: 12em;
resize: vertical;
}
}
}
.ace_editor {
height: 12em;
border: 1px solid #333;
max-width: initial;
width: 100%;
font-size: 0.86em !important;
border: 1px solid #ccc;
padding: 0.65em 0.75em;
margin: 0;
.rounded(.25em);
}
p {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
&.intro {
font-size: 1.25em;
text-align: center;
}
&.upgrade-prompt {
font-size: 0.9em;
color: #444;
}
&.text-cta {
font-size: 1.2em;
text-align: center;
margin-bottom: 0.5em;
&+ p {
text-align: center;
font-size: 0.7em;
margin-top: 0;
color: #666;
}
}
&.error {
font-style: italic;
color: @errUrgentCol;
}
&.headeresque {
font-size: 2em;
}
}
table.classy {
width: 95%;
border-collapse: collapse;
margin-bottom: 2em;
tr + tr {
border-top: 1px solid #ccc;
}
th {
text-transform: uppercase;
font-weight: normal;
font-size: 95%;
font-family: @sansFont;
padding: 1rem 0.75rem;
text-align: center;
}
td {
height: 3.5rem;
}
p {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
&.export {
.disabled {
color: #999;
}
.disabled, a {
text-transform: lowercase;
}
}
}
article table {
border-spacing: 0;
border-collapse: collapse;
width: 100%;
th {
border-width: 1px 1px 2px 1px;
border-style: solid;
border-color: #ccc;
}
td {
border-width: 0 1px 1px 1px;
border-style: solid;
border-color: #ccc;
padding: .25rem .5rem;
}
}
body#collection article, body#subpage article {
padding-top: 0;
padding-bottom: 0;
.book {
h2 {
font-size: 1.4em;
}
a.hidden.action {
color: #666;
float: right;
font-size: 1em;
margin-left: 1em;
margin-bottom: 1em;
}
}
}
body#post article {
p.badge {
font-size: 0.9em;
}
}
article {
h2.post-title a[rel=nofollow]::after {
content: '\a0 \2934';
}
}
table.downloads {
width: 100%;
td {
text-align: center;
}
img.os {
width: 48px;
vertical-align: middle;
margin-bottom: 6px;
}
}
select.inputform, textarea.inputform {
border: 1px solid #999;
background: white;
}
input, button, select.inputform, textarea.inputform, a.btn {
padding: 0.5em;
font-family: @serifFont;
font-size: 100%;
.rounded(.25em);
&[type=submit], &.submit, &.cta {
border: 1px solid @primary;
background: @primary;
color: white;
.transition(0.2s);
&:hover {
background-color: lighten(@primary, 3%);
text-decoration: none;
}
&:disabled {
cursor: default;
background-color: desaturate(@primary, 100%) !important;
border-color: desaturate(@primary, 100%) !important;
}
}
&.error[type=text], textarea.error {
-webkit-transition: all 0.30s ease-in-out;
-moz-transition: all 0.30s ease-in-out;
-ms-transition: all 0.30s ease-in-out;
-o-transition: all 0.30s ease-in-out;
outline: none;
}
&.danger {
border: 1px solid @dangerCol;
background: @dangerCol;
color: white;
&:hover {
background-color: lighten(@dangerCol, 3%);
}
}
&.error[type=text]:focus, textarea.error:focus {
box-shadow: 0 0 5px @errUrgentCol;
border: 1px solid @errUrgentCol;
}
}
.btn.pager {
border: 1px solid @lightNavBorder;
font-size: .86em;
padding: .5em 1em;
white-space: nowrap;
font-family: @sansFont;
&:hover {
text-decoration: none;
background: @lightNavBorder;
}
}
.btn.cta.secondary, input[type=submit].secondary {
background: transparent;
color: @primary;
&:hover {
background-color: #f9f9f9;
}
}
.btn.cta.disabled {
background-color: desaturate(@primary, 100%) !important;
border-color: desaturate(@primary, 100%) !important;
}
div.flat-select {
display: inline-block;
position: relative;
select {
border: 0;
background: 0;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
opacity: 0;
}
&.action {
&:hover {
label {
text-decoration: underline;
}
}
label, select {
cursor: pointer;
}
}
}
input {
&.underline{
border: none;
border-bottom: 1px solid #ccc;
padding: 0 .2em .2em;
font-size: 0.9em;
color: #333;
}
&.inline {
padding: 0.2rem 0.2rem;
margin-left: 0;
font-size: 1em;
border: 0 !important;
border-bottom: 1px solid #999 !important;
width: 7em;
.rounded(0);
}
&[type=tel], &[type=text], &[type=email], &[type=password] {
border: 1px solid #999;
}
&.boxy {
border: 1px solid #999 !important;
}
}
#beta, .content-container {
max-width: 50em;
margin: 0 auto 3em;
font-size: 1.2em;
&.tight {
max-width: 30em;
}
&.snug {
max-width: 40em;
}
.app {
+ .app {
margin-top: 1.5em;
}
h2 {
margin-bottom: 0.25em;
}
p {
margin-top: 0.25em;
}
}
h2.intro {
font-weight: normal;
}
p {
line-height: 1.5;
}
li {
margin: 0.3em 0;
}
h2 {
&.light {
font-weight: normal;
}
a {
.transition-duration(0.2s);
-moz-transition-property: color;
-webkit-transition-property: color;
-o-transition-property: color;
transition-property: color;
&:link, &:visited, &:hover {
color: @subheaders;
}
&:hover {
color: lighten(@subheaders, 10%);
text-decoration: none;
}
}
}
}
.content-container {
&#pricing {
button {
cursor: pointer;
color: white;
margin-top: 1em;
margin-bottom: 1em;
padding-left: 1.5em;
padding-right: 1.5em;
border: 0;
background: @primary;
.rounded(.25em);
.transition(0.2s);
&:hover {
background-color: lighten(@primary, 5%);
}
&.unselected {
cursor: pointer;
}
}
h2 span {
font-weight: normal;
}
.half {
margin: 0 0 1em 0;
text-align: center;
}
}
div.blurbs {
>h2 {
text-align: center;
color: #333;
font-weight: normal;
}
p.price {
font-size: 1.2em;
margin-bottom: 0;
color: #333;
margin-top: 0.5em;
&+p {
margin-top: 0;
font-size: 0.8em;
}
}
p.text-cta {
font-size: 1em;
}
}
}
footer div.blurbs {
display: flex;
flex-flow: row;
flex-wrap: wrap;
}
div.blurbs {
.half, .third, .fourth {
font-size: 0.86em;
h3 {
font-weight: normal;
}
p, ul {
color: #595959;
}
hr {
margin: 1em 0;
}
}
.half {
padding: 0 1em 0 0;
width: ~"calc(50% - 1em)";
&+.half {
padding: 0 0 0 1em;
}
}
.third {
padding: 0;
width: ~"calc(33% - 1em)";
&+.third {
padding: 0 0 0 1em;
}
}
.fourth {
flex: 1 1 25%;
-webkit-flex: 1 1 25%;
h3 {
margin-bottom: 0.5em;
}
ul {
margin-top: 0.5em;
}
}
}
.contain-me {
text-align: left;
margin: 0 auto 4em;
max-width: 50em;
h2 + p, h2 + p + p, p.describe-me {
margin-left: 1.5em;
margin-right: 1.5em;
color: #333;
}
}
footer.contain-me {
font-size: 1.1em;
}
#official-writing, #wrapper {
h2, h3, h4 {
color: @subheaders;
}
ul {
&.collections {
padding-left: 0;
margin-left: 0;
h3 {
margin-top: 0;
font-weight: normal;
}
li {
&.collection {
a.title {
&:link, &:visited {
color: @headerTextColor;
}
}
}
a.create {
color: #444;
}
}
& + p {
margin-top: 2em;
margin-left: 1em;
}
}
}
}
#official-writing, #wrapper {
h2 {
&.major {
color: #222;
}
&.bugfix {
color: #666;
}
+.android-version {
a {
color: #999;
&:hover {
text-decoration: underline;
}
}
}
}
}
li {
line-height: 1.5;
.item-desc, .prog-lang {
font-size: 0.6em;
font-family: 'Open Sans', sans-serif;
font-weight: bold;
margin-left: 0.5em;
margin-right: 0.5em;
text-transform: uppercase;
color: #999;
}
}
.success {
color: darken(@proSelectedCol, 20%);
}
.alert {
padding: 1em;
margin-bottom: 1.25em;
border: 1px solid transparent;
.rounded(.25em);
&.info {
color: #31708f;
background-color: #d9edf7;
border-color: #bce8f1;
}
&.success {
color: #3c763d;
background-color: #dff0d8;
border-color: #d6e9c6;
}
&.danger {
border-color: #856404;
background-color: white;
h3 {
margin: 0 0 0.5em 0;
font-size: 1em;
font-weight: bold;
color: black !important;
}
h3 + p, button {
font-size: 0.86em;
}
}
p {
margin: 0;
&+p {
margin-top: 0.5em;
}
}
p.dismiss {
font-family: @sansFont;
text-align: right;
font-size: 0.86em;
text-transform: uppercase;
}
}
ul.errors {
padding: 0;
text-indent: 0;
li.urgent {
list-style: none;
font-style: italic;
text-align: center;
color: @errUrgentCol;
a:link, a:visited {
color: purple;
}
}
li.info {
list-style: none;
font-size: 1.1em;
text-align: center;
}
}
body#pad #target a.upgrade-prompt {
padding-left: 1em;
padding-right: 1em;
text-align: center;
font-style: italic;
color: @primary;
}
body#pad-sub #posts, .atoms {
margin-top: 1.5em;
h3 {
margin-bottom: 0.25em;
&+ h4 {
margin-top: 0.25em;
margin-bottom: 0.5em;
&+ p {
margin-top: 0.5em;
}
}
.electron {
font-weight: normal;
font-size: 0.86em;
margin-left: 0.75rem;
}
}
h3, h4 {
a {
.transition-duration(0.2s);
-moz-transition-property: color;
-webkit-transition-property: color;
-o-transition-property: color;
transition-property: color;
}
}
h4 {
font-size: 0.9em;
font-weight: normal;
}
date, .electron {
margin-right: 0.5em;
}
.action {
font-size: 1em;
}
#more-posts p {
text-align: center;
font-size: 1.1em;
}
p {
font-size: 0.86em;
}
.error {
display: inline-block;
font-size: 0.8em;
font-style: italic;
color: @errUrgentCol;
strong {
font-style: normal;
}
}
.error + nav {
display: inline-block;
font-size: 0.8em;
margin-left: 1em;
a + a {
margin-left: 0.75em;
}
}
}
h2 {
a, time {
&+.action {
margin-left: 0.5em;
}
}
}
.action {
font-size: 0.7em;
font-weight: normal;
font-family: @serifFont;
&+ .action {
margin-left: 0.5em;
}
&.new-post {
font-weight: bold;
}
}
article.moved {
p {
font-size: 1.2em;
color: #999;
}
}
span.as {
.opacity(0.2);
font-weight: normal;
}
span.ras {
.opacity(0.6);
font-weight: normal;
}
header {
nav {
.username {
font-size: 2em;
font-weight: normal;
color: #555;
}
&#user-nav {
margin-left: 0;
& > a, .tabs > a {
&.selected {
cursor: default;
font-weight: bold;
&:hover {
text-decoration: none;
}
}
& + a {
margin-left: 2em;
}
}
a {
font-size: 1.2em;
font-family: @sansFont;
span {
font-size: 0.7em;
color: #999;
text-transform: uppercase;
margin-left: 0.5em;
margin-right: 0.5em;
}
&.title {
font-size: 1.6em;
font-family: @serifFont;
font-weight: bold;
}
}
nav > ul > li:first-child {
&> a {
display: inline-block;
}
img {
position: relative;
top: -0.5em;
right: 0.3em;
}
}
ul ul {
font-size: 0.8em;
a {
padding-top: 0.25em;
padding-bottom: 0.25em;
}
}
li {
line-height: 1.5;
}
}
&.tabs {
margin: 0 0 0 1em;
}
&+ nav.tabs {
margin: 0;
}
}
&.singleuser {
margin: 0.5em 1em 0.5em 0.25em;
nav#user-nav {
nav > ul > li:first-child {
img {
top: -0.75em;
}
}
}
.right-side {
padding-top: 0.5em;
}
}
.dash-nav {
font-weight: bold;
}
}
li#create-collection {
display: none;
h4 {
margin-top: 0px;
margin-bottom: 0px;
}
input[type=submit] {
margin-left: 0.5em;
}
}
#collection-options {
.option {
textarea {
font-size: 0.86em;
font-family: @monoFont;
}
.section > p.explain {
font-size: 0.8em;
}
}
}
.img-placeholder {
text-align: center;
img {
max-width: 100%;
}
}
dl {
&.admin-dl-horizontal {
dt {
font-weight: bolder;
width: 360px;
}
dd {
line-height: 1.5;
}
}
}
dt {
float: left;
clear: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
form {
dt, dd {
padding: 0.5rem 0;
}
dt {
line-height: 1.8;
}
dd {
font-size: 0.86em;
line-height: 2;
}
&.prominent {
margin: 1em 0;
label {
font-weight: bold;
}
input, select {
width: 100%;
}
select {
font-size: 1em;
padding: 0.5rem;
display: block;
border-radius: 0.25rem;
margin: 0.5rem 0;
}
}
}
div.row {
display: flex;
align-items: center;
> div {
flex: 1;
}
}
.check, .blip {
font-size: 1.125em;
color: #71D571;
}
.ex.failure {
font-weight: bold;
color: @dangerCol;
}
@media all and (max-width: 450px) {
body#post {
header {
nav {
.xtra-feature {
display: none;
}
}
}
}
}
@media all and (min-width: 1280px) {
body#promo {
div.heading {
margin: 10em 0;
}
}
}
@media all and (min-width: 1600px) {
body#promo {
div.heading {
margin: 14em 0;
}
}
}
@media all and (max-width: 900px) {
.half.big {
padding: 0 !important;
width: 100% !important;
}
.third {
padding: 0 !important;
float: none;
width: 100% !important;
p.introduction {
font-size: 0.86em;
}
}
div.blurbs {
.fourth {
flex: 1 1 15em;
-webkit-flex: 1 1 15em;
}
}
.blurbs .third, .blurbs .half {
p, ul {
text-align: left;
}
}
.half-col, .big {
float: none;
text-align: center;
&+.half-col, &+.big {
margin-top: 4em !important;
margin-left: 0;
}
}
#beta, .content-container {
font-size: 1.15em;
}
}
@media all and (max-width: 600px) {
div.row:not(.admin-actions) {
flex-direction: column;
}
.half {
padding: 0 !important;
width: 100% !important;
}
.third {
width: 100% !important;
float: none;
}
body#promo {
div.heading {
margin: 6em 0;
}
h2 {
font-size: 1.6em;
}
.half-col a + a {
margin-left: 1em;
}
.half-col a.channel {
margin-left: auto !important;
margin-right: auto !important;
}
}
ul.add-integrations {
li {
display: list-item;
&+ li {
margin-left: 0;
}
}
}
}
@media all and (max-height: 500px) {
body#promo {
div.heading {
margin: 5em 0;
}
}
}
@media all and (max-height: 400px) {
body#promo {
div.heading {
margin: 0em 0;
}
}
}
/* Smartphones (portrait and landscape) ----------- */
@media only screen and (min-device-width : 320px) and (max-device-width : 480px) {
header {
.opacity(1);
}
}
/* Smartphones (portrait) ----------- */
@media only screen and (max-width : 320px) {
.content-container#pricing {
.half {
float: none;
width: 100%;
}
}
header {
.opacity(1);
}
}
/* iPads (portrait and landscape) ----------- */
@media only screen and (min-device-width : 768px) and (max-device-width : 1024px) {
header {
.opacity(1);
}
}
@media (pointer: coarse) {
body footer nav a:not(.pubd) {
padding: 0.8em 1em;
margin-left: 0;
margin-top: 0;
}
article {
.hidden {
.opacity(1);
}
}
}
@media print {
h1 {
page-break-before: always;
}
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
}
table, figure {
page-break-inside: avoid;
}
header, footer {
display: none;
}
article#post-body {
margin-top: 2em;
margin-left: 0;
margin-right: 0;
}
hr {
border: 1px solid #ccc;
}
}
.code-block {
padding: 0;
max-width: 100%;
margin: 0;
background: #f8f8f8;
border: 1px solid #ccc;
padding: 0.375em 0.625em;
font-size: 0.86em;
.rounded(.25em);
}
pre.code-block {
overflow-x: auto;
}
#org-nav {
font-family: @sansFont;
font-size: 1.1em;
color: #888;
em, strong {
color: #000;
}
&+h1 {
margin-top: 0.5em;
}
a:link, a:visited, a:hover {
color: @accent;
}
a:first-child {
margin-right: 0.25em;
}
a.coll-name {
font-weight: bold;
margin-left: 0.25em;
}
}
\ No newline at end of file
diff --git a/locales.go b/locales.go
new file mode 100644
index 0000000..bf153bb
--- /dev/null
+++ b/locales.go
@@ -0,0 +1,44 @@
+package writefreely
+
+import (
+ //"fmt"
+ "io/ioutil"
+ "os"
+ //"encoding/json"
+
+ "github.com/writeas/web-core/log"
+ "git.lainoa.eus/aitzol/po2json"
+)
+
+func (app *App) GenJsonFiles(){
+
+ lang := app.cfg.App.Lang
+ log.Info("Generating json %s locales...", lang)
+ //fmt.Println(lang)
+ locales := []string {}
+ domain := "base"
+ localedir := "./locales"
+ files,_ := ioutil.ReadDir(localedir)
+ for _, f := range files {
+ if f.IsDir() {
+ //fmt.Println(f.Name())
+ locales = append(locales, f.Name(),)
+
+ if (lang == f.Name()){ //only creates json file for locale set in config.ini
+ //file, _ := json.MarshalIndent(po2json.PO2JSON([]string{f.Name(),}, domain, localedir), "", " ")
+ file := po2json.PO2JSON([]string{f.Name(),}, domain, localedir)
+
+ //err := os.WriteFile(f.Name()+".json",[]byte(file), 0666)
+ err := ioutil.WriteFile("static/js/"+f.Name()+".json",[]byte(file), 0666)
+ if err != nil {
+ log.Error(err.Error())
+ os.Exit(1)
+ }
+ }
+
+ }
+ }
+
+ //fmt.Println(po2json.PO2JSON(locales, domain, localedir))
+
+}
\ No newline at end of file
diff --git a/locales/base.pot b/locales/base.pot
new file mode 100644
index 0000000..77136a7
--- /dev/null
+++ b/locales/base.pot
@@ -0,0 +1,1672 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: GOtext\n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: Aitzol Berasategi <aitzol@lainoa.eus>\n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: en\n"
+"X-Generator: Poedit 2.4.2\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+# base.tmpl 40
+# header.tmpl 49
+# pages.tmpl 21
+msgid "Home"
+msgstr "Home"
+
+# base.tmpl 42
+# header.tmpl 51
+# footer.tmpl 8,26
+# user/include/footer.tmpl 11
+msgid "About"
+msgstr "About"
+
+# base.tmpl 49
+# header.tmpl 58,65
+# footer.tmpl 9,27
+# user/include/footer.tmpl 12
+# app-settings.tmpl 108
+# pages.tmpl 24
+msgid "Reader"
+msgstr "Reader"
+
+# login.tmpl 21
+# password-collection.tmpl 33
+# base.tmpl 51
+# header.tmpl 60
+msgid "Log in"
+msgstr "Log in"
+
+# classic.tmpl 48
+# collection.tmpl 63,74
+# pad.tmpl 43
+# base.tmpl 33,51
+# header.tmpl 14,41,60
+msgid "Log out"
+msgstr "Log out"
+
+# base.tmpl 45
+# export.tmpl 19
+# collections.tmpl 13
+# user/collection.tmpl 105
+# admin.tmpl 59
+# nav.tmpl 2
+# header.tmpl 54,63
+# view-user 106
+msgid "Blog"
+msgid_plural "Blogs"
+msgstr[0] "Blog"
+msgstr[1] "Blogs"
+
+# bare.tmpl 33
+# export.tmpl 19
+# admin.tmpl 60
+# view-user 132
+msgid "Post"
+msgid_plural "Posts"
+msgstr[0] "Post"
+msgstr[1] "Posts"
+
+# bare.tmpl 26
+# header.tmpl 55
+msgid "My Posts"
+msgstr "My Posts"
+
+# classic.tmpl 38
+# edit-meta.tmpl 43
+# pad.tmpl 26,27,33
+# post.tmpl 47
+# base.tmpl 47
+# bare.tmpl 26
+# stats.tmpl 55
+# import.tmpl 41
+# articles.tmpl 25
+# header.tmpl 21,56,64
+# posts.tmpl 23,30,50,57
+msgid "Draft"
+msgid_plural "Drafts"
+msgstr[0] "Draft"
+msgstr[1] "Drafts"
+
+# login.tmpl 27
+# base.tmpl 50
+# header.tmpl 59
+msgid "Sign up"
+msgstr "Sign up"
+
+# classic.tmpl 5
+# collection.tmpl 53
+# pad.tmpl 5
+# base.tmpl 55
+# nav.tmpl 9
+# header.tmpl 25,71
+msgid "New Post"
+msgstr "New Post"
+
+# header.tmpl 29
+msgid "Return to editor"
+msgstr "Return to editor"
+
+# footer.tmpl 10,14,34
+# user/include/footer.tmpl 13
+msgid "writer's guide"
+msgstr "writer's guide"
+
+# footer.tmpl 15,35
+msgid "developers"
+msgstr "developers"
+
+# footer.tmpl 11,28
+# user/include/footer.tmpl 14
+msgid "privacy"
+msgstr "privacy"
+
+# footer.tmpl 16,36
+msgid "source code"
+msgstr "source code"
+
+# base.tmpl 28
+# header.tmpl 9,35
+msgid "Admin dashboard"
+msgstr "Admin dashboard"
+
+# base.tmpl 29
+# header.tmpl 10,36
+msgid "Account settings"
+msgstr "Account settings"
+
+# import.tmpl 17
+# header.tmpl 11,37
+msgid "Import posts"
+msgstr "Import posts"
+
+# base.tmpl 30
+# export.tmpl 5,10
+# header.tmpl 12,38
+msgid "Export"
+msgstr "Export"
+
+# header.tmpl 103
+msgid "Dashboard"
+msgstr "Dashboard"
+
+# header.tmpl 104
+msgid "Settings"
+msgstr "Settings"
+
+# export.tmpl 19
+# admin.tmpl 58
+# header.tmpl 106
+# view-user.tmpl 59
+# users.tmpl 15,21,30
+msgid "User"
+msgid_plural "Users"
+msgstr[0] "User"
+msgstr[1] "Users"
+
+# header.tmpl 107
+# pages.tmpl 13, 17
+msgid "Page"
+msgid_plural "Pages"
+msgstr[0] "Page"
+msgstr[1] "Pages"
+
+# post.tmpl 41
+# posts.tmpl 11,43,64,66
+# view-user 128
+msgid "View"
+msgid_plural "Views"
+msgstr[0] "View"
+msgstr[1] "Views"
+
+# posts.tmpl 11,43,64,66
+# view-user 128
+# edit-meta.tmpl 49,55
+# pad.tmpl 63
+msgid "View post"
+msgid_plural "View posts"
+msgstr[0] "View post"
+msgstr[1] "View posts"
+
+# classic.tmpl 41,45
+# collection.tmpl 57
+# header.tmpl 7
+# edit-meta.tmpl 41
+# pad.tmpl 24,36,40
+# nav.tmpl 12
+msgid "View Blog"
+msgid_plural "View Blogs"
+msgstr[0] "View Blog"
+msgstr[1] "View Blogs"
+
+# classic.tmpl 62
+# pad.tmpl 124
+msgid "word"
+msgid_plural "words"
+msgstr[0] "word"
+msgstr[1] "words"
+
+# pad.tmpl 64
+msgid "Publish"
+msgstr "Publish"
+
+# pad.tmpl 20
+# bare.tmpl 20
+msgid "This post has been updated elsewhere since you last published!"
+msgstr "This post has been updated elsewhere since you last published!"
+
+# pad.tmpl 20
+# bare.tmpl 20
+msgid "Delete draft and reload"
+msgstr "Delete draft and reload"
+
+msgid "Updates"
+msgstr "Updates"
+
+# classic.tmpl 42
+# pad.tmpl 37
+# user/collection.tmpl 32
+# collection.tmpl 54
+# nav.tmpl 10
+# header.tmpl 19
+msgid "Customize"
+msgstr "Customize"
+
+# classic.tmpl 43
+# collection.tmpl 55
+# pad.tmpl 38
+# stats.tmpl 26
+# nav.tmpl 11
+# header.tmpl 20
+msgid "Stats"
+msgstr "Stats"
+
+# classic.tmpl 47
+# collection.tmpl 58
+# pad.tmpl 42
+msgid "View Draft"
+msgid_plural "View Drafts"
+msgstr[0] "View Draft"
+msgstr[1] "View Drafts"
+
+# header.tmpl 111
+msgid "Monitor"
+msgstr "Monitor"
+
+# read.tmpl 108,110
+msgid "Read more..."
+msgstr "Read more..."
+
+# silenced.tmpl 3
+msgid "Your account has been silenced."
+msgstr "Your account has been silenced."
+
+# silenced.tmpl 3
+msgid "You can still access all of your posts and blogs, but no one else can currently see them."
+msgstr "You can still access all of your posts and blogs, but no one else can currently see them."
+
+# articles.tmpl 28
+msgid "These are your draft posts. You can share them individually (without a blog) or move them to your blog when you're ready."
+msgstr "These are your draft posts. You can share them individually (without a blog) or move them to your blog when you're ready."
+
+# articles.tmpl 57
+msgid "Your anonymous and draft posts will show up here once you've published some. You'll be able to share them individually (without a blog) or move them to a blog when you're ready."
+msgstr "Your anonymous and draft posts will show up here once you've published some. You'll be able to share them individually (without a blog) or move them to a blog when you're ready."
+
+# articles.tmpl 58
+msgid "Alternatively, see your blogs and their posts on your %s page."
+msgstr "Alternatively, see your blogs and their posts on your %s page."
+
+# articles.tmpl 60
+msgid "Start writing"
+msgstr "Start writing"
+
+# articles.tmpl 64
+# static/js/postactions.jsmsgid "unsynced posts"
+msgstr "unsynced posts"
+
+# articles.tmpl 64
+# collection.tmpl 43
+# view-page.tmpl 51
+# view-user 116
+msgid "Title"
+msgstr "Title"
+
+# user/collection.tmpl 44
+# view-user 120
+msgid "Description"
+msgstr "Description"
+
+# user/collection.tmpl 50
+msgid "This blog uses your username in its URL."
+msgstr "This blog uses your username in its URL."
+
+# user/collection.tmpl 50
+msgid "This blog uses your username in its URL and fediverse handle."
+msgstr "This blog uses your username in its URL and fediverse handle."
+
+# user/collection.tmpl 50
+msgid "You can change it in your %s."
+msgstr "You can change it in your %s."
+
+# user/collection.tmpl 63
+msgid "Publicity"
+msgstr "Publicity"
+
+# user/collection.tmpl 68
+# app-settings.tmpl 120
+msgid "Unlisted"
+msgstr "Unlisted"
+
+# user/collection.tmpl 87
+# app-settings.tmpl 121
+msgid "Public"
+msgstr "Public"
+
+# user/collection.tmpl 74
+# app-settings.tmpl 122
+msgid "Private"
+msgstr "Private"
+
+# user/collection.tmpl 70
+msgid "This blog is visible to any registered user on this instance."
+msgstr "This blog is visible to any registered user on this instance."
+
+# user/collection.tmpl 70
+msgid "This blog is visible to anyone with its link."
+msgstr "This blog is visible to anyone with its link."
+
+# user/collection.tmpl 76
+msgid "Only you may read this blog (while you're logged in)."
+msgstr "Only you may read this blog (while you're logged in)."
+
+# user/collection.tmpl 80
+msgid "Password-protected:"
+msgstr "Password-protected:"
+
+# user/collection.tmpl 80
+msgid "a memorable password"
+msgstr "a memorable password"
+
+# user/collection.tmpl 82
+msgid "A password is required to read this blog."
+msgstr "A password is required to read this blog."
+
+# user/collection.tmpl 89
+msgid "This blog is displayed on the public %s, and is visible to anyone with its link."
+msgstr "This blog is displayed on the public %s, and is visible to anyone with its link."
+
+# user/collection.tmpl 89
+msgid "This blog is displayed on the public %s, and is visible to any registered user on this instance."
+msgstr "This blog is displayed on the public %s, and is visible to any registered user on this instance."
+
+# user/collection.tmpl 90
+msgid "The public reader is currently turned off for this community."
+msgstr "The public reader is currently turned off for this community."
+
+# user/collection.tmpl 98
+msgid "Display Format"
+msgstr "Display Format"
+
+# user/collection.tmpl 100
+msgid "Customize how your posts display on your page."
+msgstr "Customize how your posts display on your page."
+
+# user/collection.tmpl 107
+msgid "Dates are shown. Latest posts listed first."
+msgstr "Dates are shown. Latest posts listed first."
+
+# user/collection.tmpl 113
+msgid "No dates shown. Oldest posts first."
+msgstr "No dates shown. Oldest posts first."
+
+# user/collection.tmpl 119
+msgid "No dates shown. Latest posts first."
+msgstr "No dates shown. Latest posts first."
+
+# user/collection.tmpl 126
+msgid "Text Rendering"
+msgstr "Text Rendering"
+
+# user/collection.tmpl 128
+msgid "Customize how plain text renders on your blog."
+msgstr "Customize how plain text renders on your blog."
+
+# user/collection.tmpl 145
+msgid "Custom CSS"
+msgstr "Custom CSS"
+
+# user/collection.tmpl 148
+msgid "customization"
+msgstr "customization"
+
+# user/collection.tmpl 148
+msgid "See our guide on %s."
+msgstr "See our guide on %s."
+
+# user/collection.tmpl 153
+msgid "Post Signature"
+msgstr "Post Signature"
+
+# user/collection.tmpl 155
+msgid "This content will be added to the end of every post on this blog, as if it were part of the post itself. Markdown, HTML, and shortcodes are allowed."
+msgstr "This content will be added to the end of every post on this blog, as if it were part of the post itself. Markdown, HTML, and shortcodes are allowed."
+
+# user/collection.tmpl 162
+msgid "Web Monetization"
+msgstr "Web Monetization"
+
+# user/collection.tmpl 164
+msgid "Web Monetization enables you to receive micropayments from readers that have a %s. Add your payment pointer to enable Web Monetization on your blog."
+msgstr "Web Monetization enables you to receive micropayments from readers that have a %s. Add your payment pointer to enable Web Monetization on your blog."
+
+# user/collection.tmpl 164
+msgid "Coil membership"
+msgstr "Coil membership"
+
+# edit-meta.tmpl 263
+# settings.tmpl 81
+# user/collection.tmpl 171
+msgid "Save changes"
+msgstr "Save changes"
+
+# user/collection.tmpl 173
+msgid "Delete Blog..."
+msgstr "Delete Blog..."
+
+# user/collection.tmpl 190,220
+# articles.tmpl 36
+# posts.tmpl 19,46
+msgid "Delete"
+msgstr "Delete"
+
+# settings.tmpl 187
+# user/collection.tmpl 189
+# view-user 173
+msgid "Cancel"
+msgstr "Cancel"
+
+# user/collection.tmpl 180
+msgid "Are you sure you want to delete this blog?"
+msgstr "Are you sure you want to delete this blog?"
+
+# posts.js 46,147
+# collection.tmpl 148
+# collection-tags.tmpl 96
+# chorus-collection.tmpl 132
+msgid "Are you sure you want to delete this post?"
+msgstr "Are you sure you want to delete this post?"
+
+# posts.js 302
+# collection.tmpl 175,226
+# collection-tags.tmpl 123,174
+# chorus-collection.tmpl 159,210
+msgid "Post is synced to another account. Delete the post from that account instead."
+msgstr "Post is synced to another account. Delete the post from that account instead."
+
+# posts.js 308
+# collection.tmpl 181
+# collection-tags.tmpl 129
+# chorus-collection.tmpl 165
+msgid "Failed to delete."
+msgstr "Failed to delete."
+
+# posts.js 308
+# collection.tmpl 181
+# collection-tags.tmpl 129
+# chorus-collection.tmpl 165
+msgid "Please try again."
+msgstr "Please try again."
+
+# user/collection.tmpl 182
+msgid "This will permanently erase **%s** (%s/%s) from the internet. Any posts on this blog will be saved and made into drafts (found on your %s page)."
+msgstr "This will permanently erase **%s** (%s/%s) from the internet. Any posts on this blog will be saved and made into drafts (found on your %s page)."
+
+# user/collection.tmpl 183
+msgid "If you're sure you want to delete this blog, enter its name in the box below and press **%s**."
+msgstr "If you're sure you want to delete this blog, enter its name in the box below and press **%s**."
+
+# user/collection.tmpl 202
+msgid "Enter **%s** in the box below."
+msgstr "Enter **%s** in the box below."
+
+# user/collection.tmpl 238
+msgid "Saving changes..."
+msgstr "Saving changes..."
+
+# collections.tmpl 72,75,84
+msgid "This name is taken."
+msgstr "This name is taken."
+
+# pad.tmpl 61
+msgid "Edit post metadata"
+msgstr "Edit post metadata"
+
+# edit-meta.tmpl 5,55
+msgid "Edit metadata"
+msgstr "Edit metadata"
+
+# edit-meta.tmpl 260
+msgid "now"
+msgstr "now"
+
+# edit-meta.tmpl 63
+msgid "Slug"
+msgstr "Slug"
+
+# edit-meta.tmpl 66
+msgid "Language"
+msgstr "Language"
+
+# edit-meta.tmpl 256
+msgid "Direction"
+msgstr "Direction"
+
+# edit-meta.tmpl 258
+msgid "Created"
+msgstr "Created"
+
+# edit-meta.tmpl 257
+msgid "right-to-left"
+msgstr "right-to-left"
+
+# edit-meta.tmpl 47
+msgid "Edit post"
+msgstr "Edit post"
+
+# edit-meta.tmpl 48
+# pad.tmpl 62
+msgid "Toggle theme"
+msgstr "Toggle theme"
+
+# collection-post.tmpl 66
+# posts.tmpl 8
+msgid "Scheduled"
+msgstr "Scheduled"
+
+# collection-post.tmpl 57
+# post.tmpl 45,91
+# articles.tmpl 35
+# posts.tmpl 17,44
+msgid "Edit"
+msgstr "Edit"
+
+# posts.tmpl 18,45
+msgid "Pin"
+msgstr "Pin"
+
+# collection-post.tmpl 58
+msgid "Unpin"
+msgstr "Unpin"
+
+# posts.tmpl 21,48
+msgid "Move this post to another blog"
+msgstr "Move this post to another blog"
+
+# posts.tmpl 30,57
+msgid "Change to a draft"
+msgstr "Change to a draft"
+
+# posts.tmpl 30,57
+msgid "change to _%s_"
+msgstr "change to _%s_"
+
+# articles.tmpl 43,78
+# posts.tmpl 26,53
+msgid "move to..."
+msgstr "move to..."
+
+# articles.tmpl 47,83
+msgid "move to %s"
+msgstr "move to %s"
+
+# post.tmpl 42
+msgid "View raw"
+msgstr "View raw"
+
+# articles.tmpl 47,83
+msgid "Publish this post to your blog %s"
+msgstr "Publish this post to your blog %s"
+
+# articles.tmpl 39,74
+msgid "Move this post to one of your blogs"
+msgstr "Move this post to one of your blogs"
+
+# articles.tmpl 55
+msgid "Load more..."
+msgstr "Load more..."
+
+# stats.tmpl 32
+msgid "Stats for all time."
+msgstr "Stats for all time."
+
+# stats.tmpl 35
+msgid "Fediverse stats"
+msgstr "Fediverse stats"
+
+# stats.tmpl 38
+msgid "Followers"
+msgstr "Followers"
+
+# stats.tmpl 46
+msgid "Top %d post"
+msgid_plural "Top %d posts"
+msgstr[0] "Top %d post"
+msgstr[1] "Top %d posts"
+
+# stats.tmpl 51
+msgid "Total Views"
+msgstr "Total Views"
+
+# settings.tmpl 27
+msgid "Before you go..."
+msgstr "Before you go..."
+
+# settings.tmpl 27
+msgid "Account Settings"
+msgstr "Account Settings"
+
+# settings.tmpl 38
+msgid "Change your account settings here."
+msgstr "Change your account settings here."
+
+# signup.tmpl 80
+# signup-oauth.tmpl 85,87
+# login.tmpl 21
+# landing.tmpl 92
+# settings.tmpl 43
+# view-user.tmpl 62
+msgid "Username"
+msgstr "Username"
+
+# settings.tmpl 46
+msgid "Update"
+msgstr "Update"
+
+# settings.tmpl 56
+msgid "Passphrase"
+msgstr "Passphrase"
+
+# settings.tmpl 58
+msgid "Add a passphrase to easily log in to your account."
+msgstr "Add a passphrase to easily log in to your account."
+
+# settings.tmpl 59,60
+msgid "Current passphrase"
+msgstr "Current passphrase"
+
+# settings.tmpl 61,64
+msgid "New passphrase"
+msgstr "New passphrase"
+
+# settings.tmpl 60,64
+msgid "Show"
+msgstr "Show"
+
+msgid "Account updated."
+msgstr "Account updated."
+
+# signup.tmpl 91
+# signup-oauth.tmpl 92,94
+# landing.tmpl 103
+# settings.tmpl 69
+msgid "Email"
+msgstr "Email"
+
+# settings.tmpl 76
+msgid "Email address"
+msgstr "Email address"
+
+# settings.tmpl 71
+msgid "Add your email to get:"
+msgstr "Add your email to get:"
+
+# settings.tmpl 34
+msgid "Please add an **%s** and/or **%s** so you can log in again later."
+msgstr "Please add an **%s** and/or **%s** so you can log in again later."
+
+# settings.tmpl 73
+msgid "No-passphrase login"
+msgstr "No-passphrase login"
+
+# settings.tmpl 74
+msgid "Account recovery if you forget your passphrase"
+msgstr "Account recovery if you forget your passphrase"
+
+# settings.tmpl 89
+msgid "Linked Accounts"
+msgstr "Linked Accounts"
+
+# settings.tmpl 90
+msgid "These are your linked external accounts."
+msgstr "These are your linked external accounts."
+
+# settings.tmpl 114
+msgid "Link External Accounts"
+msgstr "Link External Accounts"
+
+msgid "Connect additional accounts to enable logging in with those providers, instead of using your username and password."
+msgstr "Connect additional accounts to enable logging in with those providers, instead of using your username and password."
+
+# settings.tmpl 162
+# view-user 149
+msgid "Incinerator"
+msgstr "Incinerator"
+
+# settings.tmpl 166,169,188
+msgid "Delete your account"
+msgstr "Delete your account"
+
+# settings.tmpl 167
+msgid "Permanently erase all your data, with no way to recover it."
+msgstr "Permanently erase all your data, with no way to recover it."
+
+# settings.tmpl 176
+# view-user 163
+msgid "Are you sure?"
+msgstr "Are you sure?"
+
+# settings.tmpl 178
+msgid "export your data"
+msgstr "export your data"
+
+# settings.tmpl 178
+msgid "This action **cannot** be undone. It will immediately and permanently erase your account, including your blogs and posts. Before continuing, you might want to %s."
+msgstr "This action **cannot** be undone. It will immediately and permanently erase your account, including your blogs and posts. Before continuing, you might want to %s."
+
+# settings.tmpl 179
+msgid "If you're sure, please type **%s** to confirm."
+msgstr "If you're sure, please type **%s** to confirm."
+
+# invite-help.tmpl 13
+msgid "Invite to %s"
+msgstr "Invite to %s"
+
+# invite-help.tmpl 15
+msgid "This invite link is expired."
+msgstr "This invite link is expired."
+
+# invite-help.tmpl 21
+msgid "Only **one** user"
+msgstr "Only **one** user"
+
+# invite-help.tmpl 21
+msgid "Up to **%d** users"
+msgstr "Up to **%d** users"
+
+# invite-help.tmpl 21
+msgid "can sign up with this link."
+msgstr "can sign up with this link."
+
+# invite-help.tmpl 23
+msgid "It expires on **%s**."
+msgstr "It expires on **%s**."
+
+# invite-help.tmpl 25
+msgid "It can be used as many times as you like"
+msgstr "It can be used as many times as you like"
+
+# invite-help 25
+msgid "before **%s**, when it expires"
+msgstr "before **%s**, when it expires"
+
+msgid "person has"
+msgid_plural "person have"
+msgstr[0] "person has"
+msgstr[1] "person have"
+
+# invite-help.tmpl 21
+msgid "So far, **%d** %s used it."
+msgstr "So far, **%d** %s used it."
+
+# invite-help.tmpl 17
+msgid "Copy the link below and send it to anyone that you want to join *%s*. You could paste it into an email, instant message, text message, or write it down on paper. Anyone who navigates to this special page will be able to create an account."
+msgstr "Copy the link below and send it to anyone that you want to join *%s*. You could paste it into an email, instant message, text message, or write it down on paper. Anyone who navigates to this special page will be able to create an account."
+
+# IMPORT PAGE
+# import.tmpl 28
+msgid "Publish plain text or Markdown files to your account by uploading them below."
+msgstr "Publish plain text or Markdown files to your account by uploading them below."
+
+# import.tmpl 31
+msgid "Select some files to import:"
+msgstr "Select some files to import:"
+
+# import.tmpl 36
+msgid "Import these posts to:"
+msgstr "Import these posts to:"
+
+# import.tmpl 59
+msgid "Import"
+msgstr "Import"
+
+msgid "Import complete, %d post imported."
+msgid_plural "Import complete, %d posts imported."
+msgstr[0] "Import complete, %d post imported."
+msgstr[1] "Import complete, %d posts imported."
+
+msgid "%d of %d posts imported, see details below."
+msgid_plural "%d of %d posts imported, see details below."
+msgstr[0] "%d of %d posts imported, see details below."
+msgstr[1] "%d of %d posts imported, see details below."
+
+msgid "%s is not a supported post file"
+msgstr "%s is not a supported post file"
+
+# export.tmpl 6
+msgid "Your data on %s is always free. Download and back-up your work any time."
+msgstr "Your data on %s is always free. Download and back-up your work any time."
+
+# export.tmpl 11
+msgid "Format"
+msgstr "Format"
+
+# header.tmpl 101
+msgid "Admin"
+msgstr "Admin"
+
+# app-settings.tmpl 37
+msgid "Site Title"
+msgstr "Site Title"
+
+# app-settings.tmpl 38
+msgid "Your public site name."
+msgstr "Your public site name."
+
+# app-settings.tmpl 44
+msgid "Site Description"
+msgstr "Site Description"
+
+# app-settings.tmpl 45
+msgid "Describe your site — this shows in your site's metadata."
+msgstr "Describe your site — this shows in your site's metadata."
+
+# app-settings.tmpl 51
+msgid "Host"
+msgstr "Host"
+
+# app-settings.tmpl 52
+msgid "The public address where users will access your site, starting with `http://` or `https://`."
+msgstr "The public address where users will access your site, starting with `http://` or `https://`."
+
+# app-settings.tmpl 58
+msgid "Community Mode"
+msgstr "Community Mode"
+
+# app-settings.tmpl 59
+msgid "Whether your site is made for one person or many."
+msgstr "Whether your site is made for one person or many."
+
+# app-settings.tmpl 61
+msgid "Single user"
+msgstr "Single user"
+
+# app-settings.tmpl 61
+msgid "Multiple users"
+msgstr "Multiple users"
+
+# app-settings.tmpl 65
+msgid "Landing Page"
+msgstr "Landing Page"
+
+# app-settings.tmpl 66
+msgid "The page that logged-out visitors will see first. This should be an absolute path like: `/read`."
+msgstr "The page that logged-out visitors will see first. This should be an absolute path like: `/read`."
+
+# app-settings.tmpl 72
+msgid "Open Registrations"
+msgstr "Open Registrations"
+
+# app-settings.tmpl 73
+msgid "Allow anyone who visits the site to create an account."
+msgstr "Allow anyone who visits the site to create an account."
+
+# app-settings.tmpl 80
+msgid "Allow account deletion"
+msgstr "Allow account deletion"
+
+# app-settings.tmpl 81
+msgid "Allow all users to delete their account. Admins can always delete users."
+msgstr "Allow all users to delete their account. Admins can always delete users."
+
+# app-settings.tmpl 88
+msgid "Allow invitations from..."
+msgstr "Allow invitations from..."
+
+# app-settings.tmpl 89
+msgid "Choose who is allowed to invite new people."
+msgstr "Choose who is allowed to invite new people."
+
+# app-settings.tmpl 93
+msgid "No one"
+msgstr "No one"
+
+# app-settings.tmpl 94
+msgid "Only Admins"
+msgstr "Only Admins"
+
+# app-settings.tmpl 95
+msgid "All Users"
+msgstr "All Users"
+
+# app-settings.tmpl 101
+msgid "Private Instance"
+msgstr "Private Instance"
+
+# app-settings.tmpl 102
+msgid "Limit site access to people with an account."
+msgstr "Limit site access to people with an account."
+
+# app-settings.tmpl 109
+msgid "Show a feed of user posts for anyone who chooses to share there."
+msgstr "Show a feed of user posts for anyone who chooses to share there."
+
+# app-settings.tmpl 115
+msgid "Default blog visibility"
+msgstr "Default blog visibility"
+
+# app-settings.tmpl 116
+msgid "The default setting for new accounts and blogs."
+msgstr "The default setting for new accounts and blogs."
+
+# app-settings.tmpl 128
+msgid "Maximum Blogs per User"
+msgstr "Maximum Blogs per User"
+
+# app-settings.tmpl 129
+msgid "Keep things simple by setting this to **1**, unlimited by setting to **0**, or pick another amount."
+msgstr "Keep things simple by setting this to **1**, unlimited by setting to **0**, or pick another amount."
+
+# app-settings.tmpl 135
+msgid "Federation"
+msgstr "Federation"
+
+# app-settings.tmpl 136
+msgid "Enable accounts on this site to propagate their posts via the ActivityPub protocol."
+msgstr "Enable accounts on this site to propagate their posts via the ActivityPub protocol."
+
+# app-settings.tmpl 142
+msgid "Public Stats"
+msgstr "Public Stats"
+
+# app-settings.tmpl 143
+msgid "Publicly display the number of users and posts on your **%s** page."
+msgstr "Publicly display the number of users and posts on your **%s** page."
+
+# app-settings.tmpl 149
+msgid "Monetization"
+msgstr "Monetization"
+
+# app-settings.tmpl 150
+msgid "Enable blogs on this site to receive micropayments from readers via %s."
+msgstr "Enable blogs on this site to receive micropayments from readers via %s."
+
+# app-settings.tmpl 156
+msgid "Minimum Username Length"
+msgstr "Minimum Username Length"
+
+# app-settings.tmpl 157
+msgid "The minimum number of characters allowed in a username. (Recommended: 2 or more.)"
+msgstr "The minimum number of characters allowed in a username. (Recommended: 2 or more.)"
+
+# app-settings.tmpl 162
+msgid "Save Settings"
+msgstr "Save Settings"
+
+# app-settings.tmpl 166
+msgid "configuration docs"
+msgstr "configuration docs"
+
+# app-settings.tmpl 166
+msgid "Still have questions? Read more details in the %s."
+msgstr "Still have questions? Read more details in the %s."
+
+msgid "Configuration saved."
+msgstr "Configuration saved."
+
+# view-user.tmpl 66
+# users.tmpl 22
+msgid "joined"
+msgstr "joined"
+
+# users.tmpl 23
+msgid "type"
+msgstr "type"
+
+# users.tmpl 24
+# view-user.tmpl 79
+msgid "status"
+msgstr "status"
+
+# base.tmpl 31
+# header.tmpl 39
+# invite.tmpl 26
+# users.tmpl 16
+msgid "Invite people"
+msgstr "Invite people"
+
+# invite.tmpl 27
+msgid "Invite others to join *%s* by generating and sharing invite links below."
+msgstr "Invite others to join *%s* by generating and sharing invite links below."
+
+# invite.tmpl 31
+msgid "Maximum number of uses:"
+msgstr "Maximum number of uses:"
+
+# invite.tmpl 33
+msgid "No limit"
+msgstr "No limit"
+
+# invite.tmpl 34,35,36,37,38,39,64
+msgid "use"
+msgid_plural "uses"
+msgstr[0] "use"
+msgstr[1] "uses"
+
+# invite.tmpl 43
+msgid "Expire after:"
+msgstr "Expire after:"
+
+# invite.tmpl 46
+msgid "minute"
+msgid_plural "minutes"
+msgstr[0] "minute"
+msgstr[1] "minutes"
+
+# invite.tmpl 47,48,49
+msgid "hour"
+msgid_plural "hours"
+msgstr[0] "hour"
+msgstr[1] "hours"
+
+# invite.tmpl 50,51
+msgid "day"
+msgid_plural "days"
+msgstr[0] "day"
+msgstr[1] "days"
+
+# invite.tmpl 52
+msgid "week"
+msgid_plural "weeks"
+msgstr[0] "week"
+msgstr[1] "weeks"
+
+# invite.tmpl 57
+msgid "You cannot generate invites while your account is silenced."
+msgstr "You cannot generate invites while your account is silenced."
+
+# invite.tmpl 57
+msgid "Generate"
+msgstr "Generate"
+
+# invite.tmpl 63
+msgid "Link"
+msgstr "Link"
+
+# invite.tmpl 121,129,137,145,152
+msgid "ToLink"
+msgstr "Link"
+
+# invite.tmpl 65
+msgid "Expires"
+msgstr "Expires"
+
+# invite.tmpl 71
+msgid "Expired"
+msgstr "Expired"
+
+# invite.tmpl 75
+msgid "No invites generated yet."
+msgstr "No invites generated yet."
+
+# pages.tmpl 18
+msgid "last modified"
+msgstr "last modified"
+
+# view-user.tmpl 85
+# users.tmpl 31
+msgid "Active"
+msgstr "Active"
+
+# view-user.tmpl 82
+# users.tmpl 31
+msgid "Silenced"
+msgstr "Silenced"
+
+# view-user.tmpl 83
+msgid "Unsilence"
+msgstr "Unsilence"
+
+# view-user 86
+msgid "disabled"
+msgstr "disabled"
+
+# view-user 86
+msgid "Silence"
+msgstr "Silence"
+
+# view-user.tmpl 54
+msgid "No."
+msgstr "No."
+
+# view-user.tmpl 70
+msgid "total posts"
+msgstr "total posts"
+
+# view-user.tmpl 74,136
+msgid "last post"
+msgstr "last post"
+
+# signup.tmpl 87
+# login.tmpl 22
+# landing.tmpl 99
+# view-user 92
+msgid "password"
+msgstr "password"
+
+msgid "Change your password"
+msgstr "Change your password"
+
+# view-user 100
+msgid "Go to reset password page"
+msgstr "Go to reset password page"
+
+# view-user 141
+msgid "Fediverse followers"
+msgstr "Fediverse followers"
+
+# view-user 124
+msgid "Visibility"
+msgstr "Visibility"
+
+# view-user 112
+msgid "Alias"
+msgstr "Alias"
+
+# view-user.tmpl 75,137
+msgid "Never"
+msgstr "Never"
+
+# view-user 97
+msgid "Reset"
+msgstr "Reset"
+
+# view-user 153,156,174
+msgid "Delete this user"
+msgstr "Delete this user"
+
+# view-user 154
+msgid "Permanently erase all user data, with no way to recover it."
+msgstr "Permanently erase all user data, with no way to recover it."
+
+# view-user 165
+msgid "This action **cannot**be undone. It will permanently erase all traces of this user, **%s**, including their account information, blogs, and posts."
+msgstr "This action **cannot**be undone. It will permanently erase all traces of this user, **%s**, including their account information, blogs, and posts."
+
+# view-user 166
+msgid "Please type **%s** to confirm."
+msgstr "Please type **%s** to confirm."
+
+# view-user 202
+msgid "Silence this user? They'll still be able to log in and access their posts, but no one else will be able to see them anymore. You can reverse this decision at any time."
+msgstr "Silence this user? They'll still be able to log in and access their posts, but no one else will be able to see them anymore. You can reverse this decision at any time."
+
+# view-user 208
+msgid "Reset this user's password? This will generate a new temporary password that you'll need to share with them, and invalidate their old one."
+msgstr "Reset this user's password? This will generate a new temporary password that you'll need to share with them, and invalidate their old one."
+
+# settings.tmpl 225
+# user/collection.tmpl 207
+# view-user.tmpl 198
+msgid "Deleting..."
+msgstr "Deleting..."
+
+# view-user.tmpl 46
+msgid "This user's password has been reset to:"
+msgstr "This user's password has been reset to:"
+
+# view-user.tmpl 48
+msgid "They can use this new password to log in to their account. **This will only be shown once**, so be sure to copy it and send it to them now."
+msgstr "They can use this new password to log in to their account. **This will only be shown once**, so be sure to copy it and send it to them now."
+
+# view-user.tmpl 49
+msgid "Their email address is:"
+msgstr "Their email address is:"
+
+# app-updates.tmlp 19
+msgid "Automated update check failed."
+msgstr "Automated update check failed."
+
+# app-updates.tmlp 20, 24, 41
+msgid "Installed version: %s (%s)."
+msgstr "Installed version: %s (%s)."
+
+# app-updates.tmlp 21, 42
+msgid "Learn about latest releases on the %s or %s."
+msgstr "Learn about latest releases on the %s or %s."
+
+# app-updates.tmlp 23
+msgid "WriteFreely is **up to date**."
+msgstr "WriteFreely is **up to date**."
+
+# app-updates.tmlp 27
+msgid "Get"
+msgstr "Get"
+
+# app-updates.tmlp 27
+msgid "A new version of WriteFreely is available! **%s %s**"
+msgstr "A new version of WriteFreely is available! **%s %s**"
+
+# app-updates.tmlp 28
+msgid "release notes"
+msgstr "release notes"
+
+# app-updates.tmlp 29
+msgid "Read the %s for details on features, bug fixes, and notes on upgrading from your current version, **%s**."
+msgstr "Read the %s for details on features, bug fixes, and notes on upgrading from your current version, **%s**."
+
+# app-updates.tmlp 31
+msgid "Check now"
+msgstr "Check now"
+
+# app-updates.tmlp 31
+msgid "Last checked"
+msgstr "Last checked"
+
+# app-updates.tmlp 40
+msgid "Automated update checks are disabled."
+msgstr "Automated update checks are disabled."
+
+# ADMIN PAGES
+# view-page.tmpl 45
+msgid "Banner"
+msgstr "Banner"
+
+# view-page.tmpl 56
+msgid "Body"
+msgstr "Body"
+
+# view-page.tmpl 33
+msgid "Outline your %s."
+msgstr "Outline your %s."
+
+# view-page.tmpl 35,37
+msgid "Customize your %s page."
+msgstr "Customize your %s page."
+
+# view-page.tmpl 31
+msgid "Describe what your instance is %s."
+msgstr "Describe what your instance is %s."
+
+msgid "Accepts Markdown and HTML."
+msgstr "Accepts Markdown and HTML."
+
+# view-page.tmpl 56
+msgid "Content"
+msgstr "Content"
+
+# view-page.tmpl 63
+msgid "Save"
+msgstr "Save"
+
+# view-page.tmpl 71
+msgid "Saving..."
+msgstr "Saving..."
+
+# view-page.tmpl 48
+msgid "We suggest a header (e.g. `# Welcome`), optionally followed by a small bit of text. Accepts Markdown and HTML."
+msgstr "We suggest a header (e.g. `# Welcome`), optionally followed by a small bit of text. Accepts Markdown and HTML."
+
+# login.tmpl 11
+msgid "Log in to %s"
+msgstr "Log in to %s"
+
+# login.tmpl 32
+msgid "Logging in..."
+msgstr "Logging in..."
+
+# login.tmpl 27
+msgid "_No account yet?_ %s to start a blog."
+msgstr "_No account yet?_ %s to start a blog."
+
+msgid "Incorrect password."
+msgstr "Incorrect password."
+
+msgid "This user never set a password. Perhaps try logging in via OAuth?"
+msgstr "This user never set a password. Perhaps try logging in via OAuth?"
+
+msgid "This user never added a password or email address. Please contact us for help."
+msgstr "This user never added a password or email address. Please contact us for help."
+
+msgid "You're doing that too much."
+msgstr "You're doing that too much."
+
+msgid "Parameter `alias` required."
+msgstr "Parameter `alias` required."
+
+msgid "A username is required."
+msgstr "A username is required."
+
+msgid "Parameter `pass` required."
+msgstr "Parameter `pass` required."
+
+msgid "A password is required."
+msgstr "A password is required."
+
+msgid "Need a collection `alias` to read."
+msgstr "Need a collection `alias` to read."
+
+msgid "Please supply a password."
+msgstr "Please supply a password."
+
+msgid "Something went very wrong. The humans have been alerted."
+msgstr "Something went very wrong. The humans have been alerted."
+
+msgid "Logging out failed. Try clearing cookies for this site, instead."
+msgstr "Logging out failed. Try clearing cookies for this site, instead."
+
+# signup-oauth.tmpl 88
+# landing.tmpl 95,197
+msgid "your-username"
+msgstr "your-username"
+
+# signup.tmpl 91
+# landing.tmpl 103
+msgid "optional"
+msgstr "optional"
+
+# signup.tmpl 95
+# landing.tmpl 107
+msgid "Create blog"
+msgstr "Create blog"
+
+# signup-oauth.tmpl 59
+msgid "Finish creating account"
+msgstr "Finish creating account"
+
+# signup-oauth.tmpl 79
+msgid "Display Name"
+msgstr "Display Name"
+
+# oauth.tmpl 26
+msgid "or"
+msgstr "or"
+
+# oauth.tmpl 9,13,17,20
+msgid "Sign in with **%s**"
+msgstr "Sign in with **%s**"
+
+# landing.tmpl 77
+msgid "Learn more..."
+msgstr "Learn more..."
+
+# landing.tmpl 114
+msgid "Registration is currently closed."
+msgstr "Registration is currently closed."
+
+# landing.tmpl 115
+msgid "another instance"
+msgstr "another instance"
+
+# landing.tmpl 115
+msgid "You can always sign up on %s."
+msgstr "You can always sign up on %s."
+
+msgid "# Start your blog"
+msgstr "# Start your blog"
+
+msgid "# Start your blog in the fediverse"
+msgstr "# Start your blog in the fediverse"
+
+msgid ""
+"## Join the Fediverse\n\nThe fediverse is a large network of platforms that all speak a common language. Imagine if you could reply to _Instagram_ posts from _Twitter_, or interact with your favorite _Medium_ blogs from _Facebook_ -- federated alternatives like %s, %s, and WriteFreely enable you to do these types of things.\n\n"
+msgstr ""
+"## Join the Fediverse\n\nThe fediverse is a large network of platforms that all speak a common language. Imagine if you could reply to _Instagram_ posts from _Twitter_, or interact with your favorite _Medium_ blogs from _Facebook_ -- federated alternatives like %s, %s, and WriteFreely enable you to do these types of things.\n\n"
+
+msgid ""
+"## Write More Socially\n"
+"\n"
+"WriteFreely can communicate with other federated platforms like _Mastodon_, so people can follow your blogs, bookmark their favorite posts, and boost them to their followers. Sign up above to create a blog and join the fediverse."
+msgstr ""
+"## Write More Socially\n"
+"\n"
+"WriteFreely can communicate with other federated platforms like _Mastodon_, so people can follow your blogs, bookmark their favorite posts, and boost them to their followers. Sign up above to create a blog and join the fediverse."
+
+msgid "About %s"
+msgstr "About %s"
+
+msgid "_%s_ is a place for you to write and publish, powered by %s."
+msgstr "_%s_ is a place for you to write and publish, powered by %s."
+
+msgid "_%s_ is an interconnected place for you to write and publish, powered by %s."
+msgstr "_%s_ is an interconnected place for you to write and publish, powered by %s."
+
+msgid "article"
+msgid_plural "articles"
+msgstr[0] "article"
+msgstr[1] "articles"
+
+msgid "_%s_ is home to **%d** %s across **%d** %s."
+msgstr "_%s_ is home to **%d** %s across **%d** %s."
+
+msgid "About WriteFreely"
+msgstr "About WriteFreely"
+
+msgid "%s is a self-hosted, decentralized blogging platform for publishing beautiful, simple blogs."
+msgstr "%s is a self-hosted, decentralized blogging platform for publishing beautiful, simple blogs."
+
+msgid "It lets you publish a single blog, or host a community of writers who can create multiple blogs under one account. You can also enable federation, which allows people in the fediverse to follow your blog, bookmark your posts, and share them with others."
+msgstr "It lets you publish a single blog, or host a community of writers who can create multiple blogs under one account. You can also enable federation, which allows people in the fediverse to follow your blog, bookmark your posts, and share them with others."
+
+msgid "Start an instance"
+msgstr "Start an instance"
+
+msgid "Privacy Policy"
+msgstr "Privacy Policy"
+
+# privacy.tmpl 6
+msgid "Last updated"
+msgstr "Last updated"
+
+msgid "Read the latest posts form %s."
+msgstr "Read the latest posts form %s."
+
+# : pages.go:98
+msgid ""
+"%s, the software that powers this site, is built to enforce your right to privacy by default.\n"
+"\n"
+"It retains as little data about you as possible, not even requiring an email address to sign up. However, if you _do_ give us your email address, it is stored encrypted in our database.\n"
+"\n"
+"We salt and hash your account's password.We store log files, or data about what happens on our servers. We also use cookies to keep you logged in to your account.\n"
+"\n"
+"Beyond this, it's important that you trust whoever runs %s. Software can only do so much to protect you -- your level of privacy protections will ultimately fall on the humans that run this particular service."
+msgstr ""
+"%s, the software that powers this site, is built to enforce your right to privacy by default.\n"
+"\n"
+"It retains as little data about you as possible, not even requiring an email address to sign up. However, if you _do_ give us your email address, it is stored encrypted in our database.\n"
+"\n"
+"We salt and hash your account's password.We store log files, or data about what happens on our servers. We also use cookies to keep you logged in to your account.\n"
+"\n"
+"Beyond this, it's important that you trust whoever runs %s. Software can only do so much to protect you -- your level of privacy protections will ultimately fall on the humans that run this particular service."
+
+# static/js/postactions.js
+msgid "Unpublished post"
+msgstr "Unpublished post"
+
+msgid "Moved to %s"
+msgstr "Moved to %s"
+
+msgid "move to"
+msgstr "move to"
+
+msgid "moving to %s..."
+msgstr "moving to %s..."
+
+msgid "unable to move"
+msgstr "unable to move"
+
+msgid "View on %s"
+msgstr "View on %s"
+
+# classic.tmpl 64
+# pad.tmpl 59
+# bare.tmpl 30
+msgid "NOTE"
+msgstr "NOTE"
+
+# classic.tmpl 64
+# pad.tmpl 59
+# bare.tmpl 30
+msgid "for now, you'll need Javascript enabled to post."
+msgstr "for now, you'll need Javascript enabled to post."
+
+# classic.tmpl 158
+# pad.tmpl 174
+msgid "Your account is silenced, so you can't publish or update posts."
+msgstr "Your account is silenced, so you can't publish or update posts."
+
+# classic.tmpl 257
+# pad.tmpl 278
+msgid "Failed to post. Please try again."
+msgstr "Failed to post. Please try again."
+
+# classic.tmpl 163
+# pad.tmpl 179
+# bare.tmpl 99
+msgid "You don't have permission to update this post."
+msgstr "You don't have permission to update this post."
+
+# pad.tmpl 16
+msgid "Write..."
+msgstr "Write..."
+
+# classic.tmpl 34
+# pad.tmpl 29
+msgid "Publish to..."
+msgstr "Publish to..."
+
+# classic.tmpl 55
+# pad.tmpl 50
+msgid "Font"
+msgstr "Font"
+
+# read.tmpl 105
+msgid "from"
+msgstr "from"
+
+# read.tmpl 105
+msgid "Anonymous"
+msgstr "Anonymous"
+
+# read.tmpl 120
+msgid "Older"
+msgstr "Older"
+
+# read.tmpl 121
+msgid "Newer"
+msgstr "Newer"
+
+# password-collection.tmpl 31
+msgid "Menu"
+msgstr "Menu"
+
+# password-collection.tmpl 51
+msgid "This blog requires a password."
+msgstr "This blog requires a password."
+
+# 404-general.tmpl 1
+msgid "Page not found"
+msgstr "Page not found"
+
+# 404-general.tmpl 4
+msgid "This page is missing."
+msgstr "This page is missing."
+
+# 404-general.tmpl 5
+msgid "Are you sure it was ever here?"
+msgstr "Are you sure it was ever here?"
+
+#404.tmpl 1,4
+msgid "Post not found"
+msgstr "Post not found"
+
+#404.tmpl
+msgid "Why not share a thought of your own?"
+msgstr "Why not share a thought of your own?"
+
+# 404.tmpl
+msgid "Start a blog"
+msgstr "Start a blog"
+
+#404.tmpl
+msgid "%s and spread your ideas on **%s**, %s."
+msgstr "%s and spread your ideas on **%s**, %s."
+
+# 404.tmpl
+msgid "a simple blogging community"
+msgstr "a simple blogging community"
+
+# 404.tmpl
+msgid "a simple, federated blogging community"
+msgstr "a simple, federated blogging community"
+
+# 410.tmpl 1
+msgid "Unpublished"
+msgst "Unpublished"
+
+# 410.tmpl 4
+msgid "Post was unpublished by the author."
+msgstr "Post was unpublished by the author."
+
+# 410.tmpl 5
+msgid "It might be back some day."
+msgstr "It might be back some day."
+
+# errors.go
+msgid "Expected valid form data."
+msgstr "Expected valid form data."
+
+msgid "Expected valid JSON object."
+msgstr "Expected valid JSON object."
+
+msgid "Expected valid JSON array."
+msgstr "Expected valid JSON array."
+
+msgid "Invalid access token."
+msgstr "Invalid access token."
+
+msgid "Authorization token required."
+msgstr "Authorization token required."
+
+msgid "Not logged in."
+msgstr "Not logged in."
+
+msgid "You don't have permission to add to this collection."
+msgstr "You don't have permission to add to this collection."
+
+msgid "Invalid editing credentials."
+msgstr "Invalid editing credentials."
+
+msgid "You don't have permission to do that."
+msgstr "You don't have permission to do that."
+
+msgid "Bad requested Content-Type."
+msgstr "Bad requested Content-Type."
+
+msgid "You don't have permission to access this collection."
+msgstr "You don't have permission to access this collection."
+
+msgid "Supply something to publish."
+msgstr "Supply something to publish."
+
+msgid "The humans messed something up. They've been notified."
+msgstr "The humans messed something up. They've been notified."
+
+msgid "Could not get cookie session."
+msgstr "Could not get cookie session."
+
+msgid "Service temporarily unavailable due to high load."
+msgstr "Service temporarily unavailable due to high load."
+
+msgid "Collection doesn't exist."
+msgstr "Collection doesn't exist."
+
+msgid "This blog was unpublished."
+msgstr "This blog was unpublished."
+
+msgid "Collection page doesn't exist."
+msgstr "Collection page doesn't exist."
+
+msgid "Post not found."
+msgstr "Post not found."
+
+msgid "Post removed."
+msgstr "Post removed."
+
+msgid "Post unpublished by author."
+msgstr "Post unpublished by author."
+
+msgid "We encountered an error getting the post. The humans have been alerted."
+msgstr "We encountered an error getting the post. The humans have been alerted."
+
+msgid "User doesn't exist."
+msgstr "User doesn't exist."
+
+msgid "Remote user not found."
+msgstr "Remote user not found."
+
+msgid "Please enter your username instead of your email address."
+msgstr "Please enter your username instead of your email address."
+
+msgid "Account is silenced."
+msgstr "Account is silenced."
+
+msgid "Password authentication is disabled."
+msgstr "Password authentication is disabled."
+
+msgid "Supply some properties to update."
+msgstr "Supply some properties to update."
\ No newline at end of file
diff --git a/locales/en_UK/LC_MESSAGES/base.mo b/locales/en_UK/LC_MESSAGES/base.mo
new file mode 100644
index 0000000..45475d0
Binary files /dev/null and b/locales/en_UK/LC_MESSAGES/base.mo differ
diff --git a/locales/en_UK/LC_MESSAGES/base.po b/locales/en_UK/LC_MESSAGES/base.po
new file mode 100644
index 0000000..d304b92
--- /dev/null
+++ b/locales/en_UK/LC_MESSAGES/base.po
@@ -0,0 +1,1672 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: GOtext\n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: Aitzol Berasategi <aitzol@lainoa.eus>\n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: en\n"
+"X-Generator: Poedit 2.4.2\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+# base.tmpl 40
+# header.tmpl 49
+# pages.tmpl 21
+msgid "Home"
+msgstr "Home"
+
+# base.tmpl 42
+# header.tmpl 51
+# footer.tmpl 8,26
+# user/include/footer.tmpl 11
+msgid "About"
+msgstr "About"
+
+# base.tmpl 49
+# header.tmpl 58,65
+# include/footer.tmpl 9,27
+# user/include/footer.tmpl 12
+# app-settings.tmpl 108
+# pages.tmpl 24
+msgid "Reader"
+msgstr "Reader"
+
+# login.tmpl 21
+# password-collection.tmpl 33
+# base.tmpl 51
+# header.tmpl 60
+msgid "Log in"
+msgstr "Log in"
+
+# classic.tmpl 48
+# collection.tmpl 63,74
+# pad.tmpl 43
+# base.tmpl 33,51
+# header.tmpl 14,41,60
+msgid "Log out"
+msgstr "Log out"
+
+# base.tmpl 45
+# export.tmpl 19
+# collections.tmpl 13
+# user/collection.tmpl 105
+# admin.tmpl 59
+# nav.tmpl 2
+# header.tmpl 54,63
+# view-user 106
+msgid "Blog"
+msgid_plural "Blogs"
+msgstr[0] "Blog"
+msgstr[1] "Blogs"
+
+# bare.tmpl 33
+# export.tmpl 19
+# admin.tmpl 60
+# view-user 132
+msgid "Post"
+msgid_plural "Posts"
+msgstr[0] "Post"
+msgstr[1] "Posts"
+
+# bare.tmpl 26
+# header.tmpl 55
+msgid "My Posts"
+msgstr "My Posts"
+
+# classic.tmpl 38
+# edit-meta.tmpl 43
+# pad.tmpl 26,27,33
+# post.tmpl 47
+# base.tmpl 47
+# bare.tmpl 26
+# stats.tmpl 55
+# import.tmpl 41
+# articles.tmpl 25
+# header.tmpl 21,56,64
+# posts.tmpl 23,30,50,57
+msgid "Draft"
+msgid_plural "Drafts"
+msgstr[0] "Draft"
+msgstr[1] "Drafts"
+
+# login.tmpl 27
+# base.tmpl 50
+# header.tmpl 59
+msgid "Sign up"
+msgstr "Sign up"
+
+# classic.tmpl 5
+# collection.tmpl 53
+# pad.tmpl 5
+# base.tmpl 55
+# nav.tmpl 9
+# header.tmpl 25,71
+msgid "New Post"
+msgstr "New Post"
+
+# header.tmpl 29
+msgid "Return to editor"
+msgstr "Return to editor"
+
+# footer.tmpl 10,14,34
+# user/include/footer.tmpl 13
+msgid "writer's guide"
+msgstr "writer's guide"
+
+# footer.tmpl 15,35
+msgid "developers"
+msgstr "developers"
+
+# footer.tmpl 11,28
+# user/include/footer.tmpl 14
+msgid "privacy"
+msgstr "privacy"
+
+# footer.tmpl 16,36
+msgid "source code"
+msgstr "source code"
+
+# base.tmpl 28
+# header.tmpl 9,35
+msgid "Admin dashboard"
+msgstr "Admin dashboard"
+
+# base.tmpl 29
+# header.tmpl 10,36
+msgid "Account settings"
+msgstr "Account settings"
+
+# import.tmpl 17
+# header.tmpl 11,37
+msgid "Import posts"
+msgstr "Import posts"
+
+# base.tmpl 30
+# export.tmpl 5,10
+# header.tmpl 12,38
+msgid "Export"
+msgstr "Export"
+
+# header.tmpl 103
+msgid "Dashboard"
+msgstr "Dashboard"
+
+# header.tmpl 104
+msgid "Settings"
+msgstr "Settings"
+
+# export.tmpl 19
+# admin.tmpl 58
+# header.tmpl 106
+# view-user.tmpl 59
+# users.tmpl 15,21,30
+msgid "User"
+msgid_plural "Users"
+msgstr[0] "User"
+msgstr[1] "Users"
+
+# header.tmpl 107
+# pages.tmpl 13, 17
+msgid "Page"
+msgid_plural "Pages"
+msgstr[0] "Page"
+msgstr[1] "Pages"
+
+# post.tmpl 41
+# posts.tmpl 11,43,64,66
+# view-user 128
+msgid "View"
+msgid_plural "Views"
+msgstr[0] "View"
+msgstr[1] "Views"
+
+# posts.tmpl 11,43,64,66
+# view-user 128
+# edit-meta.tmpl 49,55
+# pad.tmpl 63
+msgid "View post"
+msgid_plural "View posts"
+msgstr[0] "View post"
+msgstr[1] "View posts"
+
+# classic.tmpl 41,45
+# collection.tmpl 57
+# header.tmpl 7
+# edit-meta.tmpl 41
+# pad.tmpl 24,36,40
+# nav.tmpl 12
+msgid "View Blog"
+msgid_plural "View Blogs"
+msgstr[0] "View Blog"
+msgstr[1] "View Blogs"
+
+# classic.tmpl 62
+# pad.tmpl 124
+msgid "word"
+msgid_plural "words"
+msgstr[0] "word"
+msgstr[1] "words"
+
+# pad.tmpl 64
+msgid "Publish"
+msgstr "Publish"
+
+# pad.tmpl 20
+# bare.tmpl 20
+msgid "This post has been updated elsewhere since you last published!"
+msgstr "This post has been updated elsewhere since you last published!"
+
+# pad.tmpl 20
+# bare.tmpl 20
+msgid "Delete draft and reload"
+msgstr "Delete draft and reload"
+
+msgid "Updates"
+msgstr "Updates"
+
+# classic.tmpl 42
+# pad.tmpl 37
+# user/collection.tmpl 32
+# collection.tmpl 54
+# nav.tmpl 10
+# header.tmpl 19
+msgid "Customize"
+msgstr "Customize"
+
+# classic.tmpl 43
+# collection.tmpl 55
+# pad.tmpl 38
+# stats.tmpl 26
+# nav.tmpl 11
+# header.tmpl 20
+msgid "Stats"
+msgstr "Stats"
+
+# classic.tmpl 47
+# collection.tmpl 58
+# pad.tmpl 42
+msgid "View Draft"
+msgid_plural "View Drafts"
+msgstr[0] "View Draft"
+msgstr[1] "View Drafts"
+
+# header.tmpl 111
+msgid "Monitor"
+msgstr "Monitor"
+
+# read.tmpl 108,110
+msgid "Read more..."
+msgstr "Read more..."
+
+# silenced.tmpl 3
+msgid "Your account has been silenced."
+msgstr "Your account has been silenced."
+
+# silenced.tmpl 3
+msgid "You can still access all of your posts and blogs, but no one else can currently see them."
+msgstr "You can still access all of your posts and blogs, but no one else can currently see them."
+
+# articles.tmpl 28
+msgid "These are your draft posts. You can share them individually (without a blog) or move them to your blog when you're ready."
+msgstr "These are your draft posts. You can share them individually (without a blog) or move them to your blog when you're ready."
+
+# articles.tmpl 57
+msgid "Your anonymous and draft posts will show up here once you've published some. You'll be able to share them individually (without a blog) or move them to a blog when you're ready."
+msgstr "Your anonymous and draft posts will show up here once you've published some. You'll be able to share them individually (without a blog) or move them to a blog when you're ready."
+
+# articles.tmpl 58
+msgid "Alternatively, see your blogs and their posts on your %s page."
+msgstr "Alternatively, see your blogs and their posts on your %s page."
+
+# articles.tmpl 60
+msgid "Start writing"
+msgstr "Start writing"
+
+# articles.tmpl 64
+# static/js/postactions.jsmsgid "unsynced posts"
+msgstr "unsynced posts"
+
+# articles.tmpl 64
+# collection.tmpl 43
+# view-page.tmpl 51
+# view-user 116
+msgid "Title"
+msgstr "Title"
+
+# user/collection.tmpl 44
+# view-user 120
+msgid "Description"
+msgstr "Description"
+
+# user/collection.tmpl 50
+msgid "This blog uses your username in its URL."
+msgstr "This blog uses your username in its URL."
+
+# user/collection.tmpl 50
+msgid "This blog uses your username in its URL and fediverse handle."
+msgstr "This blog uses your username in its URL and fediverse handle."
+
+# user/collection.tmpl 50
+msgid "You can change it in your %s."
+msgstr "You can change it in your %s."
+
+# user/collection.tmpl 63
+msgid "Publicity"
+msgstr "Publicity"
+
+# user/collection.tmpl 68
+# app-settings.tmpl 120
+msgid "Unlisted"
+msgstr "Unlisted"
+
+# user/collection.tmpl 87
+# app-settings.tmpl 121
+msgid "Public"
+msgstr "Public"
+
+# user/collection.tmpl 74
+# app-settings.tmpl 122
+msgid "Private"
+msgstr "Private"
+
+# user/collection.tmpl 70
+msgid "This blog is visible to any registered user on this instance."
+msgstr "This blog is visible to any registered user on this instance."
+
+# user/collection.tmpl 70
+msgid "This blog is visible to anyone with its link."
+msgstr "This blog is visible to anyone with its link."
+
+# user/collection.tmpl 76
+msgid "Only you may read this blog (while you're logged in)."
+msgstr "Only you may read this blog (while you're logged in)."
+
+# user/collection.tmpl 80
+msgid "Password-protected:"
+msgstr "Password-protected:"
+
+# user/collection.tmpl 80
+msgid "a memorable password"
+msgstr "a memorable password"
+
+# user/collection.tmpl 82
+msgid "A password is required to read this blog."
+msgstr "A password is required to read this blog."
+
+# user/collection.tmpl 89
+msgid "This blog is displayed on the public %s, and is visible to anyone with its link."
+msgstr "This blog is displayed on the public %s, and is visible to anyone with its link."
+
+# user/collection.tmpl 89
+msgid "This blog is displayed on the public %s, and is visible to any registered user on this instance."
+msgstr "This blog is displayed on the public %s, and is visible to any registered user on this instance."
+
+# user/collection.tmpl 90
+msgid "The public reader is currently turned off for this community."
+msgstr "The public reader is currently turned off for this community."
+
+# user/collection.tmpl 98
+msgid "Display Format"
+msgstr "Display Format"
+
+# user/collection.tmpl 100
+msgid "Customize how your posts display on your page."
+msgstr "Customize how your posts display on your page."
+
+# user/collection.tmpl 107
+msgid "Dates are shown. Latest posts listed first."
+msgstr "Dates are shown. Latest posts listed first."
+
+# user/collection.tmpl 113
+msgid "No dates shown. Oldest posts first."
+msgstr "No dates shown. Oldest posts first."
+
+# user/collection.tmpl 119
+msgid "No dates shown. Latest posts first."
+msgstr "No dates shown. Latest posts first."
+
+# user/collection.tmpl 126
+msgid "Text Rendering"
+msgstr "Text Rendering"
+
+# user/collection.tmpl 128
+msgid "Customize how plain text renders on your blog."
+msgstr "Customize how plain text renders on your blog."
+
+# user/collection.tmpl 145
+msgid "Custom CSS"
+msgstr "Custom CSS"
+
+# user/collection.tmpl 148
+msgid "customization"
+msgstr "customization"
+
+# user/collection.tmpl 148
+msgid "See our guide on %s."
+msgstr "See our guide on %s."
+
+# user/collection.tmpl 153
+msgid "Post Signature"
+msgstr "Post Signature"
+
+# user/collection.tmpl 155
+msgid "This content will be added to the end of every post on this blog, as if it were part of the post itself. Markdown, HTML, and shortcodes are allowed."
+msgstr "This content will be added to the end of every post on this blog, as if it were part of the post itself. Markdown, HTML, and shortcodes are allowed."
+
+# user/collection.tmpl 162
+msgid "Web Monetization"
+msgstr "Web Monetization"
+
+# user/collection.tmpl 164
+msgid "Web Monetization enables you to receive micropayments from readers that have a %s. Add your payment pointer to enable Web Monetization on your blog."
+msgstr "Web Monetization enables you to receive micropayments from readers that have a %s. Add your payment pointer to enable Web Monetization on your blog."
+
+# user/collection.tmpl 164
+msgid "Coil membership"
+msgstr "Coil membership"
+
+# edit-meta.tmpl 263
+# settings.tmpl 81
+# user/collection.tmpl 171
+msgid "Save changes"
+msgstr "Save changes"
+
+# user/collection.tmpl 173
+msgid "Delete Blog..."
+msgstr "Delete Blog..."
+
+# user/collection.tmpl 190,220
+# articles.tmpl 36
+# posts.tmpl 19,46
+msgid "Delete"
+msgstr "Delete"
+
+# settings.tmpl 187
+# user/collection.tmpl 189
+# view-user 173
+msgid "Cancel"
+msgstr "Cancel"
+
+# user/collection.tmpl 180
+msgid "Are you sure you want to delete this blog?"
+msgstr "Are you sure you want to delete this blog?"
+
+# posts.js 46,147
+# collection.tmpl 148
+# collection-tags.tmpl 96
+# chorus-collection.tmpl 132
+msgid "Are you sure you want to delete this post?"
+msgstr "Are you sure you want to delete this post?"
+
+# posts.js 302
+# collection.tmpl 175,226
+# collection-tags.tmpl 123,174
+# chorus-collection.tmpl 159,210
+msgid "Post is synced to another account. Delete the post from that account instead."
+msgstr "Post is synced to another account. Delete the post from that account instead."
+
+# posts.js 308
+# collection.tmpl 181
+# collection-tags.tmpl 129
+# chorus-collection.tmpl 165
+msgid "Failed to delete."
+msgstr "Failed to delete."
+
+# posts.js 308
+# collection.tmpl 181
+# collection-tags.tmpl 129
+# chorus-collection.tmpl 165
+msgid "Please try again."
+msgstr "Please try again."
+
+# user/collection.tmpl 182
+msgid "This will permanently erase **%s** (%s/%s) from the internet. Any posts on this blog will be saved and made into drafts (found on your %s page)."
+msgstr "This will permanently erase **%s** (%s/%s) from the internet. Any posts on this blog will be saved and made into drafts (found on your %s page)."
+
+# user/collection.tmpl 183
+msgid "If you're sure you want to delete this blog, enter its name in the box below and press **%s**."
+msgstr "If you're sure you want to delete this blog, enter its name in the box below and press **%s**."
+
+# user/collection.tmpl 202
+msgid "Enter **%s** in the box below."
+msgstr "Enter **%s** in the box below."
+
+# user/collection.tmpl 238
+msgid "Saving changes..."
+msgstr "Saving changes..."
+
+# collections.tmpl 72,75,84
+msgid "This name is taken."
+msgstr "This name is taken."
+
+# pad.tmpl 61
+msgid "Edit post metadata"
+msgstr "Edit post metadata"
+
+# edit-meta.tmpl 5,55
+msgid "Edit metadata"
+msgstr "Edit metadata"
+
+# edit-meta.tmpl 260
+msgid "now"
+msgstr "now"
+
+# edit-meta.tmpl 63
+msgid "Slug"
+msgstr "Slug"
+
+# edit-meta.tmpl 66
+msgid "Language"
+msgstr "Language"
+
+# edit-meta.tmpl 256
+msgid "Direction"
+msgstr "Direction"
+
+# edit-meta.tmpl 258
+msgid "Created"
+msgstr "Created"
+
+# edit-meta.tmpl 257
+msgid "right-to-left"
+msgstr "right-to-left"
+
+# edit-meta.tmpl 47
+msgid "Edit post"
+msgstr "Edit post"
+
+# edit-meta.tmpl 48
+# pad.tmpl 62
+msgid "Toggle theme"
+msgstr "Toggle theme"
+
+# collection-post.tmpl 66
+# posts.tmpl 8
+msgid "Scheduled"
+msgstr "Scheduled"
+
+# collection-post.tmpl 57
+# post.tmpl 45,91
+# articles.tmpl 35
+# posts.tmpl 17,44
+msgid "Edit"
+msgstr "Edit"
+
+# posts.tmpl 18,45
+msgid "Pin"
+msgstr "Pin"
+
+# collection-post.tmpl 58
+msgid "Unpin"
+msgstr "Unpin"
+
+# posts.tmpl 21,48
+msgid "Move this post to another blog"
+msgstr "Move this post to another blog"
+
+# posts.tmpl 30,57
+msgid "Change to a draft"
+msgstr "Change to a draft"
+
+# posts.tmpl 30,57
+msgid "change to _%s_"
+msgstr "change to _%s_"
+
+# articles.tmpl 43,78
+# posts.tmpl 26,53
+msgid "move to..."
+msgstr "move to..."
+
+# articles.tmpl 47,83
+msgid "move to %s"
+msgstr "move to %s"
+
+# post.tmpl 42
+msgid "View raw"
+msgstr "View raw"
+
+# articles.tmpl 47,83
+msgid "Publish this post to your blog %s"
+msgstr "Publish this post to your blog %s"
+
+# articles.tmpl 39,74
+msgid "Move this post to one of your blogs"
+msgstr "Move this post to one of your blogs"
+
+# articles.tmpl 55
+msgid "Load more..."
+msgstr "Load more..."
+
+# stats.tmpl 32
+msgid "Stats for all time."
+msgstr "Stats for all time."
+
+# stats.tmpl 35
+msgid "Fediverse stats"
+msgstr "Fediverse stats"
+
+# stats.tmpl 38
+msgid "Followers"
+msgstr "Followers"
+
+# stats.tmpl 46
+msgid "Top %d post"
+msgid_plural "Top %d posts"
+msgstr[0] "Top %d post"
+msgstr[1] "Top %d posts"
+
+# stats.tmpl 51
+msgid "Total Views"
+msgstr "Total Views"
+
+# settings.tmpl 27
+msgid "Before you go..."
+msgstr "Before you go..."
+
+# settings.tmpl 27
+msgid "Account Settings"
+msgstr "Account Settings"
+
+# settings.tmpl 38
+msgid "Change your account settings here."
+msgstr "Change your account settings here."
+
+# signup.tmpl 80
+# signup-oauth.tmpl 85,87
+# login.tmpl 21
+# landing.tmpl 92
+# settings.tmpl 43
+# view-user.tmpl 62
+msgid "Username"
+msgstr "Username"
+
+# settings.tmpl 46
+msgid "Update"
+msgstr "Update"
+
+# settings.tmpl 56
+msgid "Passphrase"
+msgstr "Passphrase"
+
+# settings.tmpl 58
+msgid "Add a passphrase to easily log in to your account."
+msgstr "Add a passphrase to easily log in to your account."
+
+# settings.tmpl 59,60
+msgid "Current passphrase"
+msgstr "Current passphrase"
+
+# settings.tmpl 61,64
+msgid "New passphrase"
+msgstr "New passphrase"
+
+# settings.tmpl 60,64
+msgid "Show"
+msgstr "Show"
+
+msgid "Account updated."
+msgstr "Account updated."
+
+# signup.tmpl 91
+# signup-oauth.tmpl 92,94
+# landing.tmpl 103
+# settings.tmpl 69
+msgid "Email"
+msgstr "Email"
+
+# settings.tmpl 76
+msgid "Email address"
+msgstr "Email address"
+
+# settings.tmpl 71
+msgid "Add your email to get:"
+msgstr "Add your email to get:"
+
+# settings.tmpl 34
+msgid "Please add an **%s** and/or **%s** so you can log in again later."
+msgstr "Please add an **%s** and/or **%s** so you can log in again later."
+
+# settings.tmpl 73
+msgid "No-passphrase login"
+msgstr "No-passphrase login"
+
+# settings.tmpl 74
+msgid "Account recovery if you forget your passphrase"
+msgstr "Account recovery if you forget your passphrase"
+
+# settings.tmpl 89
+msgid "Linked Accounts"
+msgstr "Linked Accounts"
+
+# settings.tmpl 90
+msgid "These are your linked external accounts."
+msgstr "These are your linked external accounts."
+
+# settings.tmpl 114
+msgid "Link External Accounts"
+msgstr "Link External Accounts"
+
+msgid "Connect additional accounts to enable logging in with those providers, instead of using your username and password."
+msgstr "Connect additional accounts to enable logging in with those providers, instead of using your username and password."
+
+# settings.tmpl 162
+# view-user 149
+msgid "Incinerator"
+msgstr "Incinerator"
+
+# settings.tmpl 166,169,188
+msgid "Delete your account"
+msgstr "Delete your account"
+
+# settings.tmpl 167
+msgid "Permanently erase all your data, with no way to recover it."
+msgstr "Permanently erase all your data, with no way to recover it."
+
+# settings.tmpl 176
+# view-user 163
+msgid "Are you sure?"
+msgstr "Are you sure?"
+
+# settings.tmpl 178
+msgid "export your data"
+msgstr "export your data"
+
+# settings.tmpl 178
+msgid "This action **cannot** be undone. It will immediately and permanently erase your account, including your blogs and posts. Before continuing, you might want to %s."
+msgstr "This action **cannot** be undone. It will immediately and permanently erase your account, including your blogs and posts. Before continuing, you might want to %s."
+
+# settings.tmpl 179
+msgid "If you're sure, please type **%s** to confirm."
+msgstr "If you're sure, please type **%s** to confirm."
+
+# invite-help.tmpl 13
+msgid "Invite to %s"
+msgstr "Invite to %s"
+
+# invite-help.tmpl 15
+msgid "This invite link is expired."
+msgstr "This invite link is expired."
+
+# invite-help.tmpl 21
+msgid "Only **one** user"
+msgstr "Only **one** user"
+
+# invite-help.tmpl 21
+msgid "Up to **%d** users"
+msgstr "Up to **%d** users"
+
+# invite-help.tmpl 21
+msgid "can sign up with this link."
+msgstr "can sign up with this link."
+
+# invite-help.tmpl 23
+msgid "It expires on **%s**."
+msgstr "It expires on **%s**."
+
+# invite-help.tmpl 25
+msgid "It can be used as many times as you like"
+msgstr "It can be used as many times as you like"
+
+# invite-help 25
+msgid "before **%s**, when it expires"
+msgstr "before **%s**, when it expires"
+
+msgid "person has"
+msgid_plural "person have"
+msgstr[0] "person has"
+msgstr[1] "person have"
+
+# invite-help.tmpl 21
+msgid "So far, **%d** %s used it."
+msgstr "So far, **%d** %s used it."
+
+# invite-help.tmpl 17
+msgid "Copy the link below and send it to anyone that you want to join *%s*. You could paste it into an email, instant message, text message, or write it down on paper. Anyone who navigates to this special page will be able to create an account."
+msgstr "Copy the link below and send it to anyone that you want to join *%s*. You could paste it into an email, instant message, text message, or write it down on paper. Anyone who navigates to this special page will be able to create an account."
+
+# IMPORT PAGE
+# import.tmpl 28
+msgid "Publish plain text or Markdown files to your account by uploading them below."
+msgstr "Publish plain text or Markdown files to your account by uploading them below."
+
+# import.tmpl 31
+msgid "Select some files to import:"
+msgstr "Select some files to import:"
+
+# import.tmpl 36
+msgid "Import these posts to:"
+msgstr "Import these posts to:"
+
+# import.tmpl 59
+msgid "Import"
+msgstr "Import"
+
+msgid "Import complete, %d post imported."
+msgid_plural "Import complete, %d posts imported."
+msgstr[0] "Import complete, %d post imported."
+msgstr[1] "Import complete, %d posts imported."
+
+msgid "%d of %d posts imported, see details below."
+msgid_plural "%d of %d posts imported, see details below."
+msgstr[0] "%d of %d posts imported, see details below."
+msgstr[1] "%d of %d posts imported, see details below."
+
+msgid "%s is not a supported post file"
+msgstr "%s is not a supported post file"
+
+# export.tmpl 6
+msgid "Your data on %s is always free. Download and back-up your work any time."
+msgstr "Your data on %s is always free. Download and back-up your work any time."
+
+# export.tmpl 11
+msgid "Format"
+msgstr "Format"
+
+# header.tmpl 101
+msgid "Admin"
+msgstr "Admin"
+
+# app-settings.tmpl 37
+msgid "Site Title"
+msgstr "Site Title"
+
+# app-settings.tmpl 38
+msgid "Your public site name."
+msgstr "Your public site name."
+
+# app-settings.tmpl 44
+msgid "Site Description"
+msgstr "Site Description"
+
+# app-settings.tmpl 45
+msgid "Describe your site — this shows in your site's metadata."
+msgstr "Describe your site — this shows in your site's metadata."
+
+# app-settings.tmpl 51
+msgid "Host"
+msgstr "Host"
+
+# app-settings.tmpl 52
+msgid "The public address where users will access your site, starting with `http://` or `https://`."
+msgstr "The public address where users will access your site, starting with `http://` or `https://`."
+
+# app-settings.tmpl 58
+msgid "Community Mode"
+msgstr "Community Mode"
+
+# app-settings.tmpl 59
+msgid "Whether your site is made for one person or many."
+msgstr "Whether your site is made for one person or many."
+
+# app-settings.tmpl 61
+msgid "Single user"
+msgstr "Single user"
+
+# app-settings.tmpl 61
+msgid "Multiple users"
+msgstr "Multiple users"
+
+# app-settings.tmpl 65
+msgid "Landing Page"
+msgstr "Landing Page"
+
+# app-settings.tmpl 66
+msgid "The page that logged-out visitors will see first. This should be an absolute path like: `/read`."
+msgstr "The page that logged-out visitors will see first. This should be an absolute path like: `/read`."
+
+# app-settings.tmpl 72
+msgid "Open Registrations"
+msgstr "Open Registrations"
+
+# app-settings.tmpl 73
+msgid "Allow anyone who visits the site to create an account."
+msgstr "Allow anyone who visits the site to create an account."
+
+# app-settings.tmpl 80
+msgid "Allow account deletion"
+msgstr "Allow account deletion"
+
+# app-settings.tmpl 81
+msgid "Allow all users to delete their account. Admins can always delete users."
+msgstr "Allow all users to delete their account. Admins can always delete users."
+
+# app-settings.tmpl 88
+msgid "Allow invitations from..."
+msgstr "Allow invitations from..."
+
+# app-settings.tmpl 89
+msgid "Choose who is allowed to invite new people."
+msgstr "Choose who is allowed to invite new people."
+
+# app-settings.tmpl 93
+msgid "No one"
+msgstr "No one"
+
+# app-settings.tmpl 94
+msgid "Only Admins"
+msgstr "Only Admins"
+
+# app-settings.tmpl 95
+msgid "All Users"
+msgstr "All Users"
+
+# app-settings.tmpl 101
+msgid "Private Instance"
+msgstr "Private Instance"
+
+# app-settings.tmpl 102
+msgid "Limit site access to people with an account."
+msgstr "Limit site access to people with an account."
+
+# app-settings.tmpl 109
+msgid "Show a feed of user posts for anyone who chooses to share there."
+msgstr "Show a feed of user posts for anyone who chooses to share there."
+
+# app-settings.tmpl 115
+msgid "Default blog visibility"
+msgstr "Default blog visibility"
+
+# app-settings.tmpl 116
+msgid "The default setting for new accounts and blogs."
+msgstr "The default setting for new accounts and blogs."
+
+# app-settings.tmpl 128
+msgid "Maximum Blogs per User"
+msgstr "Maximum Blogs per User"
+
+# app-settings.tmpl 129
+msgid "Keep things simple by setting this to **1**, unlimited by setting to **0**, or pick another amount."
+msgstr "Keep things simple by setting this to **1**, unlimited by setting to **0**, or pick another amount."
+
+# app-settings.tmpl 135
+msgid "Federation"
+msgstr "Federation"
+
+# app-settings.tmpl 136
+msgid "Enable accounts on this site to propagate their posts via the ActivityPub protocol."
+msgstr "Enable accounts on this site to propagate their posts via the ActivityPub protocol."
+
+# app-settings.tmpl 142
+msgid "Public Stats"
+msgstr "Public Stats"
+
+# app-settings.tmpl 143
+msgid "Publicly display the number of users and posts on your **%s** page."
+msgstr "Publicly display the number of users and posts on your **%s** page."
+
+# app-settings.tmpl 149
+msgid "Monetization"
+msgstr "Monetization"
+
+# app-settings.tmpl 150
+msgid "Enable blogs on this site to receive micropayments from readers via %s."
+msgstr "Enable blogs on this site to receive micropayments from readers via %s."
+
+# app-settings.tmpl 156
+msgid "Minimum Username Length"
+msgstr "Minimum Username Length"
+
+# app-settings.tmpl 157
+msgid "The minimum number of characters allowed in a username. (Recommended: 2 or more.)"
+msgstr "The minimum number of characters allowed in a username. (Recommended: 2 or more.)"
+
+# app-settings.tmpl 162
+msgid "Save Settings"
+msgstr "Save Settings"
+
+# app-settings.tmpl 166
+msgid "configuration docs"
+msgstr "configuration docs"
+
+# app-settings.tmpl 166
+msgid "Still have questions? Read more details in the %s."
+msgstr "Still have questions? Read more details in the %s."
+
+msgid "Configuration saved."
+msgstr "Configuration saved."
+
+# view-user.tmpl 66
+# users.tmpl 22
+msgid "joined"
+msgstr "joined"
+
+# users.tmpl 23
+msgid "type"
+msgstr "type"
+
+# users.tmpl 24
+# view-user.tmpl 79
+msgid "status"
+msgstr "status"
+
+# base.tmpl 31
+# header.tmpl 39
+# invite.tmpl 26
+# users.tmpl 16
+msgid "Invite people"
+msgstr "Invite people"
+
+# invite.tmpl 27
+msgid "Invite others to join *%s* by generating and sharing invite links below."
+msgstr "Invite others to join *%s* by generating and sharing invite links below."
+
+# invite.tmpl 31
+msgid "Maximum number of uses:"
+msgstr "Maximum number of uses:"
+
+# invite.tmpl 33
+msgid "No limit"
+msgstr "No limit"
+
+# invite.tmpl 34,35,36,37,38,39,64
+msgid "use"
+msgid_plural "uses"
+msgstr[0] "use"
+msgstr[1] "uses"
+
+# invite.tmpl 43
+msgid "Expire after:"
+msgstr "Expire after:"
+
+# invite.tmpl 46
+msgid "minute"
+msgid_plural "minutes"
+msgstr[0] "minute"
+msgstr[1] "minutes"
+
+# invite.tmpl 47,48,49
+msgid "hour"
+msgid_plural "hours"
+msgstr[0] "hour"
+msgstr[1] "hours"
+
+# invite.tmpl 50,51
+msgid "day"
+msgid_plural "days"
+msgstr[0] "day"
+msgstr[1] "days"
+
+# invite.tmpl 52
+msgid "week"
+msgid_plural "weeks"
+msgstr[0] "week"
+msgstr[1] "weeks"
+
+# invite.tmpl 57
+msgid "You cannot generate invites while your account is silenced."
+msgstr "You cannot generate invites while your account is silenced."
+
+# invite.tmpl 57
+msgid "Generate"
+msgstr "Generate"
+
+# invite.tmpl 63
+msgid "Link"
+msgstr "Link"
+
+# invite.tmpl 121,129,137,145,152
+msgid "ToLink"
+msgstr "Link"
+
+# invite.tmpl 65
+msgid "Expires"
+msgstr "Expires"
+
+# invite.tmpl 71
+msgid "Expired"
+msgstr "Expired"
+
+# invite.tmpl 75
+msgid "No invites generated yet."
+msgstr "No invites generated yet."
+
+# pages.tmpl 18
+msgid "last modified"
+msgstr "last modified"
+
+# view-user.tmpl 85
+# users.tmpl 31
+msgid "Active"
+msgstr "Active"
+
+# view-user.tmpl 82
+# users.tmpl 31
+msgid "Silenced"
+msgstr "Silenced"
+
+# view-user.tmpl 83
+msgid "Unsilence"
+msgstr "Unsilence"
+
+# view-user 86
+msgid "disabled"
+msgstr "disabled"
+
+# view-user 86
+msgid "Silence"
+msgstr "Silence"
+
+# view-user.tmpl 54
+msgid "No."
+msgstr "No."
+
+# view-user.tmpl 70
+msgid "total posts"
+msgstr "total posts"
+
+# view-user.tmpl 74,136
+msgid "last post"
+msgstr "last post"
+
+# signup.tmpl 87
+# login.tmpl 22
+# landing.tmpl 99
+# view-user 92
+msgid "password"
+msgstr "password"
+
+msgid "Change your password"
+msgstr "Change your password"
+
+# view-user 100
+msgid "Go to reset password page"
+msgstr "Go to reset password page"
+
+# view-user 141
+msgid "Fediverse followers"
+msgstr "Fediverse followers"
+
+# view-user 124
+msgid "Visibility"
+msgstr "Visibility"
+
+# view-user 112
+msgid "Alias"
+msgstr "Alias"
+
+# view-user.tmpl 75,137
+msgid "Never"
+msgstr "Never"
+
+# view-user 97
+msgid "Reset"
+msgstr "Reset"
+
+# view-user 153,156,174
+msgid "Delete this user"
+msgstr "Delete this user"
+
+# view-user 154
+msgid "Permanently erase all user data, with no way to recover it."
+msgstr "Permanently erase all user data, with no way to recover it."
+
+# view-user 165
+msgid "This action **cannot**be undone. It will permanently erase all traces of this user, **%s**, including their account information, blogs, and posts."
+msgstr "This action **cannot**be undone. It will permanently erase all traces of this user, **%s**, including their account information, blogs, and posts."
+
+# view-user 166
+msgid "Please type **%s** to confirm."
+msgstr "Please type **%s** to confirm."
+
+# view-user 202
+msgid "Silence this user? They'll still be able to log in and access their posts, but no one else will be able to see them anymore. You can reverse this decision at any time."
+msgstr "Silence this user? They'll still be able to log in and access their posts, but no one else will be able to see them anymore. You can reverse this decision at any time."
+
+# view-user 208
+msgid "Reset this user's password? This will generate a new temporary password that you'll need to share with them, and invalidate their old one."
+msgstr "Reset this user's password? This will generate a new temporary password that you'll need to share with them, and invalidate their old one."
+
+# settings.tmpl 225
+# user/collection.tmpl 207
+# view-user.tmpl 198
+msgid "Deleting..."
+msgstr "Deleting..."
+
+# view-user.tmpl 46
+msgid "This user's password has been reset to:"
+msgstr "This user's password has been reset to:"
+
+# view-user.tmpl 48
+msgid "They can use this new password to log in to their account. **This will only be shown once**, so be sure to copy it and send it to them now."
+msgstr "They can use this new password to log in to their account. **This will only be shown once**, so be sure to copy it and send it to them now."
+
+# view-user.tmpl 49
+msgid "Their email address is:"
+msgstr "Their email address is:"
+
+# app-updates.tmlp 19
+msgid "Automated update check failed."
+msgstr "Automated update check failed."
+
+# app-updates.tmlp 20, 24, 41
+msgid "Installed version: %s (%s)."
+msgstr "Installed version: %s (%s)."
+
+# app-updates.tmlp 21, 42
+msgid "Learn about latest releases on the %s or %s."
+msgstr "Learn about latest releases on the %s or %s."
+
+# app-updates.tmlp 23
+msgid "WriteFreely is **up to date**."
+msgstr "WriteFreely is **up to date**."
+
+# app-updates.tmlp 27
+msgid "Get"
+msgstr "Get"
+
+# app-updates.tmlp 27
+msgid "A new version of WriteFreely is available! **%s %s**"
+msgstr "A new version of WriteFreely is available! **%s %s**"
+
+# app-updates.tmlp 28
+msgid "release notes"
+msgstr "release notes"
+
+# app-updates.tmlp 29
+msgid "Read the %s for details on features, bug fixes, and notes on upgrading from your current version, **%s**."
+msgstr "Read the %s for details on features, bug fixes, and notes on upgrading from your current version, **%s**."
+
+# app-updates.tmlp 31
+msgid "Check now"
+msgstr "Check now"
+
+# app-updates.tmlp 31
+msgid "Last checked"
+msgstr "Last checked"
+
+# app-updates.tmlp 40
+msgid "Automated update checks are disabled."
+msgstr "Automated update checks are disabled."
+
+# ADMIN PAGES
+# view-page.tmpl 45
+msgid "Banner"
+msgstr "Banner"
+
+# view-page.tmpl 56
+msgid "Body"
+msgstr "Body"
+
+# view-page.tmpl 33
+msgid "Outline your %s."
+msgstr "Outline your %s."
+
+# view-page.tmpl 35,37
+msgid "Customize your %s page."
+msgstr "Customize your %s page."
+
+# view-page.tmpl 31
+msgid "Describe what your instance is %s."
+msgstr "Describe what your instance is %s."
+
+msgid "Accepts Markdown and HTML."
+msgstr "Accepts Markdown and HTML."
+
+# view-page.tmpl 56
+msgid "Content"
+msgstr "Content"
+
+# view-page.tmpl 63
+msgid "Save"
+msgstr "Save"
+
+# view-page.tmpl 71
+msgid "Saving..."
+msgstr "Saving..."
+
+# view-page.tmpl 48
+msgid "We suggest a header (e.g. `# Welcome`), optionally followed by a small bit of text. Accepts Markdown and HTML."
+msgstr "We suggest a header (e.g. `# Welcome`), optionally followed by a small bit of text. Accepts Markdown and HTML."
+
+# login.tmpl 11
+msgid "Log in to %s"
+msgstr "Log in to %s"
+
+# login.tmpl 32
+msgid "Logging in..."
+msgstr "Logging in..."
+
+# login.tmpl 27
+msgid "_No account yet?_ %s to start a blog."
+msgstr "_No account yet?_ %s to start a blog."
+
+msgid "Incorrect password."
+msgstr "Incorrect password."
+
+msgid "This user never set a password. Perhaps try logging in via OAuth?"
+msgstr "This user never set a password. Perhaps try logging in via OAuth?"
+
+msgid "This user never added a password or email address. Please contact us for help."
+msgstr "This user never added a password or email address. Please contact us for help."
+
+msgid "You're doing that too much."
+msgstr "You're doing that too much."
+
+msgid "Parameter `alias` required."
+msgstr "Parameter `alias` required."
+
+msgid "A username is required."
+msgstr "A username is required."
+
+msgid "Parameter `pass` required."
+msgstr "Parameter `pass` required."
+
+msgid "A password is required."
+msgstr "A password is required."
+
+msgid "Need a collection `alias` to read."
+msgstr "Need a collection `alias` to read."
+
+msgid "Please supply a password."
+msgstr "Please supply a password."
+
+msgid "Something went very wrong. The humans have been alerted."
+msgstr "Something went very wrong. The humans have been alerted."
+
+msgid "Logging out failed. Try clearing cookies for this site, instead."
+msgstr "Logging out failed. Try clearing cookies for this site, instead."
+
+# signup-oauth.tmpl 88
+# landing.tmpl 95,197
+msgid "your-username"
+msgstr "your-username"
+
+# signup.tmpl 91
+# landing.tmpl 103
+msgid "optional"
+msgstr "optional"
+
+# signup.tmpl 95
+# landing.tmpl 107
+msgid "Create blog"
+msgstr "Create blog"
+
+# signup-oauth.tmpl 59
+msgid "Finish creating account"
+msgstr "Finish creating account"
+
+# signup-oauth.tmpl 79
+msgid "Display Name"
+msgstr "Display Name"
+
+# oauth.tmpl 26
+msgid "or"
+msgstr "or"
+
+# oauth.tmpl 9,13,17,20
+msgid "Sign in with **%s**"
+msgstr "Sign in with **%s**"
+
+# landing.tmpl 77
+msgid "Learn more..."
+msgstr "Learn more..."
+
+# landing.tmpl 114
+msgid "Registration is currently closed."
+msgstr "Registration is currently closed."
+
+# landing.tmpl 115
+msgid "another instance"
+msgstr "another instance"
+
+# landing.tmpl 115
+msgid "You can always sign up on %s."
+msgstr "You can always sign up on %s."
+
+msgid "# Start your blog"
+msgstr "# Start your blog"
+
+msgid "# Start your blog in the fediverse"
+msgstr "# Start your blog in the fediverse"
+
+msgid ""
+"## Join the Fediverse\n\nThe fediverse is a large network of platforms that all speak a common language. Imagine if you could reply to _Instagram_ posts from _Twitter_, or interact with your favorite _Medium_ blogs from _Facebook_ -- federated alternatives like %s, %s, and WriteFreely enable you to do these types of things.\n\n"
+msgstr ""
+"## Join the Fediverse\n\nThe fediverse is a large network of platforms that all speak a common language. Imagine if you could reply to _Instagram_ posts from _Twitter_, or interact with your favorite _Medium_ blogs from _Facebook_ -- federated alternatives like %s, %s, and WriteFreely enable you to do these types of things.\n\n"
+
+msgid ""
+"## Write More Socially\n"
+"\n"
+"WriteFreely can communicate with other federated platforms like _Mastodon_, so people can follow your blogs, bookmark their favorite posts, and boost them to their followers. Sign up above to create a blog and join the fediverse."
+msgstr ""
+"## Write More Socially\n"
+"\n"
+"WriteFreely can communicate with other federated platforms like _Mastodon_, so people can follow your blogs, bookmark their favorite posts, and boost them to their followers. Sign up above to create a blog and join the fediverse."
+
+msgid "About %s"
+msgstr "About %s"
+
+msgid "_%s_ is a place for you to write and publish, powered by %s."
+msgstr "_%s_ is a place for you to write and publish, powered by %s."
+
+msgid "_%s_ is an interconnected place for you to write and publish, powered by %s."
+msgstr "_%s_ is an interconnected place for you to write and publish, powered by %s."
+
+msgid "article"
+msgid_plural "articles"
+msgstr[0] "article"
+msgstr[1] "articles"
+
+msgid "_%s_ is home to **%d** %s across **%d** %s."
+msgstr "_%s_ is home to **%d** %s across **%d** %s."
+
+msgid "About WriteFreely"
+msgstr "About WriteFreely"
+
+msgid "%s is a self-hosted, decentralized blogging platform for publishing beautiful, simple blogs."
+msgstr "%s is a self-hosted, decentralized blogging platform for publishing beautiful, simple blogs."
+
+msgid "It lets you publish a single blog, or host a community of writers who can create multiple blogs under one account. You can also enable federation, which allows people in the fediverse to follow your blog, bookmark your posts, and share them with others."
+msgstr "It lets you publish a single blog, or host a community of writers who can create multiple blogs under one account. You can also enable federation, which allows people in the fediverse to follow your blog, bookmark your posts, and share them with others."
+
+msgid "Start an instance"
+msgstr "Start an instance"
+
+msgid "Privacy Policy"
+msgstr "Privacy Policy"
+
+# privacy.tmpl 6
+msgid "Last updated"
+msgstr "Last updated"
+
+msgid "Read the latest posts form %s."
+msgstr "Read the latest posts form %s."
+
+# : pages.go:98
+msgid ""
+"%s, the software that powers this site, is built to enforce your right to privacy by default.\n"
+"\n"
+"It retains as little data about you as possible, not even requiring an email address to sign up. However, if you _do_ give us your email address, it is stored encrypted in our database.\n"
+"\n"
+"We salt and hash your account's password.We store log files, or data about what happens on our servers. We also use cookies to keep you logged in to your account.\n"
+"\n"
+"Beyond this, it's important that you trust whoever runs %s. Software can only do so much to protect you -- your level of privacy protections will ultimately fall on the humans that run this particular service."
+msgstr ""
+"%s, the software that powers this site, is built to enforce your right to privacy by default.\n"
+"\n"
+"It retains as little data about you as possible, not even requiring an email address to sign up. However, if you _do_ give us your email address, it is stored encrypted in our database.\n"
+"\n"
+"We salt and hash your account's password.We store log files, or data about what happens on our servers. We also use cookies to keep you logged in to your account.\n"
+"\n"
+"Beyond this, it's important that you trust whoever runs %s. Software can only do so much to protect you -- your level of privacy protections will ultimately fall on the humans that run this particular service."
+
+# static/js/postactions.js
+msgid "Unpublished post"
+msgstr "Unpublished post"
+
+msgid "Moved to %s"
+msgstr "Moved to %s"
+
+msgid "move to"
+msgstr "move to"
+
+msgid "moving to %s..."
+msgstr "moving to %s..."
+
+msgid "unable to move"
+msgstr "unable to move"
+
+msgid "View on %s"
+msgstr "View on %s"
+
+# classic.tmpl 64
+# pad.tmpl 59
+# bare.tmpl 30
+msgid "NOTE"
+msgstr "NOTE"
+
+# classic.tmpl 64
+# pad.tmpl 59
+# bare.tmpl 30
+msgid "for now, you'll need Javascript enabled to post."
+msgstr "for now, you'll need Javascript enabled to post."
+
+# classic.tmpl 158
+# pad.tmpl 174
+msgid "Your account is silenced, so you can't publish or update posts."
+msgstr "Your account is silenced, so you can't publish or update posts."
+
+# classic.tmpl 257
+# pad.tmpl 278
+msgid "Failed to post. Please try again."
+msgstr "Failed to post. Please try again."
+
+# classic.tmpl 163
+# pad.tmpl 179
+# bare.tmpl 99
+msgid "You don't have permission to update this post."
+msgstr "You don't have permission to update this post."
+
+# pad.tmpl 16
+msgid "Write..."
+msgstr "Write..."
+
+# classic.tmpl 34
+# pad.tmpl 29
+msgid "Publish to..."
+msgstr "Publish to..."
+
+# classic.tmpl 55
+# pad.tmpl 50
+msgid "Font"
+msgstr "Font"
+
+# read.tmpl 105
+msgid "from"
+msgstr "from"
+
+# read.tmpl 105
+msgid "Anonymous"
+msgstr "Anonymous"
+
+# read.tmpl 120
+msgid "Older"
+msgstr "Older"
+
+# read.tmpl 121
+msgid "Newer"
+msgstr "Newer"
+
+# password-collection.tmpl 31
+msgid "Menu"
+msgstr "Menu"
+
+# password-collection.tmpl 51
+msgid "This blog requires a password."
+msgstr "This blog requires a password."
+
+# 404-general.tmpl 1
+msgid "Page not found"
+msgstr "Page not found"
+
+# 404-general.tmpl 4
+msgid "This page is missing."
+msgstr "This page is missing."
+
+# 404-general.tmpl 5
+msgid "Are you sure it was ever here?"
+msgstr "Are you sure it was ever here?"
+
+#404.tmpl 1,4
+msgid "Post not found"
+msgstr "Post not found"
+
+#404.tmpl
+msgid "Why not share a thought of your own?"
+msgstr "Why not share a thought of your own?"
+
+# 404.tmpl
+msgid "Start a blog"
+msgstr "Start a blog"
+
+#404.tmpl
+msgid "%s and spread your ideas on **%s**, %s."
+msgstr "%s and spread your ideas on **%s**, %s."
+
+# 404.tmpl
+msgid "a simple blogging community"
+msgstr "a simple blogging community"
+
+# 404.tmpl
+msgid "a simple, federated blogging community"
+msgstr "a simple, federated blogging community"
+
+# 410.tmpl 1
+msgid "Unpublished"
+msgst "Unpublished"
+
+# 410.tmpl 4
+msgid "Post was unpublished by the author."
+msgstr "Post was unpublished by the author."
+
+# 410.tmpl 5
+msgid "It might be back some day."
+msgstr "It might be back some day."
+
+# errors.go
+msgid "Expected valid form data."
+msgstr "Expected valid form data."
+
+msgid "Expected valid JSON object."
+msgstr "Expected valid JSON object."
+
+msgid "Expected valid JSON array."
+msgstr "Expected valid JSON array."
+
+msgid "Invalid access token."
+msgstr "Invalid access token."
+
+msgid "Authorization token required."
+msgstr "Authorization token required."
+
+msgid "Not logged in."
+msgstr "Not logged in."
+
+msgid "You don't have permission to add to this collection."
+msgstr "You don't have permission to add to this collection."
+
+msgid "Invalid editing credentials."
+msgstr "Invalid editing credentials."
+
+msgid "You don't have permission to do that."
+msgstr "You don't have permission to do that."
+
+msgid "Bad requested Content-Type."
+msgstr "Bad requested Content-Type."
+
+msgid "You don't have permission to access this collection."
+msgstr "You don't have permission to access this collection."
+
+msgid "Supply something to publish."
+msgstr "Supply something to publish."
+
+msgid "The humans messed something up. They've been notified."
+msgstr "The humans messed something up. They've been notified."
+
+msgid "Could not get cookie session."
+msgstr "Could not get cookie session."
+
+msgid "Service temporarily unavailable due to high load."
+msgstr "Service temporarily unavailable due to high load."
+
+msgid "Collection doesn't exist."
+msgstr "Collection doesn't exist."
+
+msgid "This blog was unpublished."
+msgstr "This blog was unpublished."
+
+msgid "Collection page doesn't exist."
+msgstr "Collection page doesn't exist."
+
+msgid "Post not found."
+msgstr "Post not found."
+
+msgid "Post removed."
+msgstr "Post removed."
+
+msgid "Post unpublished by author."
+msgstr "Post unpublished by author."
+
+msgid "We encountered an error getting the post. The humans have been alerted."
+msgstr "We encountered an error getting the post. The humans have been alerted."
+
+msgid "User doesn't exist."
+msgstr "User doesn't exist."
+
+msgid "Remote user not found."
+msgstr "Remote user not found."
+
+msgid "Please enter your username instead of your email address."
+msgstr "Please enter your username instead of your email address."
+
+msgid "Account is silenced."
+msgstr "Account is silenced."
+
+msgid "Password authentication is disabled."
+msgstr "Password authentication is disabled."
+
+msgid "Supply some properties to update."
+msgstr "Supply some properties to update."
\ No newline at end of file
diff --git a/locales/eu_ES/LC_MESSAGES/base.mo b/locales/eu_ES/LC_MESSAGES/base.mo
new file mode 100644
index 0000000..bdcf80a
Binary files /dev/null and b/locales/eu_ES/LC_MESSAGES/base.mo differ
diff --git a/locales/eu_ES/LC_MESSAGES/base.po b/locales/eu_ES/LC_MESSAGES/base.po
new file mode 100644
index 0000000..820cc31
--- /dev/null
+++ b/locales/eu_ES/LC_MESSAGES/base.po
@@ -0,0 +1,1677 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: GOtext\n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Last-Translator: Aitzol Berasategi <aitzol@lainoa.eus>\n"
+"Language-Team: Euskara-Basque\n"
+"Language: eu\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: Poedit 2.3\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+# base.tmpl 40
+# header.tmpl 49
+# pages.tmpl 21
+msgid "Home"
+msgstr "Hasiera"
+
+# base.tmpl 42
+# header.tmpl 51
+# footer.tmpl 8,26
+# user/include/footer.tmpl 11
+msgid "About"
+msgstr "Honi buruz"
+
+# base.tmpl 49
+# header.tmpl 58,65
+# include/footer.tmpl 9,27
+# user/include/footer.tmpl 12
+# app-settings.tmpl 108
+# pages.tmpl 24
+msgid "Reader"
+msgstr "Irakurlea"
+
+# login.tmpl 21
+# password-collection.tmpl 33
+# base.tmpl 51
+# header.tmpl 60
+msgid "Log in"
+msgstr "Saioa hasi"
+
+# classic.tmpl 48
+# collection.tmpl 63,74
+# pad.tmpl 43
+# base.tmpl 33,51
+# header.tmpl 14,41,60
+msgid "Log out"
+msgstr "Saioa itxi"
+
+# base.tmpl 45
+# export.tmpl 19
+# collections.tmpl 13
+# user/collection.tmpl 105
+# admin.tmpl 59
+# nav.tmpl 2
+# header.tmpl 54,63
+# view-user 106
+msgid "Blog"
+msgid_plural "Blogs"
+msgstr[0] "Blog"
+msgstr[1] "Blogak"
+
+# bare.tmpl 33
+# export.tmpl 19
+# admin.tmpl 60
+# view-user 132
+msgid "Post"
+msgid_plural "Posts"
+msgstr[0] "Post"
+msgstr[1] "Postak"
+
+# bare.tmpl 26
+# header.tmpl 55
+msgid "My Posts"
+msgstr "Nire postak"
+
+# classic.tmpl 38
+# edit-meta.tmpl 43
+# pad.tmpl 26,27,33
+# post.tmpl 47
+# base.tmpl 47
+# bare.tmpl 26
+# stats.tmpl 55
+# import.tmpl 41
+# articles.tmpl 25
+# header.tmpl 21,56,64
+# posts.tmpl 23,30,50,57
+msgid "Draft"
+msgid_plural "Drafts"
+msgstr[0] "Zirriborro"
+msgstr[1] "Zirriborroak"
+
+# login.tmpl 27
+# base.tmpl 50
+# header.tmpl 59
+msgid "Sign up"
+msgstr "Erregistratu"
+
+# classic.tmpl 5
+# collection.tmpl 53
+# pad.tmpl 5
+# base.tmpl 55
+# nav.tmpl 9
+# header.tmpl 25,71
+msgid "New Post"
+msgstr "Post berria"
+
+# header.tmpl 29
+msgid "Return to editor"
+msgstr "Itzuli editorera"
+
+# footer.tmpl 10,14,34
+# user/include/footer.tmpl 13
+msgid "writer's guide"
+msgstr "idazlearen gida"
+
+# footer.tmpl 15,35
+msgid "developers"
+msgstr "garatzaileak"
+
+# footer.tmpl 11,28
+# user/include/footer.tmpl 14
+msgid "privacy"
+msgstr "pribatutasuna"
+
+# footer.tmpl 16,36
+msgid "source code"
+msgstr "iturburu kodea"
+
+# header.tmpl 9,35
+msgid "Admin dashboard"
+msgstr "Administrazio-panela"
+
+# header.tmpl 10,36
+msgid "Account settings"
+msgstr "Kontuaren ezarpenak"
+
+# import.tmpl 17
+# header.tmpl 11,37
+msgid "Import posts"
+msgstr "Postak inportatu"
+
+# base.tmpl 30
+# export.tmpl 5,10
+# header.tmpl 12,38
+msgid "Export"
+msgstr "Exportatu"
+
+# header.tmpl 103
+msgid "Dashboard"
+msgstr "Aginte-panela"
+
+# header.tmpl 104
+msgid "Settings"
+msgstr "Ezarpenak"
+
+# export.tmpl 19
+# admin.tmpl 58
+# header.tmpl 106
+# view-user.tmpl 59
+# users.tmpl 15,21,30
+msgid "User"
+msgid_plural "Users"
+msgstr[0] "Erabiltzaile"
+msgstr[1] "Erabiltzaileak"
+
+# header.tmpl 107
+# pages.tmpl 13, 17
+msgid "Page"
+msgid_plural "Pages"
+msgstr[0] "Orria"
+msgstr[1] "Orriak"
+
+# post.tmpl 41
+# posts.tmpl 11,43,64,66
+# view-user 128
+msgid "View"
+msgid_plural "Views"
+msgstr[0] "Ikustaldi"
+msgstr[1] "Ikustaldiak"
+
+# posts.tmpl 11,43,64,66
+# view-user 128
+# edit-meta.tmpl 49,55
+# pad.tmpl 63
+msgid "View post"
+msgid_plural "View posts"
+msgstr[0] "Posta ikusi"
+msgstr[1] "Postak ikusi"
+
+# classic.tmpl 41,45
+# collection.tmpl 57
+# header.tmpl 7
+# edit-meta.tmpl 41
+# pad.tmpl 24,36,40
+# nav.tmpl 12
+msgid "View Blog"
+msgid_plural "View Blogs"
+msgstr[0] "Bloga Ikusi"
+msgstr[1] "Blogak Ikusi"
+
+# classic.tmpl 62
+# pad.tmpl 124
+msgid "word"
+msgid_plural "words"
+msgstr[0] "hitz"
+msgstr[1] "hitzak"
+
+# pad.tmpl 64
+msgid "Publish"
+msgstr "Argitaratu"
+
+# pad.tmpl 20
+# bare.tmpl 20
+msgid "This post has been updated elsewhere since you last published!"
+msgstr "Post hau beste nonbait eguneratua izan da argitaratuz geroztik!"
+
+# pad.tmpl 20
+# bare.tmpl 20
+msgid "Delete draft and reload"
+msgstr "Zirriborroa ezabatu eta birkargatu"
+
+msgid "Updates"
+msgstr "Eguneraketak"
+
+# classic.tmpl 42
+# pad.tmpl 37
+# user/collection.tmpl 32
+# collection.tmpl 54
+# nav.tmpl 10
+# header.tmpl 19
+msgid "Customize"
+msgstr "Pertsonalizatu"
+
+# classic.tmpl 43
+# collection.tmpl 55
+# pad.tmpl 38
+# stats.tmpl 26
+# nav.tmpl 11
+# header.tmpl 20
+msgid "Stats"
+msgstr "Estatistikak"
+
+# classic.tmpl 47
+# collection.tmpl 58
+# pad.tmpl 42
+msgid "View Draft"
+msgid_plural "View Drafts"
+msgstr[0] "Zirriborroa Ikusi"
+msgstr[1] "Zirriborroak Ikusi"
+
+# header.tmpl 111
+msgid "Monitor"
+msgstr "Monitorea"
+
+# read.tmpl 108,110
+msgid "Read more..."
+msgstr "Irakurri gehiago..."
+
+# silenced.tmpl 3
+msgid "Your account has been silenced."
+msgstr "Zure kontua isilarazia izan da."
+
+# silenced.tmpl 3
+msgid "You can still access all of your posts and blogs, but no one else can currently see them."
+msgstr "Oraindik zure blog eta postak ikus ditzakezu, baina ez beste inork."
+
+# articles.tmpl 28
+msgid "These are your draft posts. You can share them individually (without a blog) or move them to your blog when you're ready."
+msgstr "Hauek zure zirriborroak dira. Bakarka partekatu ahal izango dituzu (blogik gabe), edo prest dituzunean blogera mugitu eta argitaratu."
+
+# articles.tmpl 57
+msgid "Your anonymous and draft posts will show up here once you've published some. You'll be able to share them individually (without a blog) or move them to a blog when you're ready."
+msgstr "Zure zirriborro eta sarrera anonimoak hemen agertuko dira. Bakarka partekatu ahal izango dituzu (blogik gabe), edo prest dituzunean blogera mugitu eta argitaratu."
+
+# articles.tmpl 58
+msgid "Alternatively, see your blogs and their posts on your %s page."
+msgstr "Bestela, blogak eta argitalpenak ikusi ditzakezu %s orrian."
+
+# articles.tmpl 60
+msgid "Start writing"
+msgstr "Idazten hasi"
+
+# articles.tmpl 64
+# static/js/postactions.js
+msgid "unsynced posts"
+msgstr "sinkronizatu gabeko posta"
+
+# articles.tmpl 64
+# collection.tmpl 43
+# view-page.tmpl 51
+# view-user 116
+msgid "Title"
+msgstr "Izenburua"
+
+# collection.tmpl 44
+# view-user 120
+msgid "Description"
+msgstr "Deskribapena"
+
+# collection.tmpl 50
+msgid "This blog uses your username in its URL."
+msgstr "Blog honek zure erabiltzaile izena darabil bere URL-an."
+
+# collection.tmpl 50
+msgid "This blog uses your username in its URL and fediverse handle."
+msgstr "Blog honek zure erabiltzaile izena darabil bere URL-an eta fedibertsoaren kudeaketan."
+
+# collection.tmpl 50
+msgid "You can change it in your %s."
+msgstr "Hori %s atalean alda dezakezu."
+
+# collection.tmpl 63
+msgid "Publicity"
+msgstr "Publikotasuna"
+
+# collection.tmpl 68
+# app-settings.tmpl 120
+#, fuzzy
+msgid "Unlisted"
+msgstr "Irekia"
+
+# user/collection.tmpl 87
+# app-settings.tmpl 121
+msgid "Public"
+msgstr "Publikoa"
+
+# user/collection.tmpl 74
+# app-settings.tmpl 122
+msgid "Private"
+msgstr "Pribatua"
+
+# user/collection.tmpl 70
+msgid "This blog is visible to any registered user on this instance."
+msgstr "Blog hau instantzia honetan erregistraturiko edonorentzat dago ikusgai."
+
+# user/collection.tmpl 70
+msgid "This blog is visible to anyone with its link."
+msgstr "Blog hau bere esteka duen edonorentzat dago ikusgai."
+
+# user/collection.tmpl 76
+msgid "Only you may read this blog (while you're logged in)."
+msgstr "Zuk bakarrik irakur dezakezu blog hau (saioa hasita duzun bitartean)."
+
+# user/collection.tmpl 80
+msgid "Password-protected:"
+msgstr "Pasahitzez-babestua:"
+
+# user/collection.tmpl 80
+msgid "a memorable password"
+msgstr "pasahitz gogoangarri bat"
+
+# user/collection.tmpl 82
+msgid "A password is required to read this blog."
+msgstr "Pasahitza behar da blog hau irakurtzeko."
+
+# user/collection.tmpl 89
+msgid "This blog is displayed on the public %s, and is visible to anyone with its link."
+msgstr "Blog hau %s atal publikoan bistaratzen da, eta esteka ezagutzen duen edonorentzat dago ikusgai."
+
+# user/collection.tmpl 89
+msgid "This blog is displayed on the public %s, and is visible to any registered user on this instance."
+msgstr "Blog hau %s atal publikoan bistaratzen da, eta instantzia honetan erregistraturiko edonorentzat dago ikusgai."
+
+# user/collection.tmpl 90
+msgid "The public reader is currently turned off for this community."
+msgstr "Irakurlea atal publikoa desgaituta dago une honetan komunitate honentzat."
+
+# user/collection.tmpl 98
+msgid "Display Format"
+msgstr "Bistaratzea"
+
+# user/collection.tmpl 100
+msgid "Customize how your posts display on your page."
+msgstr "Pertsonalizatu zure postak zure orrian bistaratzeko modua."
+
+# user/collection.tmpl 107
+msgid "Dates are shown. Latest posts listed first."
+msgstr "Datak erakusten dira. Post berrienak zerrendan lehenak."
+
+# user/collection.tmpl 113
+msgid "No dates shown. Oldest posts first."
+msgstr "Datarik ez da erakusten. Post zaharrenak zerrendan lehenak."
+
+# user/collection.tmpl 119
+msgid "No dates shown. Latest posts first."
+msgstr "Datarik ez da erakusten. Post berrienak lehenak."
+
+# user/collection.tmpl 126
+msgid "Text Rendering"
+msgstr "Testu Errendatzea"
+
+# user/collection.tmpl 128
+msgid "Customize how plain text renders on your blog."
+msgstr "Pertsonalizatu testu-laua zure blogean errendatzeko modua."
+
+# user/collection.tmpl 145
+msgid "Custom CSS"
+msgstr "CSS pertsonalizazioa"
+
+# user/collection.tmpl 148
+msgid "customization"
+msgstr "pertsonalizazio"
+
+# user/collection.tmpl 148
+msgid "See our guide on %s."
+msgstr "Ikusi gure %s gida."
+
+# user/collection.tmpl 153
+msgid "Post Signature"
+msgstr "Post Sinadura"
+
+# user/collection.tmpl 155
+msgid "This content will be added to the end of every post on this blog, as if it were part of the post itself. Markdown, HTML, and shortcodes are allowed."
+msgstr "Eduki hau blog honetako post bakoitzaren amaieran gehituko da, postarena balitz bezala. Markdown, HTML, eta azpikodeak onartzen dira."
+
+# user/collection.tmpl 162
+msgid "Web Monetization"
+msgstr "Web Monetizazioa"
+
+# user/collection.tmpl 164
+msgid "Web Monetization enables you to receive micropayments from readers that have a %s. Add your payment pointer to enable Web Monetization on your blog."
+msgstr "Web Monetizazioak mikro-ordainketak jasotzeko aukera eskaintzen dizu %s duten irakurleengandik. Gehitu zure ordainketa datuak Web Monetizazioa zure blogean gaitzeko."
+
+# user/collection.tmpl 164
+msgid "Coil membership"
+msgstr "Coil kontua"
+
+# edit-meta.tmpl 263
+# settings.tmpl 81
+# user/collection.tmpl 171
+msgid "Save changes"
+msgstr "Gorde aldaketak"
+
+# user/collection.tmpl 173
+msgid "Delete Blog..."
+msgstr "Bloga ezabatu..."
+
+# user/collection.tmpl 190,220
+# articles.tmpl 36
+# posts.tmpl 19,46
+msgid "Delete"
+msgstr "Ezabatu"
+
+# settings.tmpl 187
+# user/collection.tmpl 189
+# view-user 173
+msgid "Cancel"
+msgstr "Utzi"
+
+# user/collection.tmpl 180
+msgid "Are you sure you want to delete this blog?"
+msgstr "Ziur al zaude blog hau ezabatu nahi duzula?"
+
+# posts.js 46,147
+# collection.tmpl 148
+# collection-tags.tmpl 96
+# chorus-collection.tmpl 132
+msgid "Are you sure you want to delete this post?"
+msgstr "Ziur al zaude post hau ezabatu nahi duzula?"
+
+# posts.js 302
+# collection.tmpl 175,226
+# collection-tags.tmpl 123,174
+# chorus-collection.tmpl 159,210
+msgid "Post is synced to another account. Delete the post from that account instead."
+msgstr "Posta beste kontu batekin sinkronizatzen da. Ezaba ezazu kontu horretako posta."
+
+# posts.js 308
+# collection.tmpl 181
+# collection-tags.tmpl 129
+# chorus-collection.tmpl 165
+msgid "Failed to delete."
+msgstr "Ezabatzeak huts egin du."
+
+# posts.js 308
+# collection.tmpl 181
+# collection-tags.tmpl 129
+# chorus-collection.tmpl 165
+msgid "Please try again."
+msgstr "Saia zaitez berriro."
+
+# user/collection.tmpl 182
+msgid "This will permanently erase **%s** (%s/%s) from the internet. Any posts on this blog will be saved and made into drafts (found on your %s page)."
+msgstr "Honek **%s** (%s/%s) betirako ezabatuko du internetetik. Blog honetako post guztiak %s orrira igaroko dira."
+
+# user/collection.tmpl 183
+msgid "If you're sure you want to delete this blog, enter its name in the box below and press **%s**."
+msgstr "Blog hau ezabatu nahi duzula ziur bazaude, sartu bere izena beheko eremuan eta sakatu **%s**."
+
+# user/collection.tmpl 202
+msgid "Enter **%s** in the box below."
+msgstr "Sartu **%s** beheko eremuan."
+
+# collection.tmpl 238
+msgid "Saving changes..."
+msgstr "Aldaketak gordetzen..."
+
+# collections.tmpl 72,75,84
+msgid "This name is taken."
+msgstr "Izen hori erabilita dago."
+
+# pad.tmpl 61
+msgid "Edit post metadata"
+msgstr "Metadatuak editatu"
+
+# edit-meta.tmpl 5,55
+msgid "Edit metadata"
+msgstr "Metadatuak editatu"
+
+# edit-meta.tmpl 260
+msgid "now"
+msgstr "orain"
+
+# edit-meta.tmpl 63
+msgid "Slug"
+msgstr "Sluga"
+
+# edit-meta.tmpl 66
+msgid "Language"
+msgstr "Hizkuntza"
+
+# edit-meta.tmpl 256
+msgid "Direction"
+msgstr "Norabidea"
+
+# edit-meta.tmpl 258
+msgid "Created"
+msgstr "Sortze data"
+
+# edit-meta.tmpl 257
+msgid "right-to-left"
+msgstr "eskuinetik-ezkerrera"
+
+# edit-meta.tmpl 47
+msgid "Edit post"
+msgstr "Posta editatu"
+
+# edit-meta.tmpl 48
+# pad.tmpl 6
+msgid "Toggle theme"
+msgstr "Gaia aldatu"
+
+# collection-post.tmpl 66
+# posts.tmpl 8
+msgid "Scheduled"
+msgstr "Programatua"
+
+# collection-post.tmpl 57
+# post.tmpl 45,91
+# articles.tmpl 35
+# posts.tmpl 17,44
+msgid "Edit"
+msgstr "Editatu"
+
+# posts.tmpl 18,45
+msgid "Pin"
+msgstr "Finkatu"
+
+# collection-post.tmpl 58
+msgid "Unpin"
+msgstr "Askatu"
+
+# posts.tmpl 21,48
+msgid "Move this post to another blog"
+msgstr "Mugitu post hau beste blog batetara"
+
+# posts.tmpl 30,57
+msgid "Change to a draft"
+msgstr "Eraman zirriborroetara"
+
+# posts.tmpl 30,57
+msgid "change to _%s_"
+msgstr "bihurtu _%s_"
+
+# articles.tmpl 43,78
+# posts.tmpl 26,53
+msgid "move to..."
+msgstr "mugi hona..."
+
+# articles.tmpl 47,83
+msgid "move to %s"
+msgstr "eraman %s-ra"
+
+# post.tmpl 42
+msgid "View raw"
+msgstr "Raw-eran ikusi"
+
+# articles.tmpl 47,83
+msgid "Publish this post to your blog %s"
+msgstr "Argitaratu post hau zure %s blogean"
+
+# articles.tmpl 39,74
+msgid "Move this post to one of your blogs"
+msgstr "Mugitu post hau zure blogetako batetara"
+
+# articles.tmpl 55
+msgid "Load more..."
+msgstr "Kargatu gehiago..."
+
+# stats.tmpl 32
+msgid "Stats for all time."
+msgstr "Estatistika orokorrak."
+
+# stats.tmpl 35
+msgid "Fediverse stats"
+msgstr "Fedibertsoko estatistikak"
+
+# stats.tmpl 38
+msgid "Followers"
+msgstr "Jarraitzaileak"
+
+# stats.tmpl 46
+msgid "Top %d post"
+msgid_plural "Top %d posts"
+msgstr[0] "%d. posta"
+msgstr[1] "Lehen %d postak"
+
+# stats.tmpl 51
+msgid "Total Views"
+msgstr "Bistaratzeak"
+
+# settings.tmpl 27
+msgid "Before you go..."
+msgstr "Joan aurretik..."
+
+# settings.tmpl 27
+msgid "Account Settings"
+msgstr "Kontuaren Ezarpenak"
+
+# settings.tmpl 38
+msgid "Change your account settings here."
+msgstr "Aldatu hemen zure kontuaren ezarpenak."
+
+# signup.tmpl 80
+# signup-oauth.tmpl 85,87
+# login.tmpl 21
+# landing.tmpl 92
+# settings.tmpl 43
+# view-user.tmpl 62
+msgid "Username"
+msgstr "Erabiltzaile-izena"
+
+# settings.tmpl 46
+msgid "Update"
+msgstr "Eguneratu"
+
+# settings.tmpl 56
+msgid "Passphrase"
+msgstr "Pasaesaldia"
+
+# settings.tmpl 58
+msgid "Add a passphrase to easily log in to your account."
+msgstr "Gehitu pasaesaldia saioa erraztasunez hasteko."
+
+# settings.tmpl 59,60
+msgid "Current passphrase"
+msgstr "Uneko pasaesaldia"
+
+# settings.tmpl 61,64
+msgid "New passphrase"
+msgstr "Pasaesaldi berria"
+
+# settings.tmpl 60,64
+msgid "Show"
+msgstr "Erakutsi"
+
+msgid "Account updated."
+msgstr "Kontua eguneratu da."
+
+# signup.tmpl 91
+# signup-oauth.tmpl 92,94
+# landing.tmpl 103
+# settings.tmpl 69
+msgid "Email"
+msgstr "Emaila"
+
+# settings.tmpl 76
+msgid "Email address"
+msgstr "Email helbidea"
+
+# settings.tmpl 71
+msgid "Add your email to get:"
+msgstr "Gehitu zure emaila hauek eskuratzeko:"
+
+# settings.tmpl 34
+msgid "Please add an **%s** and/or **%s** so you can log in again later."
+msgstr "Mesedez gehitu saioa hasteko erabiliko dituzun **%s** edo/eta **%s**."
+
+# settings.tmpl 73
+msgid "No-passphrase login"
+msgstr "Pasaesaldi gabeko saio hasiera"
+
+# settings.tmpl 74
+msgid "Account recovery if you forget your passphrase"
+msgstr "Kontu berreskuratzea pasaesaldia ahaztuz gero"
+
+# settings.tmpl 89
+msgid "Linked Accounts"
+msgstr "Lotutako Kontuak"
+
+# settings.tmpl 90
+msgid "These are your linked external accounts."
+msgstr "Hauek dira lotutako zure kanpo-kontuak."
+
+# settings.tmpl 114
+msgid "Link External Accounts"
+msgstr "Kanpoko Kontuak Lotu"
+
+msgid "Connect additional accounts to enable logging in with those providers, instead of using your username and password."
+msgstr "Konektatu kontu gehigarriak hornitzaile horiekin saioa hasteko, zure erabiltzaile-izena eta pasahitza erabili beharrean."
+
+# settings.tmpl 162
+# view-user 149
+msgid "Incinerator"
+msgstr "Errauskailua"
+
+# settings.tmpl 166,169,188
+msgid "Delete your account"
+msgstr "Ezabatu zure kontua"
+
+# settings.tmpl 167
+msgid "Permanently erase all your data, with no way to recover it."
+msgstr "Ezabatu zure datu guztiak, berreskuratzeko modurik gabe."
+
+# settings.tmpl 176
+# view-user 163
+msgid "Are you sure?"
+msgstr "Ziur al zaude?"
+
+# settings.tmpl 178
+msgid "export your data"
+msgstr "zure datuak esportatu"
+
+# settings.tmpl 178
+msgid "This action **cannot** be undone. It will immediately and permanently erase your account, including your blogs and posts. Before continuing, you might want to %s."
+msgstr "Ekintza hau **ezin da** desegin. Honek berehala eta betirako ezabatuko du zure kontua, blogak eta argitalpenak barne. Jarraitu aurretik agian %s nahi zenituzke."
+
+# settings.tmpl 179
+msgid "If you're sure, please type **%s** to confirm."
+msgstr "Ziur baldin bazaude, idatzi **%s** berresteko."
+
+# invite-help.tmpl 13
+msgid "Invite to %s"
+msgstr "%s gonbidatu"
+
+# invite-help.tmpl 15
+msgid "This invite link is expired."
+msgstr "Esteka hau iraungita dago."
+
+# invite-help.tmpl 21
+msgid "Only **one** user"
+msgstr "Erabiltzaile **bakarrak**"
+
+# invite-help.tmpl 21
+msgid "Up to **%d** users"
+msgstr "**%d** erabiltzaileko kopuruak"
+
+# invite-help.tmpl 21
+msgid "can sign up with this link."
+msgstr "hasi dezake saioa esteka honekin."
+
+# invite-help.tmpl 23
+msgid "It expires on **%s**."
+msgstr "Iraungitze data: **%s**."
+
+# invite-help.tmpl 25
+msgid "It can be used as many times as you like"
+msgstr "Nahi duzun adina aldiz erabili daiteke"
+
+# invite-help 25
+msgid "before **%s**, when it expires"
+msgstr "**%s** baino lehen, iraungitzen denean alegia"
+
+msgid "person has"
+msgid_plural "person have"
+msgstr[0] "pertsonak"
+msgstr[1] "pertsonek"
+
+# invite-help.tmpl 21
+msgid "So far, **%d** %s used it."
+msgstr "Orain arte, **%d** %s erabili dute."
+
+# invite-help.tmpl 21
+# So far, <strong>{{.Invite.Uses}}</strong> {{pluralize "person has" "people have" .Invite.Uses}} used it.
+# invite-help.tmpl 21
+# invite-help.tmpl 17
+msgid "Copy the link below and send it to anyone that you want to join *%s*. You could paste it into an email, instant message, text message, or write it down on paper. Anyone who navigates to this special page will be able to create an account."
+msgstr ""
+"Kopiatu ondorengo esteka eta *%s* gunera batzea nahi duzun lagunari bidali. Email batean erantsiz bidal dezakezu, mezularitza zerbitzu baten bidez, testu-mezu moduan edo paper zati batean idatziz. Helbide berezi honetara nabigatzen duen edonork izango du "
+"kontu bat sortzeko aukera."
+
+# IMPORT PAGE
+# import.tmpl 28
+msgid "Publish plain text or Markdown files to your account by uploading them below."
+msgstr "Argitaratu testu-lau edo Markdown fitxategiak zure kontura igoaz."
+
+# import.tmpl 31
+msgid "Select some files to import:"
+msgstr "Hautatu fitxategiak inportatzeko:"
+
+# import.tmpl 36
+msgid "Import these posts to:"
+msgstr "Inportatu postak hona:"
+
+# import.tmpl 59
+msgid "Import"
+msgstr "Inportatu"
+
+msgid "Import complete, %d post imported."
+msgid_plural "Import complete, %d posts imported."
+msgstr[0] "Import complete, %d post imported."
+msgstr[1] "Import complete, %d posts imported."
+
+msgid "%d of %d posts imported, see details below."
+msgid_plural "%d of %d posts imported, see details below."
+msgstr[0] "%d of %d posts imported, see details below."
+msgstr[1] "%d of %d posts imported, see details below."
+
+msgid "%s is not a supported post file"
+msgstr "%s post fitxategia ez da onartzen"
+
+# export.tmpl 6
+msgid "Your data on %s is always free. Download and back-up your work any time."
+msgstr "Zure datuen biltegiratzea doanekoa da %s webgunean. Deskargatu eta egin zure lanen babeskopia edonoiz."
+
+# export.tmpl 11
+msgid "Format"
+msgstr "Formatua"
+
+# header.tmpl 101
+msgid "Admin"
+msgstr "Admin"
+
+# app-settings.tmpl 37
+msgid "Site Title"
+msgstr "Gunearen Izenburua"
+
+# app-settings.tmpl 38
+msgid "Your public site name."
+msgstr "Gune publikoaren izena."
+
+# app-settings.tmpl 44
+msgid "Site Description"
+msgstr "Gunearen Deskribapena"
+
+# app-settings.tmpl 45
+msgid "Describe your site — this shows in your site's metadata."
+msgstr "Deskribatu zure gunea — hau gunearen metadatuetan erakutsiko da."
+
+# app-settings.tmpl 51
+msgid "Host"
+msgstr "Host"
+
+# app-settings.tmpl 52
+msgid "The public address where users will access your site, starting with `http://` or `https://`."
+msgstr "Erabiltzaileek gunean sartzeko erabiliko duten helbidea, hasieran `http://` edo `https://` protokoloa zehaztuz."
+
+# app-settings.tmpl 58
+msgid "Community Mode"
+msgstr "Komunitate Modua"
+
+# app-settings.tmpl 59
+msgid "Whether your site is made for one person or many."
+msgstr "Zure gunea pertsona bakarrarentzat edo askorentzat egina dagoen."
+
+# app-settings.tmpl 61
+msgid "Single user"
+msgstr "Erabiltzaile bakarra"
+
+# app-settings.tmpl 61
+msgid "Multiple users"
+msgstr "Erabiltzaile anitz"
+
+# app-settings.tmpl 65
+msgid "Landing Page"
+msgstr "Helburu-orria"
+
+# app-settings.tmpl 66
+msgid "The page that logged-out visitors will see first. This should be an absolute path like: `/read`."
+msgstr "Saioa hasi gabe duten erabiltzaileek hasieran ikusiko duten orria. Honen moduko bide absolutua izan beharko litzateke: `/read`."
+
+# app-settings.tmpl 72
+msgid "Open Registrations"
+msgstr "Erregistro Irekia"
+
+# app-settings.tmpl 73
+msgid "Allow anyone who visits the site to create an account."
+msgstr "Gunea bisitatzen duen edonori kontua sortzen utzi."
+
+# app-settings.tmpl 80
+msgid "Allow account deletion"
+msgstr "Kontua ezabatzen utzi"
+
+# app-settings.tmpl 81
+msgid "Allow all users to delete their account. Admins can always delete users."
+msgstr "Erabiltzaileei beraien kontua ezabatzen utzi. Administratzaileek beti ezabatu ditzakete erabiltzaileak."
+
+# app-settings.tmpl 88
+msgid "Allow invitations from..."
+msgstr "Gonbidapen onarpena"
+
+# app-settings.tmpl 89
+msgid "Choose who is allowed to invite new people."
+msgstr "Zehaztu nor dagoen baimenduta jende berria gonbidatzera."
+
+# app-settings.tmpl 93
+msgid "No one"
+msgstr "Inor ez"
+
+# app-settings.tmpl 94
+msgid "Only Admins"
+msgstr "Admin-ak soilik"
+
+# app-settings.tmpl 95
+msgid "All Users"
+msgstr "Erabiltzaile oro"
+
+# app-settings.tmpl 101
+msgid "Private Instance"
+msgstr "Instantzia Pribatua"
+
+# app-settings.tmpl 102
+msgid "Limit site access to people with an account."
+msgstr "Sarbidea mugatu kontua duten erabiltzaileei."
+
+# app-settings.tmpl 109
+msgid "Show a feed of user posts for anyone who chooses to share there."
+msgstr "Erabiltzailearen argitalpenen feed bat erakutsi bertan elkarbanatzea aukeratzen duen edonorentzat."
+
+# app-settings.tmpl 115
+msgid "Default blog visibility"
+msgstr "Blogaren ikusgarritasuna"
+
+# app-settings.tmpl 116
+msgid "The default setting for new accounts and blogs."
+msgstr "Kontu eta blog berrientzako lehenetsitako ezarpena."
+
+# app-settings.tmpl 128
+msgid "Maximum Blogs per User"
+msgstr "Blog Kopurua Erabiltzaileko"
+
+# app-settings.tmpl 129
+msgid "Keep things simple by setting this to **1**, unlimited by setting to **0**, or pick another amount."
+msgstr "Guztia modu sinplean mantentzeko sartu **1**, mugagabea **0** ezarriz, bestela gogoko kopurua zehaztu dezakezu."
+
+# app-settings.tmpl 135
+msgid "Federation"
+msgstr "Federazioa"
+
+# app-settings.tmpl 136
+msgid "Enable accounts on this site to propagate their posts via the ActivityPub protocol."
+msgstr "Gaitu gune honetako kontuek ActivityPub protokolo bidez postak zabaldu ditzaten."
+
+# app-settings.tmpl 142
+msgid "Public Stats"
+msgstr "Estatistika Publikoak"
+
+# app-settings.tmpl 143
+msgid "Publicly display the number of users and posts on your **%s** page."
+msgstr "Erakutsi publikoki erabiltzaile eta post kopurua **%s** orrian."
+
+# app-settings.tmpl 149
+msgid "Monetization"
+msgstr "Monetizazioa"
+
+# app-settings.tmpl 150
+msgid "Enable blogs on this site to receive micropayments from readers via %s."
+msgstr "Gaitu gune honetako blogak irakurleengandik mikro-ordainketak jaso ditzaten, %s-en bidez."
+
+# app-settings.tmpl 156
+msgid "Minimum Username Length"
+msgstr "Erabiltzaile-izenaren gutxieneko luzera"
+
+# app-settings.tmpl 157
+msgid "The minimum number of characters allowed in a username. (Recommended: 2 or more.)"
+msgstr "Erabiltzaile-izenean onarturiko gutxieneko karaktere kopurua. (Gomendatua: 2 edo gehiago.)"
+
+# app-settings.tmpl 162
+msgid "Save Settings"
+msgstr "Gorde Ezarpenak"
+
+# app-settings.tmpl 166
+msgid "configuration docs"
+msgstr "konfiguraketa dokumentuetan"
+
+# app-settings.tmpl 166
+msgid "Still have questions? Read more details in the %s."
+msgstr "Zalantzak dituzu oraindik? Irakurri xehetasun gehiago %s."
+
+msgid "Configuration saved."
+msgstr "Konfiguraketa gorde da."
+
+# view-user.tmpl 66
+# users.tmpl 22
+msgid "joined"
+msgstr "elkartua"
+
+# users.tmpl 23
+msgid "type"
+msgstr "mota"
+
+# users.tmpl 24
+# view-user.tmpl 79
+msgid "status"
+msgstr "egoera"
+
+# base.tmpl 31
+# header.tmpl 39
+# invite.tmpl 26
+# users.tmpl 16
+msgid "Invite people"
+msgstr "Jendea gonbidatu"
+
+# invite.tmpl 27
+msgid "Invite others to join *%s* by generating and sharing invite links below."
+msgstr "Gonbidatu lagunen bat *%s* gunera batu dadin gonbidapen esteka bat sortu eta berarekin elkarbanatuz."
+
+# invite.tmpl 31
+msgid "Maximum number of uses:"
+msgstr "Gehienezko erabilera kopurua:"
+
+# invite.tmpl 33
+msgid "No limit"
+msgstr "Mugagabea"
+
+# invite.tmpl 34,35,36,37,38,39,64
+msgid "use"
+msgid_plural "uses"
+msgstr[0] "use"
+msgstr[1] "uses"
+
+# invite.tmpl 43
+msgid "Expire after:"
+msgstr "Iraungitze data:"
+
+# invite.tmpl 46
+msgid "minute"
+msgid_plural "minutes"
+msgstr[0] "minute"
+msgstr[1] "minutes"
+
+# invite.tmpl 47,48,49
+msgid "hour"
+msgid_plural "hours"
+msgstr[0] "hour"
+msgstr[1] "hours"
+
+# invite.tmpl 50,51
+msgid "day"
+msgid_plural "days"
+msgstr[0] "day"
+msgstr[1] "days"
+
+# invite.tmpl 52
+msgid "week"
+msgid_plural "weeks"
+msgstr[0] "week"
+msgstr[1] "weeks"
+
+# invite.tmpl 57
+msgid "You cannot generate invites while your account is silenced."
+msgstr "Ezin duzu gonbidapenik sortu zure kontua isilarazirik dagoen bitartean."
+
+# invite.tmpl 57
+msgid "Generate"
+msgstr "Sortu"
+
+# invite.tmpl 63
+msgid "Link"
+msgstr "Lotura"
+
+# invite.tmpl 121,129,137,145,152
+msgid "ToLink"
+msgstr "Lotu"
+
+# invite.tmpl 65
+msgid "Expires"
+msgstr "Iraungitzea"
+
+# invite.tmpl 71
+msgid "Expired"
+msgstr "Iraungita"
+
+# invite.tmpl 75
+msgid "No invites generated yet."
+msgstr "Oraindik ez da gonbidapenik sortu."
+
+# pages.tmpl 18
+msgid "last modified"
+msgstr "azken aldaketa"
+
+# view-user.tmpl 85
+msgid "Active"
+msgstr "Aktibo"
+
+# view-user.tmpl 82
+msgid "Silenced"
+msgstr "Isilarazia"
+
+# view-user.tmpl 83
+msgid "Unsilence"
+msgstr "Ez-isilarazi"
+
+# view-user 86
+msgid "disabled"
+msgstr "desgaitua"
+
+# view-user 86
+msgid "Silence"
+msgstr "Isilarazi"
+
+# view-user.tmpl 54
+msgid "No."
+msgstr "Zkia."
+
+# view-user.tmpl 70
+msgid "total posts"
+msgstr "postak guztira"
+
+# view-user.tmpl 74,136
+msgid "last post"
+msgstr "azken posta"
+
+# signup.tmpl 87
+# login.tmpl 22
+# landing.tmpl 99
+# view-user 92
+msgid "password"
+msgstr "pasahitza"
+
+msgid "Change your password"
+msgstr "Aldatu zure pasahitza"
+
+# view-user 100
+msgid "Go to reset password page"
+msgstr "Pasahitza berrezartzeko orrira joan"
+
+# view-user 141
+msgid "Fediverse followers"
+msgstr "Fedibertsoko jarraitzaileak"
+
+# view-user 124
+msgid "Visibility"
+msgstr "Ikusgarritasuna"
+
+# view-user 112
+msgid "Alias"
+msgstr "Ezizena"
+
+# view-user.tmpl 75,137
+msgid "Never"
+msgstr "Inoiz ez"
+
+# view-user 97
+msgid "Reset"
+msgstr "Berrezarri"
+
+# view-user 153,156,174
+msgid "Delete this user"
+msgstr "Erabiltzailea ezabatu"
+
+# view-user 154
+msgid "Permanently erase all user data, with no way to recover it."
+msgstr "Betiko ezabatu erabiltzailearen datu guztiak, berreskuratzeko aukerarik gabe."
+
+# view-user 165
+msgid "This action **cannot**be undone. It will permanently erase all traces of this user, **%s**, including their account information, blogs, and posts."
+msgstr "Ekintza hau **ezingo da** desegin. Betiko ezabatuko ditu erabiltzaile honen aztarna guztiak, **%s**, bere kontuaren informazioa barne, blogak, eta postak."
+
+# view-user 166
+msgid "Please type **%s** to confirm."
+msgstr "Mesedez idatzi **%s** baieztatzeko."
+
+# view-user 202
+msgid "Silence this user? They'll still be able to log in and access their posts, but no one else will be able to see them anymore. You can reverse this decision at any time."
+msgstr "Erabiltzailea isilarazi? Isilarazitako erabiltzaileek sarbidea izaten jarraituko dute beraien idatzietara, baina ez ditu inork ikusiko. Erabaki hau edozein unetan alda dezakezu."
+
+# view-user 208
+msgid "Reset this user's password? This will generate a new temporary password that you'll need to share with them, and invalidate their old one."
+msgstr "Erabiltzailearen pasahitza berrezarri? Honek aldi baterako pasahitz berri bat sortuko du, beraiekin partekatu beharko duzuna eta zaharra baliogabetu."
+
+# settings.tmpl 225
+# collection.tmpl 207
+# view-user.tmpl 198
+msgid "Deleting..."
+msgstr "Ezabatzen..."
+
+# view-user.tmpl 46
+msgid "This user's password has been reset to:"
+msgstr "Erabiltzaile honen pasahitza hona berrezarri da:"
+
+# view-user.tmpl 48
+msgid "They can use this new password to log in to their account. **This will only be shown once**, so be sure to copy it and send it to them now."
+msgstr "Pasahitz berri hau kontuan saio hasteko erabili ahal izango da. **Behin bakarrik erakutsiko da**, beraz, ziurtatu kopiatu eta bidaltzeaz."
+
+# view-user.tmpl 49
+msgid "Their email address is:"
+msgstr "Email helbidea:"
+
+# app-updates.tmlp 19
+msgid "Automated update check failed."
+msgstr "Eguneratze automatizatuaren egiaztatzeak huts egin du."
+
+# app-updates.tmlp 20, 24, 41
+msgid "Installed version: %s (%s)."
+msgstr "Instalaturiko bertsioa: %s (%s)."
+
+# app-updates.tmlp 21, 42
+msgid "Learn about latest releases on the %s or %s."
+msgstr "Ezagutu azken bertsioen inguruan %s-ean edo %s-ean."
+
+# app-updates.tmlp 23
+msgid "WriteFreely is **up to date**."
+msgstr "WriteFreely **eguneratuta** dago."
+
+# app-updates.tmlp 27
+msgid "Get"
+msgstr "Lortu"
+
+# app-updates.tmlp 27
+msgid "A new version of WriteFreely is available! **%s %s**"
+msgstr "WriteFreely bertsio berri bat dago eskuragarri! **%s %s**"
+
+# app-updates.tmlp 28
+msgid "release notes"
+msgstr "bertsio oharrak"
+
+# app-updates.tmlp 29
+msgid "Read the %s for details on features, bug fixes, and notes on upgrading from your current version, **%s**."
+msgstr "Irakurri %s, **%s** bertsioaren ezaugarriei, akatsen zuzenketei eta eguneratze-oharrei buruzko xehetasunak ezagutzeko."
+
+# app-updates.tmlp 31
+msgid "Check now"
+msgstr "Egiaztatu orain"
+
+# app-updates.tmlp 31
+msgid "Last checked"
+msgstr "Azkenengoz egiaztatua"
+
+# app-updates.tmlp 40
+msgid "Automated update checks are disabled."
+msgstr "Eguneratze-kontrol automatizatuak desgaituta daude."
+
+# ADMIN PAGES
+# view-page.tmpl 45
+msgid "Banner"
+msgstr "Bannerra"
+
+# view-page.tmpl 56
+msgid "Body"
+msgstr "Body"
+
+# view-page.tmpl 33
+msgid "Outline your %s."
+msgstr "Azaldu zure %s."
+
+# view-page.tmpl 35,37
+msgid "Customize your %s page."
+msgstr "Pertsonalizatu zure %s orria."
+
+# view-page.tmpl 31
+msgid "Describe what your instance is %s."
+msgstr "Egin deskribapena instantzia %s."
+
+msgid "Accepts Markdown and HTML."
+msgstr "Markdown eta HTML erabili daitezke."
+
+# view-page.tmpl 56
+msgid "Content"
+msgstr "Edukia"
+
+# view-page.tmpl 63
+msgid "Save"
+msgstr "Gorde"
+
+# view-page.tmpl 71
+msgid "Saving..."
+msgstr "Gordetzen..."
+
+# view-page.tmpl 48
+msgid "We suggest a header (e.g. `# Welcome`), optionally followed by a small bit of text. Accepts Markdown and HTML."
+msgstr "Goiburu bat iradokitzen dugu (adib. ` # Ongi Etorri`), aukeran testu pixka batez jarraituz. Markdown eta HTML onartzen dira."
+
+# login.tmpl 11
+msgid "Log in to %s"
+msgstr "Hasi saioa %s gunean"
+
+# login.tmpl 32
+msgid "Logging in..."
+msgstr "Saioa hasten..."
+
+# login.tmpl 27
+msgid "_No account yet?_ %s to start a blog."
+msgstr "_Konturik ez oraindik?_ %s blog bat hasteko."
+
+msgid "Incorrect password."
+msgstr "Pasahitz okerra."
+
+msgid "This user never set a password. Perhaps try logging in via OAuth?"
+msgstr "Erabiltzaile honek ez du pasahitzik ezarririk. Beharbada OAuth bidez saiatu?"
+
+msgid "This user never added a password or email address. Please contact us for help."
+msgstr "Erabiltzaile honek ez du inoiz pasahitz edo emailik gehitu. Jarri gurekin harremanetan laguntza jasotzeko."
+
+msgid "You're doing that too much."
+msgstr "Gehiegitan ari zara hori egiten."
+
+msgid "Parameter `alias` required."
+msgstr "Sartu erabiltzaile izena."
+
+msgid "A username is required."
+msgstr "Sartu erabiltzaile izena."
+
+msgid "Parameter `pass` required."
+msgstr "Sartu ezazu pasahitza."
+
+msgid "A password is required."
+msgstr "Pasahitza beharrezkoa da."
+
+msgid "Need a collection `alias` to read."
+msgstr "Bilduma `ezizen` bat behar da irakurtzeko."
+
+msgid "Please supply a password."
+msgstr "Mesedez sartu pasahitz bat."
+
+msgid "Something went very wrong. The humans have been alerted."
+msgstr "Zeozer oso gaizki atera da. Gizakiak jakinaren gain jarriko dira."
+
+msgid "Logging out failed. Try clearing cookies for this site, instead."
+msgstr "Irtetzeak huts egin du. Horren ordez, saia zaitez cookieak garbitzen."
+
+msgid "your-username"
+msgstr "zure-erabiltzaile-izena"
+
+msgid "optional"
+msgstr "hautazkoa"
+
+msgid "Create blog"
+msgstr "Sortu bloga"
+
+# signup-oauth.tmpl 59
+msgid "Finish creating account"
+msgstr "Amaitu kontua sortzen"
+
+# signup-oauth.tmpl 79
+msgid "Display Name"
+msgstr "Bistaratze izena"
+
+# oauth.tmpl 26
+msgid "or"
+msgstr "edo"
+
+# oauth.tmpl 9,13,17,20
+msgid "Sign in with **%s**"
+msgstr "Hasi saioa **%s**-ekin"
+
+# landing.tmpl 77
+msgid "Learn more..."
+msgstr "Ikasi gehiago..."
+
+# landing.tmpl 114
+msgid "Registration is currently closed."
+msgstr "Izen-ematea itxita dago une honetan."
+
+# landing.tmpl 115
+msgid "another instance"
+msgstr "beste instantzia"
+
+# landing.tmpl 115
+msgid "You can always sign up on %s."
+msgstr "Beti hasi ahal izango saioa %s batean."
+
+msgid "# Start your blog"
+msgstr "# Hasi zure bloga"
+
+msgid "# Start your blog in the fediverse"
+msgstr "# Hasi zure bloga fedibertsoan"
+
+msgid ""
+"## Join the Fediverse\n"
+"\n"
+"The fediverse is a large network of platforms that all speak a common language. Imagine if you could reply to _Instagram_ posts from _Twitter_, or interact with your favorite _Medium_ blogs from _Facebook_ -- federated alternatives like %s, %s, and "
+"WriteFreely enable you to do these types of things.\n"
+"\n"
+msgstr ""
+"## Batu fedibertsora\n"
+"\n"
+"Fedibertsoa plataforma desberdinek osatzen duten sare zabal bat da, non guztiek hizkuntza bera erabiltzen duten. Imajina ezazu _Twitter_-etik _Instagram_-eko sarrera bat erantzuteko aukera izango bazenu, edo _Facebook_-etik _Medium_ blog plataforman "
+"jardutekoa. %s, %s eta WriteFreely bezalako alternatibek horrelako gauzak egitea ahalbidetzen dute.\n"
+"\n"
+
+msgid ""
+"## Write More Socially\n"
+"\n"
+"WriteFreely can communicate with other federated platforms like _Mastodon_, so people can follow your blogs, bookmark their favorite posts, and boost them to their followers. Sign up above to create a blog and join the fediverse."
+msgstr ""
+"## Idatzi sozialkiago\n"
+"\n"
+"WriteFreely _Mastodon_ bezalako beste edozein federatutako plataformarekin komunika daiteke, beraz jendeak zure bloga jarraitu ahal izango du, gogoko sarreren laster-markak egin, eta jarraitzaileekin elkarbanatu. Eman izena blog bat sortzeko eta batu "
+"fedibertsora."
+
+msgid "About %s"
+msgstr "%s-i buruz"
+
+msgid "_%s_ is a place for you to write and publish, powered by %s."
+msgstr "_%s_ idatzi eta argitaratzeko gune bat da, %s erabiliz."
+
+msgid "_%s_ is an interconnected place for you to write and publish, powered by %s."
+msgstr "_%s_ idatzi eta argitaratzeko gune bat da, %s erabiliz."
+
+msgid "article"
+msgid_plural "articles"
+msgstr[0] "artikulu"
+msgstr[1] "artikuluak"
+
+msgid "_%s_ is home to **%d** %s across **%d** %s."
+msgstr "_%s_ guneak **%d** %s ditu, eta **%d** %s."
+
+msgid "About WriteFreely"
+msgstr "WriteFreely-ri buruz"
+
+msgid "%s is a self-hosted, decentralized blogging platform for publishing beautiful, simple blogs."
+msgstr "%s blog eder eta sinpleak argitaratzeko norberak ostata dezakeen eta deszentralizaturikoa den blogintza plataforma bat da."
+
+msgid "It lets you publish a single blog, or host a community of writers who can create multiple blogs under one account. You can also enable federation, which allows people in the fediverse to follow your blog, bookmark your posts, and share them with others."
+msgstr ""
+"Blog bakar bat argitaratzeko aukera eskaintzen du, edo kontu bakarrean hainbat blog sor ditzakeen idazle komunitate bat ostatatzekoa. Federazioa ere gaitu daiteke, horrela, bertako pertsonek blogak jarraitu, posten laster-markak egin, eta besteekin partekatu "
+"ahal izango dituzte."
+
+msgid "Start an instance"
+msgstr "Hasi instantzia bat"
+
+msgid "Privacy Policy"
+msgstr "Pribatutasun Politika"
+
+msgid "Last updated"
+msgstr "Azken eguneraketa"
+
+msgid "Read the latest posts form %s."
+msgstr "Irakurri %s-en argitaratutako azken sarrerak."
+
+# : pages.go:98
+msgid ""
+"%s, the software that powers this site, is built to enforce your right to privacy by default.\n"
+"\n"
+"It retains as little data about you as possible, not even requiring an email address to sign up. However, if you _do_ give us your email address, it is stored encrypted in our database.\n"
+"\n"
+"We salt and hash your account's password.We store log files, or data about what happens on our servers. We also use cookies to keep you logged in to your account.\n"
+"\n"
+"Beyond this, it's important that you trust whoever runs %s. Software can only do so much to protect you -- your level of privacy protections will ultimately fall on the humans that run this particular service."
+msgstr ""
+"Gune hau ahalbidetzen duen softwareak, hau da, %s-k zure pribatutasun eskubidea bermatzea lehenesten du.\n"
+"\n"
+"Instantzia honetan zuri buruzko ahalik eta datu gutxien gordetzen da, erregistratzeko helbide elektronikorik ere ez da behar, baina ematen badiguzu ere, enkriptaturik gordeko da gure datu-basean. Zure pasahitza berriz salt eta hash bidez babesten da.\n"
+"\n"
+"Log fitxategiak ere gordetzen ditugu, gure zerbitzarietan gertatzen dena, alegia. Zure kontuan saioa hasita izan dezazun cookieak ere erabiltzen ditugu.\n"
+"\n"
+"Horrez gain, garrantzitsua da %s kudeatzen ari dena zure konfidantzazko norbait izatea. Softwareak asko egin dezake zu babesteko, baina zure pribatutasunaren babes-maila, azken batean, zerbitzu hau kudeatzen duten pertsonena da."
+
+# static/js/postactions.js
+msgid "Unpublished post"
+msgstr "Argitaratu gabeko posta"
+
+msgid "Moved to %s"
+msgstr "%s-(e)ra mugitua"
+
+msgid "move to"
+msgstr "eraman"
+
+msgid "moving to %s..."
+msgstr "%s-(e)ra mugitzen..."
+
+msgid "unable to move"
+msgstr "ezinezkoa mugitzea"
+
+msgid "View on %s"
+msgstr "Ikusi %s-(e)n"
+
+# classic.tmpl 64
+# pad.tmpl 59
+# bare.tmpl 30
+msgid "NOTE"
+msgstr "OHARRA"
+
+# classic.tmpl 64
+# pad.tmpl 59
+# bare.tmpl 30
+msgid "for now, you'll need Javascript enabled to post."
+msgstr "oraingoz, Javascript gaiturik behar duzu postak idazteko."
+
+# classic.tmpl 158
+# pad.tmpl 174
+msgid "Your account is silenced, so you can't publish or update posts."
+msgstr "Zure kontua isilarazi egin da, beraz ezin duzu postik argitaratu edo eguneratu."
+
+# classic.tmpl 257
+# pad.tmpl 278
+msgid "Failed to post. Please try again."
+msgstr "Huts argitaratzerakoan. Saia zaitez berriro."
+
+# classic.tmpl 163
+# pad.tmpl 179
+# bare.tmpl 99
+# errors.go
+msgid "You don't have permission to update this post."
+msgstr "Ez duzu baimenik post hau eguneratzeko."
+
+# pad.tmpl 16
+msgid "Write..."
+msgstr "Idatzi..."
+
+# classic.tmpl 34
+# pad.tmpl 29
+msgid "Publish to..."
+msgstr "Argitaratu..."
+
+# classic.tmpl 55
+# pad.tmpl 50
+msgid "Font"
+msgstr "Letra-tipoa"
+
+# read.tmpl 105
+msgid "from"
+msgstr "from"
+
+# read.tmpl 105
+msgid "Anonymous"
+msgstr "Anonimoa"
+
+# read.tmpl 120
+msgid "Older"
+msgstr "Zaharragoa"
+
+# read.tmpl 121
+msgid "Newer"
+msgstr "Berriagoa"
+
+# password-collection.tmpl 31
+msgid "Menu"
+msgstr "Menu"
+
+# password-collection.tmpl 51
+msgid "This blog requires a password."
+msgstr "Blog honek pasahitza behar du."
+
+# 404-general.tmpl 1
+msgid "Page not found"
+msgstr "Page not found"
+
+# 404-general.tmpl 4
+msgid "This page is missing."
+msgstr "Orri hori falta da."
+
+# 404-general.tmpl 5
+msgid "Are you sure it was ever here?"
+msgstr "Ziur al zaude hemen dagoela?"
+
+# 404.tmpl 1,4
+msgid "Post not found"
+msgstr "Ez da aurkitu posta"
+
+# 404.tmpl
+msgid "Why not share a thought of your own?"
+msgstr "Zergatik ez partekatu norberaren pentsamenduak?"
+
+# 404.tmpl
+msgid "Start a blog"
+msgstr "Hasi blog bat"
+
+# 404.tmpl
+msgid "%s and spread your ideas on **%s**, %s."
+msgstr "%s eta adierazi zure ideiak **%s** gunean, %s."
+
+# 404.tmpl
+msgid "a simple blogging community"
+msgstr "blogintza komunitate sinplea"
+
+# 404.tmpl
+msgid "a simple, federated blogging community"
+msgstr ",federaturiko blogintza komunitate sinplea"
+
+# 410.tmpl 1
+# 410.tmpl 4
+msgid "Post was unpublished by the author."
+msgstr "Egileak posta ezabatu du."
+
+# 410.tmpl 5
+msgid "It might be back some day."
+msgstr "Agian itzuliko da egunen batean."
+
+# errors.go
+msgid "Expected valid form data."
+msgstr "Baliozko datuak espero ziren galdetegian."
+
+msgid "Expected valid JSON object."
+msgstr "Baliozko JSON objetua espero zen."
+
+msgid "Expected valid JSON array."
+msgstr "Baliozko JSON matrizea espero zen."
+
+msgid "Invalid access token."
+msgstr "Baliogabeko sarbide token-a."
+
+msgid "Authorization token required."
+msgstr "Baimen tokena beharrezkoa da."
+
+msgid "Not logged in."
+msgstr "Saioa hasi gabe."
+
+msgid "You don't have permission to add to this collection."
+msgstr "Ez duzu baimenik bilduma honetara gehitzeko."
+
+msgid "Invalid editing credentials."
+msgstr "Editatzeko kredentzial baliogabeak."
+
+msgid "You don't have permission to do that."
+msgstr "Ez duzu hori egiteko baimenik."
+
+msgid "Bad requested Content-Type."
+msgstr "Content-Type eskaera okerra."
+
+msgid "You don't have permission to access this collection."
+msgstr "Ez duzu baimenik bilduma honetara sartzeko."
+
+msgid "Supply something to publish."
+msgstr "Eskaini argitaratzeko zeozer."
+
+msgid "The humans messed something up. They've been notified."
+msgstr "Gizakiek zerbait izorratu dute. Abisatuta daude."
+
+msgid "Could not get cookie session."
+msgstr "Ezin da cookie saioa eskuratu."
+
+msgid "Service temporarily unavailable due to high load."
+msgstr "Zerbitzua aldi baterako ez dago erabilgarri karga handia dela eta."
+
+msgid "Collection doesn't exist."
+msgstr "Bilduma ez da existitzen."
+
+msgid "This blog was unpublished."
+msgstr "Blog hau argitaratu gabe dago."
+
+msgid "Collection page doesn't exist."
+msgstr "Bildumaren orrialdea ez da existitzen."
+
+msgid "Post not found."
+msgstr "Ez da posta aurkitu."
+
+msgid "Post removed."
+msgstr "Posta ezabatua."
+
+msgid "Post unpublished by author."
+msgstr "Egileak argitaratu gabeko posta."
+
+msgid "We encountered an error getting the post. The humans have been alerted."
+msgstr "Errorea gertatu da posta eskuratzean. Gizakiak abisatuta daude."
+
+msgid "User doesn't exist."
+msgstr "Erabiltzailea ez da existitzen."
+
+msgid "Remote user not found."
+msgstr "Ez da urruneko erabiltzailea aurkitu."
+
+msgid "Please enter your username instead of your email address."
+msgstr "Idatzi zure erabiltzaile-izena e-posta helbidearen ordez."
+
+msgid "Account is silenced."
+msgstr "Kontua isilarazi egin da."
+
+msgid "Password authentication is disabled."
+msgstr "Pasahitz bidezko autentifikazioa desgaituta dago."
+
+msgid "Supply some properties to update."
+msgstr "Hornitu eguneatzeko zenbait propietate."
diff --git a/page/page.go b/page/page.go
index 5ab7750..aa23757 100644
--- a/page/page.go
+++ b/page/page.go
@@ -1,48 +1,51 @@
/*
* Copyright © 2018-2019, 2021 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
// package page provides mechanisms and data for generating a WriteFreely page.
package page
import (
"github.com/writefreely/writefreely/config"
"strings"
)
type StaticPage struct {
// App configuration
config.AppCfg
Version string
HeaderNav bool
CustomCSS bool
// Request values
Path string
Username string
Values map[string]string
Flashes []string
CanViewReader bool
IsAdmin bool
CanInvite bool
+
+ Locales string
+ Tr func (str string, ParamsToTranslate ...interface{}) interface{}
}
// SanitizeHost alters the StaticPage to contain a real hostname. This is
// especially important for the Tor hidden service, as it can be served over
// proxies, messing up the apparent hostname.
func (sp *StaticPage) SanitizeHost(cfg *config.Config) {
if cfg.Server.HiddenHost != "" && strings.HasPrefix(sp.Host, cfg.Server.HiddenHost) {
sp.Host = cfg.Server.HiddenHost
}
}
func (sp StaticPage) OfficialVersion() string {
p := strings.Split(sp.Version, "-")
return p[0]
}
diff --git a/pages.go b/pages.go
index 8b3a987..efa0fbf 100644
--- a/pages.go
+++ b/pages.go
@@ -1,164 +1,176 @@
/*
* Copyright © 2018-2019, 2021 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"database/sql"
"github.com/writefreely/writefreely/config"
"time"
)
var defaultPageUpdatedTime = time.Date(2018, 11, 8, 12, 0, 0, 0, time.Local)
func getAboutPage(app *App) (*instanceContent, error) {
c, err := app.db.GetDynamicContent("about")
if err != nil {
return nil, err
}
if c == nil {
c = &instanceContent{
ID: "about",
Type: "page",
Content: defaultAboutPage(app.cfg),
}
}
if !c.Title.Valid {
c.Title = defaultAboutTitle(app.cfg)
}
return c, nil
}
func defaultAboutTitle(cfg *config.Config) sql.NullString {
- return sql.NullString{String: "About " + cfg.App.SiteName, Valid: true}
+ var setLang = localize(cfg.App.Lang)
+ title := setLang.Get("About %s", cfg.App.SiteName)
+ return sql.NullString{String: title, Valid: true}
}
func getPrivacyPage(app *App) (*instanceContent, error) {
c, err := app.db.GetDynamicContent("privacy")
if err != nil {
return nil, err
}
if c == nil {
c = &instanceContent{
ID: "privacy",
Type: "page",
Content: defaultPrivacyPolicy(app.cfg),
Updated: defaultPageUpdatedTime,
}
}
if !c.Title.Valid {
- c.Title = defaultPrivacyTitle()
+ c.Title = defaultPrivacyTitle(app.cfg)
}
return c, nil
}
-func defaultPrivacyTitle() sql.NullString {
- return sql.NullString{String: "Privacy Policy", Valid: true}
+func defaultPrivacyTitle(cfg *config.Config) sql.NullString {
+ var setLang = localize(cfg.App.Lang)
+ title := setLang.Get("Privacy Policy");
+ return sql.NullString{String: title, Valid: true}
}
func defaultAboutPage(cfg *config.Config) string {
+ var setLang = localize(cfg.App.Lang)
+ wf_link := "[WriteFreely](https://writefreely.org)"
+ content := setLang.Get("_%s_ is a place for you to write and publish, powered by %s.", cfg.App.SiteName, wf_link)
if cfg.App.Federation {
- return `_` + cfg.App.SiteName + `_ is an interconnected place for you to write and publish, powered by [WriteFreely](https://writefreely.org) and ActivityPub.`
+ content := setLang.Get("_%s_ is an interconnected place for you to write and publish, powered by %s.", cfg.App.SiteName, wf_link)
+ return content
}
- return `_` + cfg.App.SiteName + `_ is a place for you to write and publish, powered by [WriteFreely](https://writefreely.org).`
+ return content
}
func defaultPrivacyPolicy(cfg *config.Config) string {
- return `[WriteFreely](https://writefreely.org), the software that powers this site, is built to enforce your right to privacy by default.
+ var setLang = localize(cfg.App.Lang)
+ wf_link := "[WriteFreely](https://writefreely.org)"
+ bold_site_name := "**"+cfg.App.SiteName+"**"
-It retains as little data about you as possible, not even requiring an email address to sign up. However, if you _do_ give us your email address, it is stored encrypted in our database. We salt and hash your account's password.
-
-We store log files, or data about what happens on our servers. We also use cookies to keep you logged in to your account.
-
-Beyond this, it's important that you trust whoever runs **` + cfg.App.SiteName + `**. Software can only do so much to protect you -- your level of privacy protections will ultimately fall on the humans that run this particular service.`
+ DefPrivPolString := setLang.Get("%s, the software that powers this site, is built to enforce your right to privacy by default.\n\nIt retains as little data about you as possible, not even requiring an email address to sign up. However, if you _do_ give us your email address, it is stored encrypted in our database.\n\nWe salt and hash your account's password.We store log files, or data about what happens on our servers. We also use cookies to keep you logged in to your account.\n\nBeyond this, it's important that you trust whoever runs %s. Software can only do so much to protect you -- your level of privacy protections will ultimately fall on the humans that run this particular service.", wf_link, bold_site_name);
+ return DefPrivPolString
}
func getLandingBanner(app *App) (*instanceContent, error) {
c, err := app.db.GetDynamicContent("landing-banner")
if err != nil {
return nil, err
}
if c == nil {
c = &instanceContent{
ID: "landing-banner",
Type: "section",
Content: defaultLandingBanner(app.cfg),
Updated: defaultPageUpdatedTime,
}
}
return c, nil
}
func getLandingBody(app *App) (*instanceContent, error) {
c, err := app.db.GetDynamicContent("landing-body")
if err != nil {
return nil, err
}
if c == nil {
c = &instanceContent{
ID: "landing-body",
Type: "section",
Content: defaultLandingBody(app.cfg),
Updated: defaultPageUpdatedTime,
}
}
return c, nil
}
func defaultLandingBanner(cfg *config.Config) string {
+ //var setLang = localize(cfg)
+ var setLang = localize(cfg.App.Lang)
+ banner := setLang.Get("# Start your blog")
if cfg.App.Federation {
- return "# Start your blog in the fediverse"
+ banner := setLang.Get("# Start your blog in the fediverse")
+ return banner
}
- return "# Start your blog"
+ return banner
}
func defaultLandingBody(cfg *config.Config) string {
+ var setLang = localize(cfg.App.Lang)
+ pixelfed := "[PixelFed](https://pixelfed.org)"
+ mastodon := "[Mastodon](https://joinmastodon.org)"
+ content1 := setLang.Get("## Join the Fediverse\n\nThe fediverse is a large network of platforms that all speak a common language. Imagine if you could reply to _Instagram_ posts from _Twitter_, or interact with your favorite _Medium_ blogs from _Facebook_ -- federated alternatives like %s, %s, and WriteFreely enable you to do these types of things.\n\n",pixelfed, mastodon)
+ iframe := `<div style="text-align:center">
+ <iframe style="width: 560px; height: 315px; max-width: 100%;" sandbox="allow-same-origin allow-scripts" src="https://video.writeas.org/videos/embed/cc55e615-d204-417c-9575-7b57674cc6f3" frameborder="0" allowfullscreen></iframe>
+ </div>`
+ content2 := setLang.Get("## Write More Socially\n\nWriteFreely can communicate with other federated platforms like _Mastodon_, so people can follow your blogs, bookmark their favorite posts, and boost them to their followers. Sign up above to create a blog and join the fediverse.")
if cfg.App.Federation {
- return `## Join the Fediverse
-
-The fediverse is a large network of platforms that all speak a common language. Imagine if you could reply to Instagram posts from Twitter, or interact with your favorite Medium blogs from Facebook -- federated alternatives like [PixelFed](https://pixelfed.org), [Mastodon](https://joinmastodon.org), and WriteFreely enable you to do these types of things.
-
-<div style="text-align:center">
- <iframe style="width: 560px; height: 315px; max-width: 100%;" sandbox="allow-same-origin allow-scripts" src="https://video.writeas.org/videos/embed/cc55e615-d204-417c-9575-7b57674cc6f3" frameborder="0" allowfullscreen></iframe>
-</div>
-
-## Write More Socially
-
-WriteFreely can communicate with other federated platforms like Mastodon, so people can follow your blogs, bookmark their favorite posts, and boost them to their followers. Sign up above to create a blog and join the fediverse.`
+ return content1 + "\n\n" + iframe + "\n\n" + content2
}
return ""
}
func getReaderSection(app *App) (*instanceContent, error) {
c, err := app.db.GetDynamicContent("reader")
if err != nil {
return nil, err
}
if c == nil {
c = &instanceContent{
ID: "reader",
Type: "section",
Content: defaultReaderBanner(app.cfg),
Updated: defaultPageUpdatedTime,
}
}
if !c.Title.Valid {
c.Title = defaultReaderTitle(app.cfg)
}
return c, nil
}
func defaultReaderTitle(cfg *config.Config) sql.NullString {
- return sql.NullString{String: "Reader", Valid: true}
+ var setLang = localize(cfg.App.Lang)
+ title := setLang.Get("Reader")
+ return sql.NullString{String: title, Valid: true}
}
func defaultReaderBanner(cfg *config.Config) string {
- return "Read the latest posts from " + cfg.App.SiteName + "."
+ var setLang = localize(cfg.App.Lang)
+ return setLang.Get("Read the latest posts form %s.", cfg.App.SiteName)
}
diff --git a/pages/404-general.tmpl b/pages/404-general.tmpl
index dfc4653..f954d3c 100644
--- a/pages/404-general.tmpl
+++ b/pages/404-general.tmpl
@@ -1,7 +1,7 @@
-{{define "head"}}<title>Page not found &mdash; {{.SiteName}}</title>{{end}}
+{{define "head"}}<title>{{call .Tr "Page not found"}} &mdash; {{.SiteName}}</title>{{end}}
{{define "content"}}
<div class="error-page">
- <p class="msg">This page is missing.</p>
- <p>Are you sure it was ever here?</p>
+ <p class="msg">{{call .Tr "This page is missing."}}</p>
+ <p>{{call .Tr "Are you sure it was ever here?"}}</p>
</div>
{{end}}
diff --git a/pages/404.tmpl b/pages/404.tmpl
index b103e27..46337e0 100644
--- a/pages/404.tmpl
+++ b/pages/404.tmpl
@@ -1,10 +1,11 @@
-{{define "head"}}<title>Post not found &mdash; {{.SiteName}}</title>{{end}}
+{{define "head"}}<title>{{call .Tr "Post not found"}} &mdash; {{.SiteName}}</title>{{end}}
{{define "content"}}
<div class="error-page" style="max-width:30em">
- <p class="msg">Post not found.</p>
+ <p class="msg">{{call .Tr "Post not found"}}.</p>
{{if and (not .SingleUser) .OpenRegistration}}
- <p class="commentary" style="margin-top:2.5em">Why not share a thought of your own?</p>
- <p><a href="/">Start a blog</a> and spread your ideas on <strong>{{.SiteName}}</strong>, a simple{{if .Federation}}, federated{{end}} blogging community.</p>
+ <p class="commentary" style="margin-top:2.5em">{{call .Tr "Why not share a thought of your own?"}}</p>
+ {{$str := print "a simple blogging community"}}{{if .Federation}}{{$str = print "a simple, federated blogging community"}}{{end}}
+ <p>{{call .Tr "%s and spread your ideas on **%s**, %s." true (variables "Start a blog;/" .SiteName $str)}}</p>
{{end}}
</div>
{{end}}
diff --git a/pages/410.tmpl b/pages/410.tmpl
index 5dfd4a4..1593a60 100644
--- a/pages/410.tmpl
+++ b/pages/410.tmpl
@@ -1,7 +1,7 @@
-{{define "head"}}<title>Unpublished &mdash; {{.SiteName}}</title>{{end}}
+{{define "head"}}<title>{{call .Tr "Unpublished"}} &mdash; {{.SiteName}}</title>{{end}}
{{define "content"}}
<div class="error-page">
- <p class="msg">{{if .Content}}{{.Content}}{{else}}Post was unpublished by the author.{{end}}</p>
- <p class="commentary">It might be back some day.</p>
+ <p class="msg">{{if .Content}}{{.Content}}{{else}}{{call .Tr "Post was unpublished by the author."}}{{end}}</p>
+ <p class="commentary">{{call .Tr "It might be back some day."}}</p>
</div>
{{end}}
diff --git a/pages/about.tmpl b/pages/about.tmpl
index 7c502dc..77c8b6e 100644
--- a/pages/about.tmpl
+++ b/pages/about.tmpl
@@ -1,29 +1,32 @@
{{define "head"}}<title>{{.ContentTitle}} &mdash; {{.SiteName}}</title>
<meta name="description" content="{{.PlainContent}}">
{{end}}
{{define "content"}}
<div class="content-container snug">
<h1>{{.ContentTitle}}</h1>
{{.Content}}
{{if .PublicStats}}
<hr style="margin:1.5em 0;" />
- <p><em>{{.SiteName}}</em> is home to <strong>{{largeNumFmt .AboutStats.NumPosts}}</strong> {{pluralize "article" "articles" .AboutStats.NumPosts}} across <strong>{{largeNumFmt .AboutStats.NumBlogs}}</strong> {{pluralize "blog" "blogs" .AboutStats.NumBlogs}}.</p>
+ {{ $POSTS := .AboutStats.NumPosts }}{{ if eq .AppCfg.Lang "eu_ES" }}{{ $POSTS = 1}}{{ end }}
+ {{ $BLOGS := .AboutStats.NumBlogs }}{{ if eq .AppCfg.Lang "eu_ES" }}{{ $BLOGS = 1}}{{ end }}
+ {{ $A := call .Tr "article" $POSTS }}{{ $B := call .Tr "Blog" $BLOGS | tolower }}
+ <p>{{call .Tr "_%s_ is home to **%d** %s across **%d** %s." true (variables .SiteName .AboutStats.NumPosts $A .AboutStats.NumBlogs $B )}}</p>
{{end}}
{{if not .WFModesty}}
- <h2 style="margin-top:2em">About WriteFreely</h2>
- <p><a href="https://writefreely.org">WriteFreely</a> is a self-hosted, decentralized blogging platform for publishing beautiful, simple blogs.</p>
- <p>It lets you publish a single blog, or host a community of writers who can create multiple blogs under one account. You can also enable federation, which allows people in the fediverse to follow your blog, bookmark your posts, and share them with others.</p>
+ <h2 style="margin-top:2em">{{call .Tr "About WriteFreely"}}</h2>
+ <p>{{call .Tr "%s is a self-hosted, decentralized blogging platform for publishing beautiful, simple blogs." true (variables "WriteFreely;https://writefreely.org/")}}</p>
+ <p>{{call .Tr "It lets you publish a single blog, or host a community of writers who can create multiple blogs under one account. You can also enable federation, which allows people in the fediverse to follow your blog, bookmark your posts, and share them with others."}}</p>
<div class="clearfix blurbs" style="font-size: 1.3em;text-align:center">
<div class="half big">
- <p><a href="https://writefreely.org/start">Start an instance</a></p>
+ <p><a href="https://writefreely.org/start">{{call .Tr "Start an instance"}}</a></p>
</div>
<div class="half big">
<p><a href="https://writefreely.org">WriteFreely</a></p>
</div>
</div>
{{end}}
</div>
{{end}}
diff --git a/pages/landing.tmpl b/pages/landing.tmpl
index 2131b40..7ef914a 100644
--- a/pages/landing.tmpl
+++ b/pages/landing.tmpl
@@ -1,203 +1,206 @@
{{define "head"}}
<title>{{.SiteName}}</title>
<style type="text/css">
h2 {
font-weight: normal;
}
#pricing.content-container div.form-container #payment-form {
display: block !important;
}
#pricing #signup-form table {
max-width: inherit !important;
width: 100%;
}
#pricing #payment-form table {
margin-top: 0 !important;
max-width: inherit !important;
width: 100%;
}
tr.subscription {
border-spacing: 0;
}
#pricing.content-container tr.subscription button {
margin-top: 0 !important;
margin-bottom: 0 !important;
width: 100%;
}
#pricing tr.subscription td {
padding: 0 0.5em;
}
#pricing table.billing > tbody > tr > td:first-child {
vertical-align: middle !important;
}
.billing-section {
display: none;
}
.billing-section.bill-me {
display: table-row;
}
#btn-create {
color: white !important;
}
#total-price {
padding-left: 0.5em;
}
#alias-site.demo {
color: #999;
}
#alias-site {
text-align: left;
margin: 0.5em 0;
}
form dd {
margin: 0;
}
.banner-container {
text-align: left;
}
.banner-container h1 {
margin-top: 0;
max-width: 8em;
}
.or {
margin-bottom: 2.5em !important;
}
+label{
+ text-transform: capitalize;
+}
</style>
{{end}}
{{define "content"}}
<div id="pricing" class="content-container wide-form">
<div class="row">
<div class="banner-container">
{{.Banner}}
- <p><a href="{{if .Content}}#more{{else}}/about{{end}}">Learn more...</a></p>
+ <p><a href="{{if .Content}}#more{{else}}/about{{end}}">{{call .Tr "Learn more..."}}</a></p>
</div>
<div{{if not .OpenRegistration}} style="padding: 2em 0;"{{end}}>
{{ if .OpenRegistration }}
{{template "oauth-buttons" .}}
{{if not .DisablePasswordAuth}}
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
<div id="billing">
<form action="/auth/signup" method="POST" id="signup-form" onsubmit="return signup()">
<dl class="billing">
<label>
- <dt>Username</dt>
+ <dt>{{call .Tr "Username"}}</dt>
<dd>
<input type="text" id="alias" name="alias" style="width: 100%; box-sizing: border-box;" tabindex="1" autofocus {{if .ForcedLanding}}disabled{{end}} />
- {{if .Federation}}<p id="alias-site" class="demo">@<strong>your-username</strong>@{{.FriendlyHost}}</p>{{else}}<p id="alias-site" class="demo">{{.FriendlyHost}}/<strong>your-username</strong></p>{{end}}
+ {{if .Federation}}<p id="alias-site" class="demo">@<strong>{{call .Tr "your-username"}}</strong>@{{.FriendlyHost}}</p>{{else}}<p id="alias-site" class="demo">{{.FriendlyHost}}/<strong>{{call .Tr "your-username"}}</strong></p>{{end}}
</dd>
</label>
<label>
- <dt>Password</dt>
+ <dt>{{call .Tr "password"}}</dt>
<dd><input type="password" id="password" name="pass" autocomplete="new-password" placeholder="" tabindex="2" style="width: 100%; box-sizing: border-box;" {{if .ForcedLanding}}disabled{{end}} /></dd>
</label>
<label>
- <dt>Email (optional)</dt>
+ <dt>{{call .Tr "Email"}} ({{call .Tr "optional"}})</dt>
<dd><input type="email" name="email" id="email" style="letter-spacing: 1px; width: 100%; box-sizing: border-box;" placeholder="me@example.com" tabindex="3" {{if .ForcedLanding}}disabled{{end}} /></dd>
</label>
<dt>
- <button id="btn-create" type="submit" style="margin-top: 0" {{if .ForcedLanding}}disabled{{end}}>Create blog</button>
+ <button id="btn-create" type="submit" style="margin-top: 0" {{if .ForcedLanding}}disabled{{end}}>{{call .Tr "Create blog"}}</button>
</dt>
</dl>
</form>
</div>
{{end}}
{{ else }}
- <p style="font-size: 1.3em; margin: 1rem 0;">Registration is currently closed.</p>
- <p>You can always sign up on <a href="https://writefreely.org/instances">another instance</a>.</p>
+ <p style="font-size: 1.3em; margin: 1rem 0;">{{call .Tr "Registration is currently closed."}}</p>
+ <p>{{call .Tr "You can always sign up on %s." true (variables "another instance;https://writefreely.org/instances")}}</p>
{{ end }}
</div>
</div>
{{if .Content}}
<a name="more"></a><hr style="margin: 1em auto 3em;" />
{{end}}
</div>
{{ if .Content }}
<div class="content-container snug">
{{.Content}}
</div>
{{ end }}
<script type="text/javascript" src="/js/h.js"></script>
<script type="text/javascript">
function signup() {
var $pass = document.getElementById('password');
// Validate input
if (!aliasOK) {
var $a = $alias;
$a.el.className = 'error';
$a.el.focus();
$a.el.scrollIntoView();
return false;
}
if ($pass.value == "") {
var $a = $pass;
$a.className = 'error';
$a.focus();
$a.scrollIntoView();
return false;
}
var $btn = document.getElementById('btn-create');
$btn.disabled = true;
$btn.value = 'Creating...';
return true;
}
var $alias = H.getEl('alias');
var $aliasSite = document.getElementById('alias-site');
var aliasOK = true;
var typingTimer;
var doneTypingInterval = 750;
var doneTyping = function() {
// Check on username
var alias = $alias.el.value;
if (alias != "") {
var params = {
username: alias
};
var http = new XMLHttpRequest();
http.open("POST", '/api/alias', true);
// Send the proper header information along with the request
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
data = JSON.parse(http.responseText);
if (http.status == 200) {
aliasOK = true;
$alias.removeClass('error');
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)demo(?!\S)/g, '');
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)error(?!\S)/g, '');
$aliasSite.innerHTML = '{{ if .Federation }}@<strong>' + data.data + '</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>' + data.data + '</strong>/{{ end }}';
} else {
aliasOK = false;
$alias.setClass('error');
$aliasSite.className = 'error';
$aliasSite.textContent = data.error_msg;
}
}
}
http.send(JSON.stringify(params));
} else {
$aliasSite.className += ' demo';
- $aliasSite.innerHTML = '{{ if .Federation }}@<strong>your-username</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>your-username</strong>/{{ end }}';
+ $aliasSite.innerHTML = '{{ if .Federation }}@<strong>{{call .Tr "your-username"}}</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>{{call .Tr "your-username"}}</strong>/{{ end }}';
}
};
$alias.on('keyup input', function() {
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
});
</script>
{{end}}
diff --git a/pages/login.tmpl b/pages/login.tmpl
index f0a54eb..9d78a50 100644
--- a/pages/login.tmpl
+++ b/pages/login.tmpl
@@ -1,36 +1,37 @@
{{define "head"}}<title>Log in &mdash; {{.SiteName}}</title>
<meta name="description" content="Log in to {{.SiteName}}.">
<meta itemprop="description" content="Log in to {{.SiteName}}.">
<style>
input{margin-bottom:0.5em;}
+::placeholder {text-transform:capitalize;}
</style>
{{end}}
{{define "content"}}
<div class="tight content-container">
- <h1>Log in to {{.SiteName}}</h1>
+ <h1>{{call .Tr "Log in to %s" (variables .SiteName)}}</h1>
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
{{template "oauth-buttons" .}}
{{if not .DisablePasswordAuth}}
<form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
- <input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br />
- <input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br />
+ <input type="text" name="alias" placeholder={{call .Tr "Username"}} value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br />
+ <input type="password" name="pass" placeholder={{call .Tr "password"}} {{if .LoginUsername}}autofocus{{end}} /><br />
{{if .To}}<input type="hidden" name="to" value="{{.To}}" />{{end}}
- <input type="submit" id="btn-login" value="Login" />
+ <input type="submit" id="btn-login" value={{call .Tr "Log in"}} />
</form>
- {{if and (not .SingleUser) .OpenRegistration}}<p style="text-align:center;font-size:0.9em;margin:3em auto;max-width:26em;">{{if .Message}}{{.Message}}{{else}}<em>No account yet?</em> <a href="{{.SignupPath}}">Sign up</a> to start a blog.{{end}}</p>{{end}}
+ {{if and (not .SingleUser) .OpenRegistration}}<p style="text-align:center;font-size:0.9em;margin:3em auto;max-width:26em;">{{if .Message}}{{.Message}}{{else}}{{call .Tr "_No account yet?_ %s to start a blog." true (variables "Sign up; .SignupPath")}}{{end}}</p>{{end}}
<script type="text/javascript">
function disableSubmit() {
var $btn = document.getElementById("btn-login");
- $btn.value = "Logging in...";
+ $btn.value = {{call .Tr "Logging in..."}};
$btn.disabled = true;
}
</script>
{{end}}
{{end}}
diff --git a/pages/privacy.tmpl b/pages/privacy.tmpl
index 578472a..d893a99 100644
--- a/pages/privacy.tmpl
+++ b/pages/privacy.tmpl
@@ -1,10 +1,11 @@
{{define "head"}}<title>{{.ContentTitle}} &mdash; {{.SiteName}}</title>
<meta name="description" content="{{.PlainContent}}">
{{end}}
{{define "content"}}<div class="content-container snug">
<h1>{{.ContentTitle}}</h1>
- <p style="font-style:italic">Last updated {{.Updated}}</p>
+ <p style="font-style:italic">{{call .Tr "Last updated"}}, <time datetime="{{.Updated}}" content="{{.Updated}}"></time></p>
{{.Content}}
</div>
+ <script src="/js/localdate.js"></script>
{{end}}
diff --git a/pages/signup-oauth.tmpl b/pages/signup-oauth.tmpl
index fcd70d2..f16b7e6 100644
--- a/pages/signup-oauth.tmpl
+++ b/pages/signup-oauth.tmpl
@@ -1,186 +1,186 @@
{{define "head"}}<title>Finish Creating Account &mdash; {{.SiteName}}</title>
<style>input{margin-bottom:0.5em;}</style>
<style type="text/css">
h2 {
font-weight: normal;
}
#pricing.content-container div.form-container #payment-form {
display: block !important;
}
#pricing #signup-form table {
max-width: inherit !important;
width: 100%;
}
#pricing #payment-form table {
margin-top: 0 !important;
max-width: inherit !important;
width: 100%;
}
tr.subscription {
border-spacing: 0;
}
#pricing.content-container tr.subscription button {
margin-top: 0 !important;
margin-bottom: 0 !important;
width: 100%;
}
#pricing tr.subscription td {
padding: 0 0.5em;
}
#pricing table.billing > tbody > tr > td:first-child {
vertical-align: middle !important;
}
.billing-section {
display: none;
}
.billing-section.bill-me {
display: table-row;
}
#btn-create {
color: white !important;
}
#total-price {
padding-left: 0.5em;
}
#alias-site.demo {
color: #999;
}
#alias-site {
text-align: left;
margin: 0.5em 0;
}
form dd {
margin: 0;
}
</style>
{{end}}
{{define "content"}}
<div id="pricing" class="tight content-container">
- <h1>Finish creating account</h1>
+ <h1>{{call .Tr "Finish creating account"}}</h1>
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
<div id="billing">
<form action="/oauth/signup" method="post" style="text-align: center;margin-top:1em;" onsubmit="return disableSubmit()">
<input type="hidden" name="access_token" value="{{ .AccessToken }}" />
<input type="hidden" name="token_username" value="{{ .TokenUsername }}" />
<input type="hidden" name="token_alias" value="{{ .TokenAlias }}" />
<input type="hidden" name="token_email" value="{{ .TokenEmail }}" />
<input type="hidden" name="token_remote_user" value="{{ .TokenRemoteUser }}" />
<input type="hidden" name="provider" value="{{ .Provider }}" />
<input type="hidden" name="client_id" value="{{ .ClientID }}" />
<input type="hidden" name="signature" value="{{ .TokenHash }}" />
{{if .InviteCode}}<input type="hidden" name="invite_code" value="{{ .InviteCode }}" />{{end}}
<dl class="billing">
<label>
- <dt>Display Name</dt>
+ <dt>{{call .Tr "Display Name"}}</dt>
<dd>
<input type="text" style="width: 100%; box-sizing: border-box;" name="alias" placeholder="Name"{{ if .Alias }} value="{{.Alias}}"{{ end }} />
</dd>
</label>
<label>
- <dt>Username</dt>
+ <dt>{{call .Tr "Username"}}</dt>
<dd>
- <input type="text" id="username" name="username" style="width: 100%; box-sizing: border-box;" placeholder="Username" value="{{.LoginUsername}}" /><br />
- {{if .Federation}}<p id="alias-site" class="demo">@<strong>your-username</strong>@{{.FriendlyHost}}</p>{{else}}<p id="alias-site" class="demo">{{.FriendlyHost}}/<strong>your-username</strong></p>{{end}}
+ <input type="text" id="username" name="username" style="width: 100%; box-sizing: border-box;" placeholder={{call .Tr "Username"}} value="{{.LoginUsername}}" /><br />
+ {{if .Federation}}<p id="alias-site" class="demo">@<strong>{{call .Tr "your-username"}}</strong>@{{.FriendlyHost}}</p>{{else}}<p id="alias-site" class="demo">{{.FriendlyHost}}/<strong>{{call .Tr "your-username"}}</strong></p>{{end}}
</dd>
</label>
<label>
- <dt>Email</dt>
+ <dt>{{call .Tr "Email"}}</dt>
<dd>
-<input type="text" name="email" style="width: 100%; box-sizing: border-box;" placeholder="Email"{{ if .Email }} value="{{.Email}}"{{ end }} />
+<input type="text" name="email" style="width: 100%; box-sizing: border-box;" placeholder={{call .Tr "Email"}}{{ if .Email }} value="{{.Email}}"{{ end }} />
</dd>
</label>
<dt>
<input type="submit" id="btn-login" value="Next" />
</dt>
</dl>
</form>
</div>
<script type="text/javascript" src="/js/h.js"></script>
<script type="text/javascript">
// Copied from signup.tmpl
// NOTE: this element is named "alias" on signup.tmpl and "username" here
var $alias = H.getEl('username');
function disableSubmit() {
// Validate input
if (!aliasOK) {
var $a = $alias;
$a.el.className = 'error';
$a.el.focus();
$a.el.scrollIntoView();
return false;
}
var $btn = document.getElementById("btn-login");
$btn.value = "Logging in...";
$btn.disabled = true;
return true;
}
// Copied from signup.tmpl
var $aliasSite = document.getElementById('alias-site');
var aliasOK = true;
var typingTimer;
var doneTypingInterval = 750;
var doneTyping = function(genID) {
// Check on username
var alias = $alias.el.value;
if (alias != "") {
var params = {
username: alias
};
var http = new XMLHttpRequest();
http.open("POST", '/api/alias', true);
// Send the proper header information along with the request
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
data = JSON.parse(http.responseText);
if (http.status == 200) {
aliasOK = true;
$alias.removeClass('error');
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)demo(?!\S)/g, '');
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)error(?!\S)/g, '');
$aliasSite.innerHTML = '{{ if .Federation }}@<strong>' + data.data + '</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>' + data.data + '</strong>/{{ end }}';
} else {
if (genID === true) {
$alias.el.value = alias + "-" + randStr(4);
doneTyping();
return;
}
aliasOK = false;
$alias.setClass('error');
$aliasSite.className = 'error';
$aliasSite.textContent = data.error_msg;
}
}
}
http.send(JSON.stringify(params));
} else {
$aliasSite.className += ' demo';
$aliasSite.innerHTML = '{{ if .Federation }}@<strong>your-username</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>your-username</strong>/{{ end }}';
}
};
$alias.on('keyup input', function() {
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
});
function randStr(len) {
var res = '';
var chars = '23456789bcdfghjklmnpqrstvwxyz';
for (var i=0; i<len; i++) {
res += chars.charAt(Math.floor(Math.random() * chars.length));
}
return res;
}
doneTyping(true);
</script>
{{end}}
diff --git a/pages/signup.tmpl b/pages/signup.tmpl
index b1bb50d..ee9652a 100644
--- a/pages/signup.tmpl
+++ b/pages/signup.tmpl
@@ -1,180 +1,180 @@
{{define "head"}}
<title>Sign up &mdash; {{.SiteName}}</title>
<style type="text/css">
h2 {
font-weight: normal;
}
#pricing.content-container div.form-container #payment-form {
display: block !important;
}
#pricing #signup-form table {
max-width: inherit !important;
width: 100%;
}
#pricing #payment-form table {
margin-top: 0 !important;
max-width: inherit !important;
width: 100%;
}
tr.subscription {
border-spacing: 0;
}
#pricing.content-container tr.subscription button {
margin-top: 0 !important;
margin-bottom: 0 !important;
width: 100%;
}
#pricing tr.subscription td {
padding: 0 0.5em;
}
#pricing table.billing > tbody > tr > td:first-child {
vertical-align: middle !important;
}
.billing-section {
display: none;
}
.billing-section.bill-me {
display: table-row;
}
#btn-create {
color: white !important;
}
#total-price {
padding-left: 0.5em;
}
#alias-site.demo {
color: #999;
}
#alias-site {
text-align: left;
margin: 0.5em 0;
}
form dd {
margin: 0;
}
</style>
{{end}}
{{define "content"}}
<div id="pricing" class="content-container wide-form">
<div class="row">
<div style="margin: 0 auto; max-width: 25em;">
<h1>Sign up</h1>
{{ if .Error }}
<p style="font-style: italic">{{.Error}}</p>
{{ else }}
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
<div id="billing">
{{template "oauth-buttons" .}}
{{if not .DisablePasswordAuth}}
<form action="/auth/signup" method="POST" id="signup-form" onsubmit="return signup()">
<input type="hidden" name="invite_code" value="{{.Invite}}" />
<dl class="billing">
<label>
- <dt>Username</dt>
+ <dt>{{call .Tr "Username"}}</dt>
<dd>
<input type="text" id="alias" name="alias" style="width: 100%; box-sizing: border-box;" tabindex="1" autofocus />
{{if .Federation}}<p id="alias-site" class="demo">@<strong>your-username</strong>@{{.FriendlyHost}}</p>{{else}}<p id="alias-site" class="demo">{{.FriendlyHost}}/<strong>your-username</strong></p>{{end}}
</dd>
</label>
<label>
- <dt>Password</dt>
+ <dt style="text-transform:capitalize;">{{call .Tr "Password"}}</dt>
<dd><input type="password" id="password" name="pass" autocomplete="new-password" placeholder="" tabindex="2" style="width: 100%; box-sizing: border-box;" /></dd>
</label>
<label>
- <dt>Email (optional)</dt>
+ <dt>{{call .Tr "Email"}} ({{call .Tr "optional"}})</dt>
<dd><input type="email" name="email" id="email" style="letter-spacing: 1px; width: 100%; box-sizing: border-box;" placeholder="me@example.com" tabindex="3" /></dd>
</label>
<dt>
- <button id="btn-create" type="submit" style="margin-top: 0">Create blog</button>
+ <button id="btn-create" type="submit" style="margin-top: 0">{{call .Tr "Create blog"}}</button>
</dt>
</dl>
</form>
{{end}}
</div>
{{ end }}
</div>
</div>
<script type="text/javascript" src="/js/h.js"></script>
<script type="text/javascript">
function signup() {
var $pass = document.getElementById('password');
// Validate input
if (!aliasOK) {
var $a = $alias;
$a.el.className = 'error';
$a.el.focus();
$a.el.scrollIntoView();
return false;
}
if ($pass.value == "") {
var $a = $pass;
$a.className = 'error';
$a.focus();
$a.scrollIntoView();
return false;
}
var $btn = document.getElementById('btn-create');
$btn.disabled = true;
$btn.value = 'Creating...';
return true;
}
var $alias = H.getEl('alias');
var $aliasSite = document.getElementById('alias-site');
var aliasOK = true;
var typingTimer;
var doneTypingInterval = 750;
var doneTyping = function() {
// Check on username
var alias = $alias.el.value;
if (alias != "") {
var params = {
username: alias
};
var http = new XMLHttpRequest();
http.open("POST", '/api/alias', true);
// Send the proper header information along with the request
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
data = JSON.parse(http.responseText);
if (http.status == 200) {
aliasOK = true;
$alias.removeClass('error');
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)demo(?!\S)/g, '');
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)error(?!\S)/g, '');
$aliasSite.innerHTML = '{{ if .Federation }}@<strong>' + data.data + '</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>' + data.data + '</strong>/{{ end }}';
} else {
aliasOK = false;
$alias.setClass('error');
$aliasSite.className = 'error';
$aliasSite.textContent = data.error_msg;
}
}
}
http.send(JSON.stringify(params));
} else {
$aliasSite.className += ' demo';
$aliasSite.innerHTML = '{{ if .Federation }}@<strong>your-username</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>your-username</strong>/{{ end }}';
}
};
$alias.on('keyup input', function() {
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
});
</script>
{{end}}
diff --git a/static/js/localdate.js b/static/js/localdate.js
index 879ebe4..57dfddd 100644
--- a/static/js/localdate.js
+++ b/static/js/localdate.js
@@ -1,16 +1,77 @@
-function toLocalDate(dateEl, displayEl) {
+//localdate.js
+//Modified for correctly formating the dates in Basque language(while some errors are fixed on "unicode-org/cldr"
+// https://github.com/unicode-org/cldr/blob/main/common/main/eu.xml): 2022/08/16
+
+function toLocalDate(dateEl, displayEl, longFormat) {
var d = new Date(dateEl.getAttribute("datetime"));
- displayEl.textContent = d.toLocaleDateString(navigator.language || "en-US", { year: 'numeric', month: 'long', day: 'numeric' });
+
+ //displayEl.textContent = d.toLocaleDateString(navigator.language || "en-US", { year: 'numeric', month: 'long', day: 'numeric' });
+ if(longFormat){
+ var dateString = d.toLocaleDateString(navigator.language || "en-US", { year: 'numeric', month: 'long', day: 'numeric' , hour: 'numeric', minute: 'numeric'});
+ }else{
+ var dateString = d.toLocaleDateString(navigator.language || "en-US", { year: 'numeric', month: 'long', day: 'numeric' });
+ }
+
+ function euFormat(data){
+ var hk = [
+ "urtarrila", "otsaila", "martxoa",
+ "apirila", "maiatza", "ekaina", "uztaila",
+ "abuztua", "iraila", "urria",
+ "azaroa", "abendua"
+ ];
+
+ var e = data.getDate();
+ var h = data.getMonth();
+ var u = data.getFullYear();
+ var or = data.getHours();
+ var min = data.getMinutes();
+ var a = (or >= 12) ? "PM" : "AM";
+
+ var lot = ((((Number(u[2])%2 == 0 && u[3] =='1') || (Number(u[2])%2 == 1 && u[3]=='0')) || u[3] == '5')?'e':'')+'ko'
+
+ if(longFormat){
+ return u + lot + ' ' + hk[h] + 'k ' + e + ', ' + or + ':' + min + ' ' + a;
+ }else{
+ return u + lot + ' ' + hk[h] + 'k ' + e;
+ }
+
+
+ }
+
+ //if lang eu or eu-ES ...
+ displayEl.textContent = navigator.language.indexOf('eu') != -1 ? euFormat(d) : dateString
+
}
// Adjust dates on individual post pages, and on posts in a list *with* an explicit title
var $dates = document.querySelectorAll("article > time");
for (var i=0; i < $dates.length; i++) {
toLocalDate($dates[i], $dates[i]);
}
// Adjust dates on posts in a list without an explicit title, where they act as the header
$dates = document.querySelectorAll("h2.post-title > time");
for (i=0; i < $dates.length; i++) {
- toLocalDate($dates[i], $dates[i].querySelector('a'));
-}
\ No newline at end of file
+ toLocalDate($dates[i], $dates[i].querySelector('a'), false);
+}
+
+// Adjust dates on drafts 2022/08/16
+$dates = document.querySelectorAll("h4 > date");
+for (var i=0; i < $dates.length; i++) {
+ $dates[i].setAttribute("datetime", $dates[i].getAttribute("datetime").split(" +")[0])
+ toLocalDate($dates[i], $dates[i], false);
+}
+
+// Adjust date on privacy page 2022/08/17
+$dates = document.querySelectorAll("p > time");
+for (var i=0; i < $dates.length; i++) {
+ toLocalDate($dates[i], $dates[i], false);
+}
+
+// Adjust date to long format on admin/user-s pages 2022/11/21
+if(location.pathname.startsWith("/admin/user")){
+ $dates = document.querySelectorAll("td > time");
+ for (var i=0; i < $dates.length; i++) {
+ toLocalDate($dates[i], $dates[i], true);
+ }
+}
diff --git a/static/js/postactions.js b/static/js/postactions.js
index 74e3223..3d0c392 100644
--- a/static/js/postactions.js
+++ b/static/js/postactions.js
@@ -1,121 +1,141 @@
var postActions = function() {
var $container = He.get('moving');
- var MultiMove = function(el, id, singleUser) {
+
+ var tr = function(term, str){
+ return term.replace("%s", str);
+ };
+ var MultiMove = function(el, id, singleUser, loc) {
var lbl = el.options[el.selectedIndex].textContent;
+ var loc = JSON.parse(loc);
var collAlias = el.options[el.selectedIndex].value;
var $lbl = He.$('label[for=move-'+id+']')[0];
- $lbl.textContent = "moving to "+lbl+"...";
+ //$lbl.textContent = loc['moving to']+" "+lbl+"...";
+ $lbl.textContent = tr(loc['moving to %s...'],lbl);
+
var params;
if (collAlias == '|anonymous|') {
params = [id];
} else {
params = [{
id: id
}];
}
var callback = function(code, resp) {
if (code == 200) {
for (var i=0; i<resp.data.length; i++) {
if (resp.data[i].code == 200) {
- $lbl.innerHTML = "moved to <strong>"+lbl+"</strong>";
+ //$lbl.innerHTML = loc["Moved to"]+" <strong>"+lbl+"</strong>";
+ $lbl.innerHTML = tr(loc["Moved to %s"], " <strong>"+lbl+"</strong>");
var pre = "/"+collAlias;
if (typeof singleUser !== 'undefined' && singleUser) {
pre = "";
}
var newPostURL = pre+"/"+resp.data[i].post.slug;
try {
// Posts page
He.$('#post-'+resp.data[i].post.id+' > h3 > a')[0].href = newPostURL;
} catch (e) {
// Blog index
var $article = He.get('post-'+resp.data[i].post.id);
$article.className = 'norm moved';
if (collAlias == '|anonymous|') {
var draftPre = "";
if (typeof singleUser !== 'undefined' && singleUser) {
draftPre = "d/";
}
- $article.innerHTML = '<p><a href="/'+draftPre+resp.data[i].post.id+'">Unpublished post</a>.</p>';
+ $article.innerHTML = '<p><a href="/'+draftPre+resp.data[i].post.id+'">'+loc["Unpublished post"]+'</a>.</p>';
} else {
- $article.innerHTML = '<p>Moved to <a style="font-weight:bold" href="'+newPostURL+'">'+lbl+'</a>.</p>';
+ //$article.innerHTML = '<p>'+loc["Moved to"]+' <a style="font-weight:bold" href="'+newPostURL+'">'+lbl+'</a>.</p>';
+ $article.innerHTML = '<p>'+ tr(loc["Moved to %s"], '<a style="font-weight:bold" href="'+newPostURL+'">'+lbl+'</a>')+'.</p>';
}
}
} else {
- $lbl.innerHTML = "unable to move: "+resp.data[i].error_msg;
+ $lbl.innerHTML = loc['unable to move']+": "+resp.data[i].error_msg;
}
}
}
};
if (collAlias == '|anonymous|') {
He.postJSON("/api/posts/disperse", params, callback);
} else {
He.postJSON("/api/collections/"+collAlias+"/collect", params, callback);
}
};
- var Move = function(el, id, collAlias, singleUser) {
+ var Move = function(el, id, collAlias, singleUser, loc) {
var lbl = el.textContent;
+ var loc = JSON.parse(loc)
+
+ /*
try {
- var m = lbl.match(/move to (.*)/);
+ //var m = lbl.match(/move to (.*)/);
+ var m = lbl.match(RegExp(loc['move to'] + "(.*)"));
lbl = m[1];
} catch (e) {
if (collAlias == '|anonymous|') {
lbl = "draft";
}
}
+ */
+ if (collAlias == '|anonymous|'){
+ lbl = loc["draft"];
+ }else{
+ lbl = collAlias
+ }
- el.textContent = "moving to "+lbl+"...";
+ el.textContent = tr(loc['moving to %s...'],lbl);
if (collAlias == '|anonymous|') {
params = [id];
} else {
params = [{
id: id
}];
}
var callback = function(code, resp) {
if (code == 200) {
for (var i=0; i<resp.data.length; i++) {
if (resp.data[i].code == 200) {
- el.innerHTML = "moved to <strong>"+lbl+"</strong>";
+ el.innerHTML = tr(loc["Moved to %s"], " <strong>"+lbl+"</strong>");
el.onclick = null;
var pre = "/"+collAlias;
if (typeof singleUser !== 'undefined' && singleUser) {
pre = "";
}
var newPostURL = pre+"/"+resp.data[i].post.slug;
el.href = newPostURL;
- el.title = "View on "+lbl;
+ el.title = tr(loc["View on %s"], lbl)
try {
// Posts page
He.$('#post-'+resp.data[i].post.id+' > h3 > a')[0].href = newPostURL;
} catch (e) {
// Blog index
var $article = He.get('post-'+resp.data[i].post.id);
$article.className = 'norm moved';
if (collAlias == '|anonymous|') {
var draftPre = "";
if (typeof singleUser !== 'undefined' && singleUser) {
draftPre = "d/";
}
- $article.innerHTML = '<p><a href="/'+draftPre+resp.data[i].post.id+'">Unpublished post</a>.</p>';
+ $article.innerHTML = '<p><a href="/'+draftPre+resp.data[i].post.id+'">'+loc["Unpublished post"]+'</a>.</p>';
} else {
- $article.innerHTML = '<p>Moved to <a style="font-weight:bold" href="'+newPostURL+'">'+lbl+'</a>.</p>';
+ $article.innerHTML = '<p>'+ tr(loc["Moved to %s"], '<a style="font-weight:bold" href="'+newPostURL+'">'+lbl+'</a>')+'.</p>';
}
}
} else {
- el.innerHTML = "unable to move: "+resp.data[i].error_msg;
+ el.innerHTML = loc['unable to move']+": "+resp.data[i].error_msg;
}
}
}
}
if (collAlias == '|anonymous|') {
He.postJSON("/api/posts/disperse", params, callback);
} else {
He.postJSON("/api/collections/"+collAlias+"/collect", params, callback);
}
+
};
return {
move: Move,
multiMove: MultiMove,
};
}();
diff --git a/static/js/posts.js b/static/js/posts.js
index dfc30b7..1f05310 100644
--- a/static/js/posts.js
+++ b/static/js/posts.js
@@ -1,332 +1,334 @@
/**
* Functionality for managing local Write.as posts.
*
* Dependencies:
* h.js
*/
+
function toggleTheme() {
var btns;
try {
btns = Array.prototype.slice.call(document.getElementById('belt').querySelectorAll('.tool img'));
} catch (e) {}
if (document.body.className == 'light') {
document.body.className = 'dark';
try {
for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('_dark@2x.png', '@2x.png');
}
} catch (e) {}
} else if (document.body.className == 'dark') {
document.body.className = 'light';
try {
for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('@2x.png', '_dark@2x.png');
}
} catch (e) {}
} else {
// Don't alter the theme
return;
}
H.set('padTheme', document.body.className);
}
if (H.get('padTheme', 'light') != 'light') {
toggleTheme();
}
var deleting = false;
-function delPost(e, id, owned) {
+function delPost(e, id, owned, loc) {
e.preventDefault();
if (deleting) {
return;
}
+ var loc = JSON.parse(loc);
// TODO: UNDO!
- if (window.confirm('Are you sure you want to delete this post?')) {
+ if (window.confirm(loc['Are you sure you want to delete this post?'])) {
var token;
for (var i=0; i<posts.length; i++) {
if (posts[i].id == id) {
token = posts[i].token;
break;
}
}
if (owned || token) {
// AJAX
deletePost(id, token, function() {
// Remove post from list
var $postEl = document.getElementById('post-' + id);
$postEl.parentNode.removeChild($postEl);
if (posts.length == 0) {
displayNoPosts();
return;
}
// Fill in full page of posts
var $postsChildren = $posts.el.getElementsByClassName('post');
if ($postsChildren.length < postsPerPage && $postsChildren.length < posts.length) {
var lastVisiblePostID = $postsChildren[$postsChildren.length-1].id;
lastVisiblePostID = lastVisiblePostID.substr(lastVisiblePostID.indexOf('-')+1);
for (var i=0; i<posts.length-1; i++) {
if (posts[i].id == lastVisiblePostID) {
var $moreBtn = document.getElementById('more-posts');
if ($moreBtn) {
// Should always land here (?)
$posts.el.insertBefore(createPostEl(posts[i-1]), $moreBtn);
} else {
$posts.el.appendChild(createPostEl(posts[i-1]));
}
}
}
}
});
} else {
alert('Something went seriously wrong. Try refreshing.');
}
}
}
var getFormattedDate = function(d) {
var mos = [
"January", "February", "March",
"April", "May", "June", "July",
"August", "September", "October",
"November", "December"
];
var day = d.getDate();
var mo = d.getMonth();
var yr = d.getFullYear();
return mos[mo] + ' ' + day + ', ' + yr;
};
var posts = JSON.parse(H.get('posts', '[]'));
var initialListPop = function() {
pages = Math.ceil(posts.length / postsPerPage);
loadPage(page, true);
};
var $posts = H.getEl("posts");
if ($posts.el == null) {
$posts = H.getEl("unsynced-posts");
}
$posts.el.innerHTML = '<p class="status">Reading...</p>';
var createMorePostsEl = function() {
var $more = document.createElement('div');
var nextPage = page+1;
$more.id = 'more-posts';
$more.innerHTML = '<p><a href="#' + nextPage + '">More...</a></p>';
return $more;
};
var localPosts = function() {
var $delPost, lastDelPost, lastInfoHTML;
var $info = He.get('unsynced-posts-info');
var findPostIdx = function(id) {
for (var i=0; i<posts.length; i++) {
if (posts[i].id == id) {
return i;
}
}
return -1;
};
var DismissError = function(e, el) {
e.preventDefault();
var $errorMsg = el.parentNode.previousElementSibling;
$errorMsg.parentNode.removeChild($errorMsg);
var $errorMsgNav = el.parentNode;
$errorMsgNav.parentNode.removeChild($errorMsgNav);
};
var DeletePostLocal = function(e, el, id) {
e.preventDefault();
if (!window.confirm('Are you sure you want to delete this post?')) {
return;
}
var i = findPostIdx(id);
if (i > -1) {
lastDelPost = posts.splice(i, 1)[0];
$delPost = H.getEl('post-'+id);
$delPost.setClass('del-undo');
var $unsyncPosts = document.getElementById('unsynced-posts');
var visible = $unsyncPosts.children.length;
for (var i=0; i < $unsyncPosts.children.length; i++) { // NOTE: *.children support in IE9+
if ($unsyncPosts.children[i].className.indexOf('del-undo') !== -1) {
visible--;
}
}
if (visible == 0) {
H.getEl('unsynced-posts-header').hide();
// TODO: fix undo functionality and don't do the following:
H.getEl('unsynced-posts-info').hide();
}
H.set('posts', JSON.stringify(posts));
// TODO: fix undo functionality and re-add
//lastInfoHTML = $info.innerHTML;
//$info.innerHTML = 'Unsynced entry deleted. <a href="#" onclick="localPosts.undoDelete()">Undo</a>.';
}
};
var UndoDelete = function() {
// TODO: fix this header reappearing
H.getEl('unsynced-posts-header').show();
$delPost.removeClass('del-undo');
$info.innerHTML = lastInfoHTML;
};
return {
dismissError: DismissError,
deletePost: DeletePostLocal,
undoDelete: UndoDelete,
};
}();
var movePostHTML = function(postID) {
let $tmpl = document.getElementById('move-tmpl');
if ($tmpl === null) {
return "";
}
return $tmpl.innerHTML.replace(/POST_ID/g, postID);
}
var createPostEl = function(post, owned) {
var $post = document.createElement('div');
let p = H.createPost(post.id, "", post.body)
var title = (post.title || p.title || post.id);
title = title.replace(/</g, "&lt;");
$post.id = 'post-' + post.id;
$post.className = 'post';
$post.innerHTML = '<h3><a href="/' + post.id + '">' + title + '</a></h3>';
var posted = "";
if (post.created) {
posted = getFormattedDate(new Date(post.created))
}
var hasDraft = H.exists('draft' + post.id);
$post.innerHTML += '<h4><date>' + posted + '</date> <a class="action" href="/pad/' + post.id + '">edit' + (hasDraft ? 'ed' : '') + '</a> <a class="delete action" href="/' + post.id + '" onclick="delPost(event, \'' + post.id + '\'' + (owned === true ? ', true' : '') + ')">delete</a> '+movePostHTML(post.id)+'</h4>';
if (post.error) {
$post.innerHTML += '<p class="error"><strong>Sync error:</strong> ' + post.error + ' <nav><a href="#" onclick="localPosts.dismissError(event, this)">dismiss</a> <a href="#" onclick="localPosts.deletePost(event, this, \''+post.id+'\')">remove post</a></nav></p>';
}
if (post.summary) {
// TODO: switch to using p.summary, after ensuring it matches summary generated on the backend.
$post.innerHTML += '<p>' + post.summary.replace(/</g, "&lt;") + '</p>';
} else if (post.body) {
var preview;
if (post.body.length > 140) {
preview = post.body.substr(0, 140) + '...';
} else {
preview = post.body;
}
$post.innerHTML += '<p>' + preview.replace(/</g, "&lt;") + '</p>';
}
return $post;
};
var loadPage = function(p, loadAll) {
if (loadAll) {
$posts.el.innerHTML = '';
}
var startPost = posts.length - 1 - (loadAll ? 0 : ((p-1)*postsPerPage));
var endPost = posts.length - 1 - (p*postsPerPage);
for (var i=startPost; i>=0 && i>endPost; i--) {
$posts.el.appendChild(createPostEl(posts[i]));
}
if (loadAll) {
if (p < pages) {
$posts.el.appendChild(createMorePostsEl());
}
} else {
var $moreEl = document.getElementById('more-posts');
$moreEl.parentNode.removeChild($moreEl);
}
try {
postsLoaded(posts.length);
} catch (e) {}
};
var getPageNum = function(url) {
var hash;
if (url) {
hash = url.substr(url.indexOf('#')+1);
} else {
hash = window.location.hash.substr(1);
}
var page = hash || 1;
page = parseInt(page);
if (isNaN(page)) {
page = 1;
}
return page;
};
var postsPerPage = 10;
var pages = 0;
var page = getPageNum();
window.addEventListener('hashchange', function(e) {
var newPage = getPageNum();
var didPageIncrement = newPage == getPageNum(e.oldURL) + 1;
loadPage(newPage, !didPageIncrement);
});
var deletePost = function(postID, token, callback) {
deleting = true;
var $delBtn = document.getElementById('post-' + postID).getElementsByClassName('delete action')[0];
$delBtn.innerHTML = '...';
var http = new XMLHttpRequest();
var url = "/api/posts/" + postID + (typeof token !== 'undefined' ? "?token=" + encodeURIComponent(token) : '');
http.open("DELETE", url, true);
http.onreadystatechange = function() {
if (http.readyState == 4) {
deleting = false;
if (http.status == 204 || http.status == 404) {
for (var i=0; i<posts.length; i++) {
if (posts[i].id == postID) {
// TODO: use this return value, along will full content, for restoring post
posts.splice(i, 1);
break;
}
}
H.set('posts', JSON.stringify(posts));
callback();
} else if (http.status == 409) {
$delBtn.innerHTML = 'delete';
alert("Post is synced to another account. Delete the post from that account instead.");
// TODO: show "remove" button instead of "delete" now
// Persist that state.
// Have it remove the post locally only.
} else {
$delBtn.innerHTML = 'delete';
alert("Failed to delete. Please try again.");
}
}
}
http.send();
};
var hasWritten = H.get('lastDoc', '') !== '';
var displayNoPosts = function() {
if (auth) {
$posts.el.innerHTML = '';
return;
}
var cta = '<a href="/pad">Create a post</a> and it\'ll appear here.';
if (hasWritten) {
cta = '<a href="/pad">Finish your post</a> and it\'ll appear here.';
}
H.getEl("posts").el.innerHTML = '<p class="status">No posts created yet.</p><p class="status">' + cta + '</p>';
};
if (posts.length == 0) {
displayNoPosts();
} else {
initialListPop();
}
diff --git a/templates.go b/templates.go
index ecd8750..5dd122f 100644
--- a/templates.go
+++ b/templates.go
@@ -1,229 +1,258 @@
/*
* Copyright © 2018-2021 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"errors"
"html/template"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
-
+
"github.com/dustin/go-humanize"
"github.com/writeas/web-core/l10n"
"github.com/writeas/web-core/log"
"github.com/writefreely/writefreely/config"
+ "github.com/leonelquinteros/gotext"
)
var (
templates = map[string]*template.Template{}
pages = map[string]*template.Template{}
userPages = map[string]*template.Template{}
funcMap = template.FuncMap{
"largeNumFmt": largeNumFmt,
"pluralize": pluralize,
"isRTL": isRTL,
"isLTR": isLTR,
"localstr": localStr,
"localhtml": localHTML,
"tolower": strings.ToLower,
"title": strings.Title,
"hasPrefix": strings.HasPrefix,
"hasSuffix": strings.HasSuffix,
"dict": dict,
+
+ "localize": localize,
+ "variables": variables,
}
)
const (
templatesDir = "templates"
pagesDir = "pages"
)
func showUserPage(w http.ResponseWriter, name string, obj interface{}) {
if obj == nil {
log.Error("showUserPage: data is nil!")
return
}
if err := userPages[filepath.Join("user", name+".tmpl")].ExecuteTemplate(w, name, obj); err != nil {
log.Error("Error parsing %s: %v", name, err)
}
}
func initTemplate(parentDir, name string) {
if debugging {
log.Info(" " + filepath.Join(parentDir, templatesDir, name+".tmpl"))
}
files := []string{
filepath.Join(parentDir, templatesDir, name+".tmpl"),
filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"),
filepath.Join(parentDir, templatesDir, "base.tmpl"),
filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"),
}
if name == "collection" || name == "collection-tags" || name == "chorus-collection" || name == "read" {
// These pages list out collection posts, so we also parse templatesDir + "include/posts.tmpl"
files = append(files, filepath.Join(parentDir, templatesDir, "include", "posts.tmpl"))
}
if name == "chorus-collection" || name == "chorus-collection-post" {
files = append(files, filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"))
}
if name == "collection" || name == "collection-tags" || name == "collection-post" || name == "post" || name == "chorus-collection" || name == "chorus-collection-post" {
files = append(files, filepath.Join(parentDir, templatesDir, "include", "post-render.tmpl"))
}
templates[name] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...))
}
func initPage(parentDir, path, key string) {
if debugging {
log.Info(" [%s] %s", key, path)
}
files := []string{
path,
filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"),
filepath.Join(parentDir, templatesDir, "base.tmpl"),
filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"),
}
if key == "login.tmpl" || key == "landing.tmpl" || key == "signup.tmpl" {
files = append(files, filepath.Join(parentDir, templatesDir, "include", "oauth.tmpl"))
}
pages[key] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...))
}
func initUserPage(parentDir, path, key string) {
if debugging {
log.Info(" [%s] %s", key, path)
}
userPages[key] = template.Must(template.New(key).Funcs(funcMap).ParseFiles(
path,
filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"),
filepath.Join(parentDir, templatesDir, "user", "include", "footer.tmpl"),
filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"),
filepath.Join(parentDir, templatesDir, "user", "include", "nav.tmpl"),
))
}
// InitTemplates loads all template files from the configured parent dir.
func InitTemplates(cfg *config.Config) error {
log.Info("Loading templates...")
tmplFiles, err := ioutil.ReadDir(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir))
if err != nil {
return err
}
for _, f := range tmplFiles {
if !f.IsDir() && !strings.HasPrefix(f.Name(), ".") {
parts := strings.Split(f.Name(), ".")
key := parts[0]
initTemplate(cfg.Server.TemplatesParentDir, key)
}
}
log.Info("Loading pages...")
// Initialize all static pages that use the base template
filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, pagesDir), func(path string, i os.FileInfo, err error) error {
if !i.IsDir() && !strings.HasPrefix(i.Name(), ".") {
key := i.Name()
initPage(cfg.Server.PagesParentDir, path, key)
}
return nil
})
log.Info("Loading user pages...")
// Initialize all user pages that use base templates
filepath.Walk(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir, "user"), func(path string, f os.FileInfo, err error) error {
if !f.IsDir() && !strings.HasPrefix(f.Name(), ".") {
corePath := path
if cfg.Server.TemplatesParentDir != "" {
corePath = corePath[len(cfg.Server.TemplatesParentDir)+1:]
}
parts := strings.Split(corePath, string(filepath.Separator))
key := f.Name()
if len(parts) > 2 {
key = filepath.Join(parts[1], f.Name())
}
initUserPage(cfg.Server.TemplatesParentDir, path, key)
}
return nil
})
return nil
}
// renderPage retrieves the given template and renders it to the given io.Writer.
// If something goes wrong, the error is logged and returned.
func renderPage(w io.Writer, tmpl string, data interface{}) error {
err := pages[tmpl].ExecuteTemplate(w, "base", data)
if err != nil {
log.Error("%v", err)
}
return err
}
func largeNumFmt(n int64) string {
return humanize.Comma(n)
}
func pluralize(singular, plural string, n int64) string {
if n == 1 {
return singular
}
return plural
}
func isRTL(d string) bool {
return d == "rtl"
}
func isLTR(d string) bool {
return d == "ltr" || d == "auto"
}
-
+/*
func localStr(term, lang string) string {
s := l10n.Strings(lang)[term]
if s == "" {
s = l10n.Strings("")[term]
}
return s
}
+*/
+func localStr(term, lang string) string {
+ switch lang {
+ case "eu": lang = "eu_ES"
+ case "es": lang = "es_ES"
+ default: lang = "en_UK"
+ }
+ setLang := localize(lang);
+ return setLang.Get(term)
+}
func localHTML(term, lang string) template.HTML {
s := l10n.Strings(lang)[term]
if s == "" {
s = l10n.Strings("")[term]
}
s = strings.Replace(s, "write.as", "<a href=\"https://writefreely.org\">writefreely</a>", 1)
return template.HTML(s)
}
// from: https://stackoverflow.com/a/18276968/1549194
func dict(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, errors.New("dict: invalid number of parameters")
}
dict := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, errors.New("dict: keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
}
+
+func localize(lang string) (*gotext.Locale){
+ var language string = "en_UK"
+ if lang != "" {
+ language = lang
+ }
+ setLang := gotext.NewLocale("./locales", language);
+ setLang.AddDomain("base");
+ return setLang
+}
+
+func variables(Vars ...interface{}) interface{}{
+ res := Vars
+ return res
+}
\ No newline at end of file
diff --git a/templates/bare.tmpl b/templates/bare.tmpl
index a5f9910..b940abe 100644
--- a/templates/bare.tmpl
+++ b/templates/bare.tmpl
@@ -1,266 +1,265 @@
{{define "pad"}}<!DOCTYPE HTML>
<html>
<head>
<title>{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} &mdash; {{.SiteName}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
- {{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="google" value="notranslate">
</head>
<body id="pad" class="light">
<div id="overlay"></div>
<textarea id="writer" placeholder="Write..." class="{{.Post.Font}}" autofocus>{{if .Post.Title}}# {{.Post.Title}}
{{end}}{{.Post.Content}}</textarea>
- <div class="alert success hidden" id="edited-elsewhere">This post has been updated elsewhere since you last published! <a href="#" id="erase-edit">Delete draft and reload</a>.</div>
+ <div class="alert success hidden" id="edited-elsewhere">{{call .Tr "This post has been updated elsewhere since you last published!"}} <a href="#" id="erase-edit">{{call .Tr "Delete draft and reload"}}</a>.</div>
<header id="tools">
<div id="clip">
{{if not .SingleUser}}<h1>{{if .Chorus}}<a href="/" title="Home">{{else}}<a href="/me/c/" title="View blogs">{{end}}{{.SiteName}}</a></h1>{{end}}
<nav id="target" {{if .SingleUser}}style="margin-left:0"{{end}}><ul>
- <li>{{if .Blogs}}<a href="{{$c := index .Blogs 0}}{{$c.CanonicalURL}}">My Posts</a>{{else}}<a>Draft</a>{{end}}</li>
+ <li>{{if .Blogs}}<a href="{{$c := index .Blogs 0}}{{$c.CanonicalURL}}">{{call .Tr "My Posts"}}</a>{{else}}<a>{{call .Tr "Draft"}}</a>{{end}}</li>
</ul></nav>
<span id="wc" class="hidden if-room room-4">0 words</span>
</div>
- <noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need Javascript enabled to post.</noscript>
+ <noscript style="margin-left: 2em;"><strong>{{call .Tr "NOTE"}}</strong>: {{call .Tr "for now, you'll need Javascript enabled to post."}}</noscript>
<div id="belt">
{{if .Editing}}<div class="tool hidden if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit/meta{{else}}/{{if .SingleUser}}d/{{end}}{{.Post.Id}}/meta{{end}}" title="Edit post metadata" id="edit-meta"><img class="ic-24dp" src="/img/ic_info_dark@2x.png" /></a></div>{{end}}
- <div class="tool"><button title="Publish your writing" id="publish" style="font-weight: bold">Post</button></div>
+ <div class="tool"><button title="Publish your writing" id="publish" style="font-weight: bold">{{call .Tr "Post"}}</button></div>
</div>
</header>
<script src="/js/h.js"></script>
<script>
var $writer = H.getEl('writer');
var $btnPublish = H.getEl('publish');
var $btnEraseEdit = H.getEl('edited-elsewhere');
var $wc = H.getEl("wc");
var updateWordCount = function() {
var words = 0;
var val = $writer.el.value.trim();
if (val != '') {
words = $writer.el.value.trim().replace(/\s+/gi, ' ').split(' ').length;
}
$wc.el.innerText = words + " word" + (words != 1 ? "s" : "");
};
var setButtonStates = function() {
if (!canPublish) {
$btnPublish.el.className = 'disabled';
return;
}
if ($writer.el.value.length === 0 || (draftDoc != 'lastDoc' && $writer.el.value == origDoc)) {
$btnPublish.el.className = 'disabled';
} else {
$btnPublish.el.className = '';
}
};
{{if .Post.Id}}var draftDoc = 'draft{{.Post.Id}}';
var origDoc = '{{.Post.Content}}';{{else}}var draftDoc = 'lastDoc';{{end}}
var updatedStr = '{{.Post.Updated8601}}';
var updated = null;
if (updatedStr != '') {
updated = new Date(updatedStr);
}
var ok = H.load($writer, draftDoc, true, updated);
if (!ok) {
// Show "edited elsewhere" warning
$btnEraseEdit.el.classList.remove('hidden');
}
var defaultTimeSet = false;
updateWordCount();
var typingTimer;
var doneTypingInterval = 200;
var posts;
{{if and .Post.Id (not .Post.Slug)}}
var token = null;
var curPostIdx;
posts = JSON.parse(H.get('posts', '[]'));
for (var i=0; i<posts.length; i++) {
if (posts[i].id == "{{.Post.Id}}") {
token = posts[i].token;
break;
}
}
var canPublish = token != null;
{{else}}var canPublish = true;{{end}}
var publishing = false;
var justPublished = false;
var publish = function(content, font) {
{{if and (and .Post.Id (not .Post.Slug)) (not .User)}}
if (!token) {
- alert("You don't have permission to update this post.");
+ alert({{call .Tr "You don't have permission to update this post."}});
return;
}
{{end}}
publishing = true;
$btnPublish.el.textContent = 'Posting...';
$btnPublish.el.disabled = true;
var http = new XMLHttpRequest();
var post = H.getTitleStrict(content);
var params = {
body: post.content,
title: post.title,
font: font
};
{{ if .Post.Slug }}
var url = "/api/collections/{{.EditCollection.Alias}}/posts/{{.Post.Id}}";
{{ else if .Post.Id }}
var url = "/api/posts/{{.Post.Id}}";
if (typeof token === 'undefined' || !token) {
token = "";
}
params.token = token;
{{ else }}
var lang = navigator.languages ? navigator.languages[0] : (navigator.language || navigator.userLanguage);
lang = lang.substring(0, 2);
params.lang = lang;
var url = "/api/posts";
var postTarget = '{{if .Blogs}}{{$c := index .Blogs 0}}{{$c.Alias}}{{else}}anonymous{{end}}';
if (postTarget != 'anonymous') {
url = "/api/collections/" + postTarget + "/posts";
}
{{ end }}
http.open("POST", url, true);
// Send the proper header information along with the request
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
publishing = false;
if (http.status == 200 || http.status == 201) {
data = JSON.parse(http.responseText);
id = data.data.id;
nextURL = '{{if .SingleUser}}/d{{end}}/'+id;
localStorage.setItem('draft'+id+'-published', new Date().toISOString());
{{ if not .Post.Id }}
// Post created
if (postTarget != 'anonymous') {
nextURL = {{if not .SingleUser}}'/'+postTarget+{{end}}'/'+data.data.slug;
}
editToken = data.data.token;
{{ if not .User }}if (postTarget == 'anonymous') {
// Save the data
var posts = JSON.parse(H.get('posts', '[]'));
{{if .Post.Id}}var newPost = H.createPost("{{.Post.Id}}", token, content);
for (var i=0; i<posts.length; i++) {
if (posts[i].id == "{{.Post.Id}}") {
posts[i].title = newPost.title;
posts[i].summary = newPost.summary;
break;
}
}
nextURL = "/pad/posts";{{else}}posts.push(H.createPost(id, editToken, content));{{end}}
H.set('posts', JSON.stringify(posts));
}
{{ end }}
{{ end }}
justPublished = true;
if (draftDoc != 'lastDoc') {
H.remove(draftDoc);
{{if .Editing}}H.remove('draft{{.Post.Id}}font');{{end}}
} else {
H.set(draftDoc, '');
}
{{if .EditCollection}}
window.location = '{{.EditCollection.CanonicalURL}}{{.Post.Slug}}';
{{else}}
window.location = nextURL;
{{end}}
} else {
$btnPublish.el.textContent = 'Post';
alert("Failed to post. Please try again.");
}
}
}
http.send(JSON.stringify(params));
};
setButtonStates();
$writer.on('keyup input', function() {
setButtonStates();
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
}, false);
$writer.on('keydown', function(e) {
clearTimeout(typingTimer);
if (e.keyCode == 13 && (e.metaKey || e.ctrlKey)) {
$btnPublish.el.click();
}
});
$btnPublish.on('click', function(e) {
e.preventDefault();
if (!publishing && $writer.el.value) {
var content = $writer.el.value;
publish(content, selectedFont);
}
});
H.getEl('erase-edit').on('click', function(e) {
e.preventDefault();
H.remove(draftDoc);
H.remove(draftDoc+'-published');
justPublished = true; // Block auto-save
location.reload();
});
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
var selectedFont = H.get('{{if .Editing}}draft{{.Post.Id}}font{{else}}padFont{{end}}', '{{.Post.Font}}');
var doneTyping = function() {
if (draftDoc == 'lastDoc' || $writer.el.value != origDoc) {
H.save($writer, draftDoc);
if (!defaultTimeSet) {
var lastLocalPublishStr = localStorage.getItem(draftDoc+'-published');
if (lastLocalPublishStr == null || lastLocalPublishStr == '') {
localStorage.setItem(draftDoc+'-published', updatedStr);
}
defaultTimeSet = true;
}
updateWordCount();
}
};
window.addEventListener('beforeunload', function(e) {
if (draftDoc != 'lastDoc' && $writer.el.value == origDoc) {
H.remove(draftDoc);
H.remove(draftDoc+'-published');
} else if (!justPublished) {
doneTyping();
}
});
try {
(function() {
var wf=document.createElement('script');
wf.src = '/js/webfont.js';
wf.type='text/javascript';
wf.async='true';
var s=document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) {
// whatevs
}
</script>
</body>
</html>{{end}}
diff --git a/templates/base.tmpl b/templates/base.tmpl
index 273c2b9..94e1d6c 100644
--- a/templates/base.tmpl
+++ b/templates/base.tmpl
@@ -1,94 +1,94 @@
{{define "base"}}<!DOCTYPE HTML>
<html>
<head>
{{ template "head" . }}
<link rel="stylesheet" type="text/css" href="/css/{{.Theme}}.css" />
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="application-name" content="{{.SiteName}}">
<meta name="application-url" content="{{.Host}}">
<meta property="og:site_name" content="{{.SiteName}}" />
</head>
<body {{template "body-attrs" .}}>
<div id="overlay"></div>
<header>
{{ if .Chorus }}<nav id="full-nav">
<div class="left-side">
<h2><a href="/">{{.SiteName}}</a></h2>
</div>
{{ else }}
<h2><a href="/">{{.SiteName}}</a></h2>
{{ end }}
{{if not .SingleUser}}
<nav id="user-nav">
{{if .Username}}
<nav class="dropdown-nav">
<ul><li class="has-submenu"><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
- {{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}}
- <li><a href="/me/settings">Account settings</a></li>
- <li><a href="/me/export">Export</a></li>
- {{if .CanInvite}}<li><a href="/me/invites">Invite people</a></li>{{end}}
+ {{if .IsAdmin}}<li><a href="/admin">{{call .Tr "Admin dashboard"}}</a></li>{{end}}
+ <li><a href="/me/settings">{{call .Tr "Account settings"}}</a></li>
+ <li><a href="/me/export">{{call .Tr "Export"}}</a></li>
+ {{if .CanInvite}}<li><a href="/me/invites">{{call .Tr "Invite people"}}</a></li>{{end}}
<li class="separator"><hr /></li>
- <li><a href="/me/logout">Log out</a></li>
+ <li><a href="/me/logout">{{call .Tr "Log out"}}</a></li>
</ul></li>
</ul>
</nav>
{{end}}
<nav class="tabs">
{{ if and .SimpleNav (not .SingleUser) }}
- {{if and (and .LocalTimeline .CanViewReader) .Chorus}}<a href="/"{{if eq .Path "/"}} class="selected"{{end}}>Home</a>{{end}}
+ {{if and (and .LocalTimeline .CanViewReader) .Chorus}}<a href="/"{{if eq .Path "/"}} class="selected"{{end}}>{{call .Tr "Home"}}</a>{{end}}
{{ end }}
- {{if or .Chorus (not .Username)}}<a href="/about"{{if eq .Path "/about"}} class="selected"{{end}}>About</a>{{end}}
+ {{if or .Chorus (not .Username)}}<a href="/about"{{if eq .Path "/about"}} class="selected"{{end}}>{{call .Tr "About"}}</a>{{end}}
{{ if not .SingleUser }}
{{ if .Username }}
- {{if or (not .Chorus) (gt .MaxBlogs 1)}}<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a>{{end}}
+ {{if or (not .Chorus) (gt .MaxBlogs 1)}}<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>{{call .Tr "Blog" 2}}</a>{{end}}
{{if and (and .Chorus (eq .MaxBlogs 1)) .Username}}<a href="/{{.Username}}/"{{if eq .Path (printf "/%s/" .Username)}} class="selected"{{end}}>My Posts</a>{{end}}
- {{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}}
+ {{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>{{call .Tr "Draft" 2}}</a>{{end}}
{{ end }}
- {{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read"{{if eq .Path "/read"}} class="selected"{{end}}>Reader</a>{{end}}
- {{if eq .SignupPath "/signup"}}<a href="/signup"{{if eq .Path "/signup"}} class="selected"{{end}}>Sign up</a>{{end}}
- {{if and (not .Username) (not .Private)}}<a href="/login"{{if eq .Path "/login"}} class="selected"{{end}}>Log in</a>{{else if .SimpleNav}}<a href="/me/logout">Log out</a>{{end}}
+ {{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read"{{if eq .Path "/read"}} class="selected"{{end}}>{{call .Tr "Reader"}}</a>{{end}}
+ {{if eq .SignupPath "/signup"}}<a href="/signup"{{if eq .Path "/signup"}} class="selected"{{end}}>{{call .Tr "Sign up"}}</a>{{end}}
+ {{if and (not .Username) (not .Private)}}<a href="/login"{{if eq .Path "/login"}} class="selected"{{end}}>{{call .Tr "Log in"}}</a>{{else if .SimpleNav}}<a href="/me/logout">{{call .Tr "Log out"}}</a>{{end}}
{{ end }}
</nav>
{{if .Chorus}}{{if .Username}}<div class="right-side" style="font-size: 0.86em;">
- <a class="simple-btn" href="/new">New Post</a>
+ <a class="simple-btn" href="/new">{{call .Tr "New Post"}}</a>
</div>{{end}}
</nav>
{{end}}
</nav>
{{end}}
</header>
<div id="official-writing">
{{ template "content" . }}
</div>
{{ template "footer" . }}
{{if not .JSDisabled}}
<script type="text/javascript" src="/js/menu.js"></script>
<script type="text/javascript">
{{if .WebFonts}}
try { // Google Fonts
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
(function() {
var wf = document.createElement('script');
wf.src = '/js/webfont.js';
wf.type = 'text/javascript';
wf.async = 'true';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) { /* ¯\_(ツ)_/¯ */ }
{{end}}
</script>
{{else}}
{{if .WebFonts}}<link href="/css/fonts.css" rel="stylesheet" type="text/css" />{{end}}
{{end}}
</body>
</html>{{end}}
{{define "body-attrs"}}{{end}}
diff --git a/templates/chorus-collection-post.tmpl b/templates/chorus-collection-post.tmpl
index 468455c..ed1478f 100644
--- a/templates/chorus-collection-post.tmpl
+++ b/templates/chorus-collection-post.tmpl
@@ -1,155 +1,154 @@
{{define "post"}}<!DOCTYPE HTML>
<html {{if .Language.Valid}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">
<head prefix="og: http://ogp.me/ns# article: http://ogp.me/ns/article#">
<meta charset="utf-8">
<title>{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
- {{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="canonical" href="{{.CanonicalURL .Host}}" />
<meta name="generator" content="WriteFreely">
<meta name="title" content="{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{if .Collection.Title}}{{.Collection.Title}}{{else}}{{.Collection.Alias}}{{end}}">
<meta name="description" content="{{.Summary}}">
{{if gt .Views 1}}<meta name="twitter:label1" value="Views">
<meta name="twitter:data1" value="{{largeNumFmt .Views}}">{{end}}
<meta name="author" content="{{.Collection.Title}}" />
<meta itemprop="description" content="{{.Summary}}">
<meta itemprop="datePublished" content="{{.CreatedDate}}" />
<meta name="twitter:card" content="summary">
<meta name="twitter:description" content="{{.Summary}}">
<meta name="twitter:title" content="{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{if .Collection.Title}}{{.Collection.Title}}{{else}}{{.Collection.Alias}}{{end}}">
{{if gt (len .Images) 0}}<meta name="twitter:image" content="{{index .Images 0}}">{{else}}<meta name="twitter:image" content="{{.Collection.AvatarURL}}">{{end}}
<meta property="og:title" content="{{.PlainDisplayTitle}}" />
<meta property="og:description" content="{{.Summary}}" />
<meta property="og:site_name" content="{{.Collection.DisplayTitle}}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{.CanonicalURL .Host}}" />
<meta property="og:updated_time" content="{{.Created8601}}" />
{{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Collection.AvatarURL}}">{{end}}
<meta property="article:published_time" content="{{.Created8601}}">
{{template "collection-meta" .}}
{{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}}
<style type="text/css">
body footer {
max-width: 40rem;
margin: 0 auto;
}
body#post header {
padding: 1em 1rem;
}
</style>
{{if .Collection.RenderMathJax}}
<!-- Add mathjax logic -->
{{template "mathjax" . }}
{{end}}
<!-- Add highlighting logic -->
{{template "highlighting" .}}
</head>
<body id="post">
<div id="overlay"></div>
{{template "user-navigation" .}}
{{if .Silenced}}
- {{template "user-silenced"}}
+ {{template "user-silenced" (dict "Tr" $.Tr)}}
{{end}}
<article id="post-body" class="{{.Font}} h-entry">{{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}{{if .Title.String}}<h2 id="title" class="p-name{{if $.Collection.Format.ShowDates}} dated{{end}}">{{.FormattedDisplayTitle}}</h2>{{end}}{{if and $.Collection.Format.ShowDates (not .IsPinned)}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{.DisplayDate}}</time>{{end}}<div class="e-content">{{.HTMLContent}}</div></article>
{{ if .Collection.ShowFooterBranding }}
<footer dir="ltr">
<p style="text-align: left">Published by <a rel="author" href="{{if .IsTopLevel}}/{{else}}/{{.Collection.Alias}}/{{end}}" class="h-card p-author">{{.Collection.DisplayTitle}}</a>
{{ if .IsOwner }} &middot; <span class="views" dir="ltr"><strong>{{largeNumFmt .Views}}</strong> {{pluralize "view" "views" .Views}}</span>
&middot; <a class="xtra-feature" href="/{{if not .SingleUser}}{{.Collection.Alias}}/{{end}}{{.Slug.String}}/edit" dir="{{.Direction}}">Edit</a>
{{if .IsPinned}} &middot; <a class="xtra-feature unpin" href="/{{.Collection.Alias}}/{{.Slug.String}}/unpin" dir="{{.Direction}}" onclick="unpinPost(event, '{{.ID}}')">Unpin</a>{{end}}
{{ end }}
</p>
<nav>
{{if .PinnedPosts}}
{{range .PinnedPosts}}<a class="pinned{{if eq .Slug.String $.Slug.String}} selected{{end}}" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}
{{end}}
</nav>
<hr>
<nav><p style="font-size: 0.9em">{{localhtml "published with write.as" .Language.String}}</p></nav>
</footer>
{{ end }}
</body>
{{if .Collection.CanShowScript}}
{{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
{{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}}
{{end}}
<script src="/js/localdate.js"></script>
<script type="text/javascript">
var pinning = false;
function unpinPost(e, postID) {
e.preventDefault();
if (pinning) {
return;
}
pinning = true;
var $footer = document.getElementsByTagName('footer')[0];
var callback = function() {
// Hide current page
var $pinnedNavLink = $footer.getElementsByTagName('nav')[0].querySelector('.pinned.selected');
$pinnedNavLink.style.display = 'none';
};
var $pinBtn = $footer.getElementsByClassName('unpin')[0];
$pinBtn.innerHTML = '...';
var http = new XMLHttpRequest();
var url = "/api/collections/{{.Collection.Alias}}/unpin";
var params = [ { "id": postID } ];
http.open("POST", url, true);
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
pinning = false;
if (http.status == 200) {
callback();
$pinBtn.style.display = 'none';
$pinBtn.innerHTML = 'Pin';
} else if (http.status == 409) {
$pinBtn.innerHTML = 'Unpin';
} else {
$pinBtn.innerHTML = 'Unpin';
alert("Failed to unpin." + (http.status>=500?" Please try again.":""));
}
}
}
http.send(JSON.stringify(params));
};
try { // Fonts
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
(function() {
var wf = document.createElement('script');
wf.src = '/js/webfont.js';
wf.type = 'text/javascript';
wf.async = 'true';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) { /* ¯\_(ツ)_/¯ */ }
</script>
{{if and .Monetization (not .IsOwner)}}
<script src="/js/webmonetization.js"></script>
<script>
window.collAlias = '{{.Collection.Alias}}'
window.postSlug = '{{.Slug.String}}'
initMonetization()
</script>
{{end}}
</html>{{end}}
diff --git a/templates/chorus-collection.tmpl b/templates/chorus-collection.tmpl
index 2bc165d..35102e0 100644
--- a/templates/chorus-collection.tmpl
+++ b/templates/chorus-collection.tmpl
@@ -1,236 +1,240 @@
{{define "collection"}}<!DOCTYPE HTML>
<html {{if .Language}}lang="{{.Language}}"{{end}} dir="{{.Direction}}">
<head>
<meta charset="utf-8">
<title>{{.DisplayTitle}}{{if not .SingleUser}} &mdash; {{.SiteName}}{{end}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
- {{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="canonical" href="{{.CanonicalURL}}">
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}}
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}}
{{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} &raquo; Feed" href="{{.CanonicalURL}}feed/" />{{end}}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="generator" content="WriteFreely">
<meta name="description" content="{{.Description}}">
<meta itemprop="name" content="{{.DisplayTitle}}">
<meta itemprop="description" content="{{.Description}}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.DisplayTitle}}">
<meta name="twitter:image" content="{{.AvatarURL}}">
<meta name="twitter:description" content="{{.Description}}">
<meta property="og:title" content="{{.DisplayTitle}}" />
<meta property="og:site_name" content="{{.DisplayTitle}}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{.CanonicalURL}}" />
<meta property="og:description" content="{{.Description}}" />
<meta property="og:image" content="{{.AvatarURL}}">
{{template "collection-meta" .}}
{{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}}
<style type="text/css">
body#collection header {
max-width: 40em;
margin: 1em auto;
text-align: left;
padding: 0;
}
body#collection header.multiuser {
max-width: 100%;
margin: 1em;
}
body#collection header nav:not(.pinned-posts) {
display: inline;
}
body#collection header nav.dropdown-nav,
body#collection header nav.tabs,
body#collection header nav.tabs a:first-child {
margin: 0 0 0 1em;
}
</style>
{{if .RenderMathJax}}
<!-- Add mathjax logic -->
{{template "mathjax" .}}
{{end}}
<!-- Add highlighting logic -->
{{template "highlighting" . }}
</head>
<body id="collection" itemscope itemtype="http://schema.org/WebPage">
{{template "user-navigation" .}}
{{if .Silenced}}
- {{template "user-silenced"}}
+ {{template "user-silenced" (dict "Tr" $.Tr)}}
{{end}}
<header>
<h1 dir="{{.Direction}}" id="blog-title"><a href="/{{if .IsTopLevel}}{{else}}{{.Prefix}}{{.Alias}}/{{end}}" class="h-card p-author u-url" rel="me author">{{.DisplayTitle}}</a></h1>
{{if .Description}}<p class="description p-note">{{.Description}}</p>{{end}}
{{/*if not .Public/*}}
<!--p class="meta-note"><span>Private collection</span>. Only you can see this page.</p-->
{{/*end*/}}
{{if .PinnedPosts}}<nav class="pinned-posts">
{{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}</nav>
{{end}}
</header>
{{if .Posts}}<section id="wrapper" itemscope itemtype="http://schema.org/Blog">{{else}}<div id="wrapper">{{end}}
{{if .IsWelcome}}
<div id="welcome">
<h2>Welcome, <strong>{{.Username}}</strong>!</h2>
<p>This is your new blog.</p>
<p><a class="simple-cta" href="/#{{.Alias}}">Start writing</a>, or <a class="simple-cta" href="/me/c/{{.Alias}}">customize</a> your blog.</p>
<p>Check out our <a class="simple-cta" href="https://guides.write.as/writing/?pk_campaign=welcome">writing guide</a> to see what else you can do, and <a class="simple-cta" href="/contact">get in touch</a> anytime with questions or feedback.</p>
</div>
{{end}}
{{template "posts" .}}
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
{{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}}
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">&#8672; {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} &#8674;</a>{{end}}
{{else}}
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">&#8672; Older</a>{{end}}
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">Newer &#8674;</a>{{end}}
{{end}}
</nav>{{end}}
{{if .Posts}}</section>{{else}}</div>{{end}}
{{if .ShowFooterBranding }}
<footer>
<hr />
<nav dir="ltr">
{{if not .SingleUser}}<a class="home pubd" href="/">{{.SiteName}}</a> &middot; {{end}}powered by <a style="margin-left:0" href="https://writefreely.org">writefreely</a>
</nav>
</footer>
{{ end }}
</body>
{{if .CanShowScript}}
{{range .ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
{{if .Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}}
{{end}}
<script src="/js/h.js"></script>
<script src="/js/localdate.js"></script>
<script src="/js/postactions.js"></script>
<script type="text/javascript">
var deleting = false;
-function delPost(e, id, owned) {
+function delPost(e, id, owned, loc) {
e.preventDefault();
if (deleting) {
return;
}
+ var loc = JSON.parse(loc);
+
// TODO: UNDO!
- if (window.confirm('Are you sure you want to delete this post?')) {
+ if (window.confirm(loc['Are you sure you want to delete this post?'])) {
// AJAX
deletePost(id, "", function() {
// Remove post from list
var $postEl = document.getElementById('post-' + id);
$postEl.parentNode.removeChild($postEl);
// TODO: add next post from this collection at the bottom
});
}
}
var deletePost = function(postID, token, callback) {
deleting = true;
var $delBtn = document.getElementById('post-' + postID).getElementsByClassName('delete action')[0];
$delBtn.innerHTML = '...';
var http = new XMLHttpRequest();
var url = "/api/posts/" + postID;
http.open("DELETE", url, true);
http.onreadystatechange = function() {
if (http.readyState == 4) {
deleting = false;
if (http.status == 204) {
callback();
} else if (http.status == 409) {
$delBtn.innerHTML = 'delete';
alert("Post is synced to another account. Delete the post from that account instead.");
// TODO: show "remove" button instead of "delete" now
// Persist that state.
// Have it remove the post locally only.
} else {
$delBtn.innerHTML = 'delete';
alert("Failed to delete." + (http.status>=500?" Please try again.":""));
}
}
}
http.send();
};
var pinning = false;
-function pinPost(e, postID, slug, title) {
+function pinPost(e, postID, slug, title, loc) {
e.preventDefault();
if (pinning) {
return;
}
pinning = true;
+ var loc = JSON.parse(loc)
+
var callback = function() {
// Visibly remove post from collection
var $postEl = document.getElementById('post-' + postID);
$postEl.parentNode.removeChild($postEl);
var $header = document.querySelector('header:not(.multiuser)');
var $pinnedNavs = $header.getElementsByTagName('nav');
// Add link to nav
var link = '<a class="pinned" href="/{{.Alias}}/'+slug+'">'+title+'</a>';
if ($pinnedNavs.length == 0) {
$header.insertAdjacentHTML("beforeend", '<nav>'+link+'</nav>');
} else {
$pinnedNavs[0].insertAdjacentHTML("beforeend", link);
}
};
var $pinBtn = document.getElementById('post-' + postID).getElementsByClassName('pin action')[0];
$pinBtn.innerHTML = '...';
var http = new XMLHttpRequest();
var url = "/api/collections/{{.Alias}}/pin";
var params = [ { "id": postID } ];
http.open("POST", url, true);
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
pinning = false;
if (http.status == 200) {
callback();
} else if (http.status == 409) {
$pinBtn.innerHTML = 'pin';
- alert("Post is synced to another account. Delete the post from that account instead.");
+ alert(loc["Post is synced to another account. Delete the post from that account instead."]);
// TODO: show "remove" button instead of "delete" now
// Persist that state.
// Have it remove the post locally only.
} else {
$pinBtn.innerHTML = 'pin';
- alert("Failed to pin." + (http.status>=500?" Please try again.":""));
+ //alert("Failed to pin." + (http.status>=500?" Please try again.":""));
+ alert(loc["Failed to pin."] + (http.status>=500?" " + loc["Please try again."]:""));
}
}
}
http.send(JSON.stringify(params));
};
try {
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
(function() {
var wf = document.createElement('script');
wf.src = '/js/webfont.js';
wf.type = 'text/javascript';
wf.async = 'true';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) {}
</script>
</html>{{end}}
diff --git a/templates/classic.tmpl b/templates/classic.tmpl
index 7032f58..af30356 100644
--- a/templates/classic.tmpl
+++ b/templates/classic.tmpl
@@ -1,402 +1,405 @@
{{define "pad"}}<!DOCTYPE HTML>
<html>
<head>
- <title>{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} &mdash; {{.SiteName}}</title>
+ <title>{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}{{call .Tr "New Post"}}{{end}} &mdash; {{.SiteName}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
<link rel="stylesheet" type="text/css" href="/css/prose.css" />
- {{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="google" value="notranslate">
</head>
<body id="pad" class="light classic">
<div id="overlay"></div>
<!-- <div style="text-align: center"> -->
<!-- <label style="border-right: 1px solid silver"> -->
<!-- Markdown <input type=radio name=inputformat value=markdown checked>&nbsp;</label> -->
<!-- <label>&nbsp;<input type=radio name=inputformat value=prosemirror> WYSIWYM</label> -->
<!-- </div> -->
<input type="text" id="title" name="title" placeholder="Title..." {{if .Post.Title}}value="{{.Post.Title}}"{{end}} autofocus />
<div id="editor" style="margin-bottom: 0"></div>
<div style="display: none"><textarea id="content"{{if .Post.Content }} value={{.Post.Content}}>{{.Post.Content}}{{else}}>{{end}}</textarea></div>
<header id="tools">
<div id="clip">
{{if not .SingleUser}}<h1><a href="/me/c/" title="View blogs"><img class="ic-24dp" src="/img/ic_blogs_dark@2x.png" /></a></h1>{{end}}
<nav id="target" {{if .SingleUser}}style="margin-left:0"{{end}}><ul>
{{if .Editing}}<li>{{if .EditCollection}}<a href="{{.EditCollection.CanonicalURL}}">{{.EditCollection.Title}}</a>{{else}}<a>Draft</a>{{end}}</li>
{{else}}<li><a id="publish-to"><span id="target-name">Draft</span> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /></a>
<ul>
- <li class="menu-heading">Publish to...</li>
+ <li class="menu-heading">{{call .Tr "Publish to..."}}</li>
{{if .Blogs}}{{range $idx, $el := .Blogs}}
<li class="target{{if eq $idx 0}} selected{{end}}" id="blog-{{$el.Alias}}"><a href="#{{$el.Alias}}"><i class="material-icons md-18">public</i> {{if $el.Title}}{{$el.Title}}{{else}}{{$el.Alias}}{{end}}</a></li>
{{end}}{{end}}
- <li class="target" id="blog-anonymous"><a href="#anonymous"><i class="material-icons md-18">description</i> <em>Draft</em></a></li>
+ <li class="target" id="blog-anonymous"><a href="#anonymous"><i class="material-icons md-18">description</i> <em>{{call .Tr "Draft"}}</em></a></li>
<li id="user-separator" class="separator"><hr /></li>
{{ if .SingleUser }}
- <li><a href="/"><i class="material-icons md-18">launch</i> View Blog</a></li>
- <li><a href="/me/c/{{.Username}}"><i class="material-icons md-18">palette</i> Customize</a></li>
- <li><a href="/me/c/{{.Username}}/stats"><i class="material-icons md-18">trending_up</i> Stats</a></li>
+ <li><a href="/"><i class="material-icons md-18">launch</i> {{call .Tr "View Blog"}}</a></li>
+ <li><a href="/me/c/{{.Username}}"><i class="material-icons md-18">palette</i> {{call .Tr "Customize"}}</a></li>
+ <li><a href="/me/c/{{.Username}}/stats"><i class="material-icons md-18">trending_up</i> {{call .Tr "Stats"}}</a></li>
{{ else }}
- <li><a href="/me/c/"><i class="material-icons md-18">library_books</i> View Blogs</a></li>
+ <li><a href="/me/c/"><i class="material-icons md-18">library_books</i> {{call .Tr "View Blog2" 2}}</a></li>
{{ end }}
- <li><a href="/me/posts/"><i class="material-icons md-18">view_list</i> View Drafts</a></li>
- <li><a href="/me/logout"><i class="material-icons md-18">power_settings_new</i> Log out</a></li>
+ <li><a href="/me/posts/"><i class="material-icons md-18">view_list</i> {{call .Tr "View Draft" 2}}</a></li>
+ <li><a href="/me/logout"><i class="material-icons md-18">power_settings_new</i> {{call .Tr "Log out"}}</a></li>
</ul>
</li>{{end}}
</ul></nav>
<nav id="font-picker" class="if-room room-3 hidden" style="margin-left:-1em"><ul>
<li><a href="#" id="" onclick="return false"><img class="ic-24dp" src="/img/ic_font_dark@2x.png" /> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /></a>
<ul style="text-align: center">
- <li class="menu-heading">Font</li>
+ <li class="menu-heading">{{call .Tr "Font"}}</li>
<li class="selected"><a class="font norm" href="#norm">Serif</a></li>
<li><a class="font sans" href="#sans">Sans-serif</a></li>
<li><a class="font wrap" href="#wrap">Monospace</a></li>
</ul>
</li>
</ul></nav>
- <span id="wc" class="hidden if-room room-4">0 words</span>
+ {{ $N := 2 }}{{ if eq .AppCfg.Lang "eu_ES" }}{{ $N = 1}}{{ end }}
+ <span id="wc" class="hidden if-room room-4">0 {{call .Tr "words" $N}}</span>
</div>
- <noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need Javascript enabled to post.</noscript>
+ <noscript style="margin-left: 2em;"><strong>{{call .Tr "NOTE"}}</strong>: {{call .Tr "for now, you'll need Javascript enabled to post."}}</noscript>
<div id="belt">
{{if .Editing}}<div class="tool hidden if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit/meta{{else}}/{{if .SingleUser}}d/{{end}}{{.Post.Id}}/meta{{end}}" title="Edit post metadata" id="edit-meta"><img class="ic-24dp" src="/img/ic_info_dark@2x.png" /></a></div>{{end}}
<div class="tool hidden if-room room-2"><a href="#theme" title="Toggle theme" id="toggle-theme"><img class="ic-24dp" src="/img/ic_brightness_dark@2x.png" /></a></div>
<div class="tool if-room room-1"><a href="{{if not .User}}/pad/posts{{else}}/me/posts/{{end}}" title="View posts" id="view-posts"><img class="ic-24dp" src="/img/ic_list_dark@2x.png" /></a></div>
<div class="tool"><a href="#publish" title="Publish" id="publish"><img class="ic-24dp" src="/img/ic_send_dark@2x.png" /></a></div>
</div>
</header>
<script src="/js/h.js"></script>
<script>
function toggleTheme() {
var btns = Array.prototype.slice.call(document.getElementById('tools').querySelectorAll('a img'));
var newTheme = '';
if (document.body.classList.contains('light')) {
newTheme = 'dark';
document.body.className = document.body.className.replace(/(?:^|\s)light(?!\S)/g, newTheme);
for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('_dark@2x.png', '@2x.png');
}
} else {
newTheme = 'light';
document.body.className = document.body.className.replace(/(?:^|\s)dark(?!\S)/g, newTheme);
for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('@2x.png', '_dark@2x.png');
}
}
H.set('padTheme', newTheme);
}
if (H.get('padTheme', 'light') != 'light') {
toggleTheme();
}
var $title = H.getEl('title');
var $writer = H.getQEl('div.ProseMirror');
var $content = H.getEl('content');
var $btnPublish = H.getEl('publish');
var $wc = H.getEl("wc");
var updateWordCount = function() {
var words = 0;
var val = $content.el.value.trim();
if (val != '') {
words = $content.el.value.trim().replace(/\s+/gi, ' ').split(' ').length;
}
val = $title.el.value.trim();
if (val != '') {
words += $title.el.value.trim().replace(/\s+/gi, ' ').split(' ').length;
}
- $wc.el.innerText = words + " word" + (words != 1 ? "s" : "");
+ //$wc.el.innerText = words + " word" + (words != 1 ? "s" : "");
+
+ {{ $N := 2 }}{{ if eq .AppCfg.Lang "eu_ES" }}{{ $N = 1}}{{ end }} //don't need to pluralize for the basque language
+ $wc.el.innerText = words + " " + (words !=1 ? {{call .Tr "word" $N}} : {{call .Tr "word" 1}})
};
var setButtonStates = function() {
if (!canPublish) {
$btnPublish.el.className = 'disabled';
return;
}
if ($content.el.value.length === 0 || (draftDoc != 'lastDoc' && $content.el.value == origDoc)) {
$btnPublish.el.className = 'disabled';
} else {
$btnPublish.el.className = '';
}
};
{{if .Post.Id}}var draftDoc = 'draft{{.Post.Id}}';
var origDoc = '{{.Post.Content}}';{{else}}var draftDoc = 'lastDoc';{{end}}
// ProseMirror editor
window.draftKey = draftDoc;
// H.loadClassic($title, $writer, draftDoc, true);
updateWordCount();
var typingTimer;
var doneTypingInterval = 200;
var posts;
{{if and .Post.Id (not .Post.Slug)}}
var token = null;
var curPostIdx;
posts = JSON.parse(H.get('posts', '[]'));
for (var i=0; i<posts.length; i++) {
if (posts[i].id == "{{.Post.Id}}") {
token = posts[i].token;
break;
}
}
var canPublish = token != null;
{{else}}var canPublish = true;{{end}}
var publishing = false;
var justPublished = false;
var silenced = {{.Silenced}};
var publish = function(title, content, font) {
if (silenced === true) {
- alert("Your account is silenced, so you can't publish or update posts.");
+ alert({{call .Tr "Your account is silenced, so you can't publish or update posts."}});
return;
}
{{if and (and .Post.Id (not .Post.Slug)) (not .User)}}
if (!token) {
- alert("You don't have permission to update this post.");
+ alert({{call .Tr "You don't have permission to update this post."}});
return;
}
if ($btnPublish.el.className == 'disabled') {
return;
}
{{end}}
$btnPublish.el.children[0].textContent = 'more_horiz';
publishing = true;
var xpostTarg = H.get('crosspostTarget', '[]');
var http = new XMLHttpRequest();
var post = H.getTitleStrict(content);
var params = {
body: post.content,
title: title,
font: font
};
{{ if .Post.Slug }}
var url = "/api/collections/{{.EditCollection.Alias}}/posts/{{.Post.Id}}";
{{ else if .Post.Id }}
var url = "/api/posts/{{.Post.Id}}";
if (typeof token === 'undefined' || !token) {
token = "";
}
params.token = token;
{{ else }}
var lang = navigator.languages ? navigator.languages[0] : (navigator.language || navigator.userLanguage);
lang = lang.substring(0, 2);
params.lang = lang;
var url = "/api/posts";
var postTarget = H.get('postTarget', 'anonymous');
if (postTarget != 'anonymous') {
url = "/api/collections/" + postTarget + "/posts";
}
params.crosspost = JSON.parse(xpostTarg);
{{ end }}
http.open("POST", url, true);
// Send the proper header information along with the request
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
publishing = false;
if (http.status == 200 || http.status == 201) {
data = JSON.parse(http.responseText);
id = data.data.id;
nextURL = '{{if .SingleUser}}/d{{end}}/'+id;
{{ if not .Post.Id }}
// Post created
if (postTarget != 'anonymous') {
nextURL = {{if not .SingleUser}}'/'+postTarget+{{end}}'/'+data.data.slug;
}
editToken = data.data.token;
{{ if not .User }}if (postTarget == 'anonymous') {
// Save the data
var posts = JSON.parse(H.get('posts', '[]'));
{{if .Post.Id}}var newPost = H.createPost("{{.Post.Id}}", token, content);
for (var i=0; i<posts.length; i++) {
if (posts[i].id == "{{.Post.Id}}") {
posts[i].title = newPost.title;
posts[i].summary = newPost.summary;
break;
}
}
nextURL = "/pad/posts";{{else}}posts.push(H.createPost(id, editToken, content));{{end}}
H.set('posts', JSON.stringify(posts));
}
{{ end }}
{{ end }}
justPublished = true;
if (draftDoc != 'lastDoc') {
H.remove(draftDoc);
{{if .Editing}}H.remove('draft{{.Post.Id}}font');{{end}}
} else {
H.set(draftDoc, '');
}
{{if .EditCollection}}
window.location = '{{.EditCollection.CanonicalURL}}{{.Post.Slug}}';
{{else}}
window.location = nextURL;
{{end}}
} else {
$btnPublish.el.children[0].textContent = 'send';
- alert("Failed to post. Please try again.");
+ alert({{call .Tr "Failed to post. Please try again."}});
}
}
}
http.send(JSON.stringify(params));
};
setButtonStates();
$title.on('keydown', function(e) {
if (e.keyCode == 13) {
if (e.metaKey || e.ctrlKey) {
$btnPublish.el.click();
} else {
e.preventDefault();
$writer.el.focus();
}
}
});
/*
$writer.on('keyup input', function() {
setButtonStates();
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
}, false);
$writer.on('keydown', function(e) {
clearTimeout(typingTimer);
if (e.keyCode == 13 && (e.metaKey || e.ctrlKey)) {
$btnPublish.el.click();
}
});
*/
$btnPublish.on('click', function(e) {
e.preventDefault();
if (!publishing && ($title.el.value || $content.el.value)) {
var title = $title.el.value;
var content = $content.el.value;
publish(title, content, selectedFont);
}
});
H.getEl('toggle-theme').on('click', function(e) {
e.preventDefault();
var newTheme = 'light';
if (document.body.className == 'light') {
newTheme = 'dark';
}
toggleTheme();
});
var targets = document.querySelectorAll('#target li.target a');
for (var i=0; i<targets.length; i++) {
targets[i].addEventListener('click', function(e) {
e.preventDefault();
var targetName = this.href.substring(this.href.indexOf('#')+1);
H.set('postTarget', targetName);
document.querySelector('#target li.target.selected').classList.remove('selected');
this.parentElement.classList.add('selected');
var newText = this.innerText.split(' ');
newText.shift();
document.getElementById('target-name').innerText = newText.join(' ');
});
}
var postTarget = H.get('postTarget', '{{if .Blogs}}{{$blog := index .Blogs 0}}{{$blog.Alias}}{{else}}anonymous{{end}}');
if (location.hash != '') {
postTarget = location.hash.substring(1);
// TODO: pushState to /pad (or whatever the URL is) so we live on a clean URL
location.hash = '';
}
var pte = document.querySelector('#target li.target#blog-'+postTarget+' a');
if (pte != null) {
pte.click();
} else {
postTarget = 'anonymous';
H.set('postTarget', postTarget);
}
var sansLoaded = false;
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
var loadSans = function() {
if (sansLoaded) return;
sansLoaded = true;
WebFontConfig.custom.families.push('Open+Sans:400,700:latin');
try {
(function() {
var wf=document.createElement('script');
wf.src = '/js/webfont.js';
wf.type='text/javascript';
wf.async='true';
var s=document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) {}
};
var fonts = document.querySelectorAll('nav#font-picker a.font');
for (var i=0; i<fonts.length; i++) {
fonts[i].addEventListener('click', function(e) {
e.preventDefault();
selectedFont = this.href.substring(this.href.indexOf('#')+1);
// TODO: don't change classes on the editor window
//$title.el.className = selectedFont;
//$writer.el.className = selectedFont;
document.querySelector('nav#font-picker li.selected').classList.remove('selected');
this.parentElement.classList.add('selected');
H.set('{{if .Editing}}draft{{.Post.Id}}font{{else}}padFont{{end}}', selectedFont);
if (selectedFont == 'sans') {
loadSans();
}
});
}
var selectedFont = H.get('{{if .Editing}}draft{{.Post.Id}}font{{else}}padFont{{end}}', '{{.Post.Font}}');
var sfe = document.querySelector('nav#font-picker a.font.'+selectedFont);
if (sfe != null) {
sfe.click();
}
var doneTyping = function() {
if (draftDoc == 'lastDoc' || $content.el.value != origDoc) {
H.saveClassic($title, $content, draftDoc);
updateWordCount();
}
};
window.addEventListener('beforeunload', function(e) {
if (draftDoc != 'lastDoc' && $content.el.value == origDoc) {
H.remove(draftDoc);
} else if (!justPublished) {
doneTyping();
}
});
try {
(function() {
var wf=document.createElement('script');
wf.src = '/js/webfont.js';
wf.type='text/javascript';
wf.async='true';
var s=document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) {
// whatevs
}
</script>
<script src="/js/prose.bundle.js"></script>
<link href="/css/icons.css" rel="stylesheet">
</body>
</html>{{end}}
diff --git a/templates/collection-post.tmpl b/templates/collection-post.tmpl
index 54d5298..dd45b9b 100644
--- a/templates/collection-post.tmpl
+++ b/templates/collection-post.tmpl
@@ -1,145 +1,144 @@
{{define "post"}}<!DOCTYPE HTML>
<html {{if .Language.Valid}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">
<head prefix="og: http://ogp.me/ns# article: http://ogp.me/ns/article#">
<meta charset="utf-8">
<title>{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
- {{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{{ if .IsFound }}
<link rel="canonical" href="{{.CanonicalURL .Host}}" />
<meta name="generator" content="WriteFreely">
<meta name="title" content="{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{if .Collection.Title}}{{.Collection.Title}}{{else}}{{.Collection.Alias}}{{end}}">
<meta name="description" content="{{.Summary}}">
{{if gt .Views 1}}<meta name="twitter:label1" value="Views">
<meta name="twitter:data1" value="{{largeNumFmt .Views}}">{{end}}
<meta name="author" content="{{.Collection.Title}}" />
<meta itemprop="description" content="{{.Summary}}">
<meta itemprop="datePublished" content="{{.CreatedDate}}" />
<meta name="twitter:card" content="summary">
<meta name="twitter:description" content="{{.Summary}}">
<meta name="twitter:title" content="{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{if .Collection.Title}}{{.Collection.Title}}{{else}}{{.Collection.Alias}}{{end}}">
{{if gt (len .Images) 0}}<meta name="twitter:image" content="{{index .Images 0}}">{{else}}<meta name="twitter:image" content="{{.Collection.AvatarURL}}">{{end}}
<meta property="og:title" content="{{.PlainDisplayTitle}}" />
<meta property="og:description" content="{{.Summary}}" />
<meta property="og:site_name" content="{{.Collection.DisplayTitle}}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{.CanonicalURL .Host}}" />
<meta property="og:updated_time" content="{{.Created8601}}" />
{{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Collection.AvatarURL}}">{{end}}
<meta property="article:published_time" content="{{.Created8601}}">
{{ end }}
{{template "collection-meta" .}}
{{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}}
{{if .Collection.RenderMathJax}}
<!-- Add mathjax logic -->
{{template "mathjax" . }}
{{end}}
<!-- Add highlighting logic -->
{{template "highlighting" .}}
</head>
<body id="post">
<div id="overlay"></div>
<header>
<h1 dir="{{.Direction}}" id="blog-title"><a rel="author" href="{{if .IsTopLevel}}/{{else}}/{{.Collection.Alias}}/{{end}}" class="h-card p-author">{{.Collection.DisplayTitle}}</a></h1>
<nav>
{{if .PinnedPosts}}
{{range .PinnedPosts}}<a class="pinned{{if eq .Slug.String $.Slug.String}} selected{{end}}" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}
{{end}}
- {{ if and .IsOwner .IsFound }}<span class="views" dir="ltr"><strong>{{largeNumFmt .Views}}</strong> {{pluralize "view" "views" .Views}}</span>
- <a class="xtra-feature" href="/{{if not .SingleUser}}{{.Collection.Alias}}/{{end}}{{.Slug.String}}/edit" dir="{{.Direction}}">Edit</a>
- {{if .IsPinned}}<a class="xtra-feature unpin" href="/{{.Collection.Alias}}/{{.Slug.String}}/unpin" dir="{{.Direction}}" onclick="unpinPost(event, '{{.ID}}')">Unpin</a>{{end}}
+ {{ if and .IsOwner .IsFound }}{{ $N := .Views }}{{ if eq .AppCfg.Lang "eu_ES" }}{{ $N = 1}}{{ end }}<span class="views" dir="ltr"><strong>{{largeNumFmt .Views}}</strong> {{call .Tr "View" $N}}</span>
+ <a class="xtra-feature" href="/{{if not .SingleUser}}{{.Collection.Alias}}/{{end}}{{.Slug.String}}/edit" dir="{{.Direction}}">{{call .Tr "Edit"}}</a>
+ {{if .IsPinned}}<a class="xtra-feature unpin" href="/{{.Collection.Alias}}/{{.Slug.String}}/unpin" dir="{{.Direction}}" onclick="unpinPost(event, '{{.ID}}')">{{call .Tr "Unpin"}}</a>{{end}}
{{ end }}
</nav>
</header>
{{if .Silenced}}
- {{template "user-silenced"}}
+ {{template "user-silenced" (dict "Tr" $.Tr)}}
{{end}}
- <article id="post-body" class="{{.Font}} h-entry {{if not .IsFound}}error-page{{end}}">{{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}{{if .Title.String}}<h2 id="title" class="p-name{{if and $.Collection.Format.ShowDates (not .IsPinned)}} dated{{end}}">{{.FormattedDisplayTitle}}</h2>{{end}}{{if and $.Collection.Format.ShowDates (not .IsPinned) .IsFound}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{.DisplayDate}}</time>{{end}}<div class="e-content">{{.HTMLContent}}</div></article>
+ <article id="post-body" class="{{.Font}} h-entry {{if not .IsFound}}error-page{{end}}">{{if .IsScheduled}}<p class="badge">{{call .Tr "Scheduled"}}</p>{{end}}{{if .Title.String}}<h2 id="title" class="p-name{{if and $.Collection.Format.ShowDates (not .IsPinned)}} dated{{end}}">{{.FormattedDisplayTitle}}</h2>{{end}}{{if and $.Collection.Format.ShowDates (not .IsPinned) .IsFound}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{.DisplayDate}}</time>{{end}}<div class="e-content">{{.HTMLContent}}</div></article>
{{ if .Collection.ShowFooterBranding }}
<footer dir="ltr"><hr><nav><p style="font-size: 0.9em">{{localhtml "published with write.as" .Language.String}}</p></nav></footer>
{{ end }}
</body>
{{if .Collection.CanShowScript}}
{{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
{{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}}
{{end}}
<script src="/js/localdate.js"></script>
<script type="text/javascript">
var pinning = false;
function unpinPost(e, postID) {
e.preventDefault();
if (pinning) {
return;
}
pinning = true;
var $header = document.getElementsByTagName('header')[0];
var callback = function() {
// Hide current page
var $pinnedNavLink = $header.getElementsByTagName('nav')[0].querySelector('.pinned.selected');
$pinnedNavLink.style.display = 'none';
};
var $pinBtn = $header.getElementsByClassName('unpin')[0];
$pinBtn.innerHTML = '...';
var http = new XMLHttpRequest();
var url = "/api/collections/{{.Collection.Alias}}/unpin";
var params = [ { "id": postID } ];
http.open("POST", url, true);
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
pinning = false;
if (http.status == 200) {
callback();
$pinBtn.style.display = 'none';
$pinBtn.innerHTML = 'Pin';
} else if (http.status == 409) {
$pinBtn.innerHTML = 'Unpin';
} else {
$pinBtn.innerHTML = 'Unpin';
alert("Failed to unpin." + (http.status>=500?" Please try again.":""));
}
}
}
http.send(JSON.stringify(params));
};
try { // Fonts
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
(function() {
var wf = document.createElement('script');
wf.src = '/js/webfont.js';
wf.type = 'text/javascript';
wf.async = 'true';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) { /* ¯\_(ツ)_/¯ */ }
</script>
{{if and .Monetization (not .IsOwner)}}
<script src="/js/webmonetization.js"></script>
<script>
window.collAlias = '{{.Collection.Alias}}'
window.postSlug = '{{.Slug.String}}'
initMonetization()
</script>
{{end}}
</html>{{end}}
diff --git a/templates/collection-tags.tmpl b/templates/collection-tags.tmpl
index 6a989a7..edbcc29 100644
--- a/templates/collection-tags.tmpl
+++ b/templates/collection-tags.tmpl
@@ -1,200 +1,203 @@
{{define "collection-tags"}}<!DOCTYPE HTML>
<html>
<head prefix="og: http://ogp.me/ns# article: http://ogp.me/ns/article#">
<meta charset="utf-8">
<title>{{.Tag}} &mdash; {{.Collection.DisplayTitle}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
- {{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<link rel="shortcut icon" href="/favicon.ico" />
{{if not .Collection.IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.Tag}} posts on {{.DisplayTitle}}" href="{{.CanonicalURL}}tag:{{.Tag}}/feed/" />{{end}}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="canonical" href="{{.CanonicalURL}}tag:{{.Tag | tolower}}" />
<meta name="generator" content="Write.as">
<meta name="title" content="{{.Tag}} &mdash; {{.Collection.DisplayTitle}}">
<meta name="description" content="{{.Tag}} posts on {{.Collection.DisplayTitle}}">
<meta name="application-name" content="Write.as">
<meta name="application-url" content="https://write.as">
{{if gt .Views 1}}<meta name="twitter:label1" value="Views">
<meta name="twitter:data1" value="{{largeNumFmt .Views}}">{{end}}
<meta itemprop="name" content="{{.Collection.DisplayTitle}}">
<meta itemprop="description" content="{{.Tag}} posts on {{.Collection.DisplayTitle}}">
<meta name="twitter:card" content="summary">
<meta name="twitter:site" content="@writeas__">
<meta name="twitter:description" content="{{.Tag}} posts on {{.Collection.DisplayTitle}}">
<meta name="twitter:title" content="{{.Tag}} &mdash; {{.Collection.DisplayTitle}}">
<meta name="twitter:image" content="{{.Collection.AvatarURL}}">
<meta property="og:title" content="{{.Tag}} &mdash; {{.Collection.DisplayTitle}}" />
<meta property="og:site_name" content="{{.DisplayTitle}}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{.CanonicalURL}}tag:{{.Tag}}" />
<meta property="og:image" content="{{.Collection.AvatarURL}}">
{{template "collection-meta" .}}
{{if .Collection.StyleSheet}}<style type="text/css">{{.Collection.StyleSheetDisplay}}</style>{{end}}
{{if .Collection.RenderMathJax}}
<!-- Add mathjax logic -->
{{template "mathjax" .}}
{{end}}
<!-- Add highlighting logic -->
{{template "highlighting" . }}
</head>
<body id="subpage">
<div id="overlay"></div>
<header>
<h1 dir="{{.Direction}}" id="blog-title"><a href="{{if .IsTopLevel}}/{{else}}/{{.Collection.Alias}}/{{end}}" class="h-card p-author">{{.Collection.DisplayTitle}}</a></h1>
<nav>
{{if .PinnedPosts}}
{{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.DisplayTitle}}</a>{{end}}
{{end}}
</nav>
</header>
{{if .Silenced}}
- {{template "user-silenced"}}
+ {{template "user-silenced" (dict "Tr" $.Tr)}}
{{end}}
{{if .Posts}}<section id="wrapper" itemscope itemtype="http://schema.org/Blog">{{else}}<div id="wrapper">{{end}}
<h1>{{.Tag}}</h1>
{{template "posts" .}}
{{if .Posts}}</section>{{else}}</div>{{end}}
{{ if .Collection.ShowFooterBranding }}
<footer dir="ltr">
<hr>
<nav>
<p style="font-size: 0.9em"><a class="home pubd" href="/">{{.SiteName}}</a> &middot; powered by <a style="margin-left:0" href="https://writefreely.org">writefreely</a></p>
</nav>
</footer>
{{ end }}
</body>
{{if .CanShowScript}}
{{range .ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
{{if .Collection.Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}}
{{end}}
<script src="/js/localdate.js"></script>
{{if .IsOwner}}
<script src="/js/h.js"></script>
<script src="/js/postactions.js"></script>
{{end}}
<script type="text/javascript">
{{if .IsOwner}}
var deleting = false;
-function delPost(e, id, owned) {
+function delPost(e, id, owned, loc) {
e.preventDefault();
if (deleting) {
return;
}
+ var loc = JSON.parse(loc);
+
// TODO: UNDO!
- if (window.confirm('Are you sure you want to delete this post?')) {
+ if (window.confirm(loc['Are you sure you want to delete this post?'])) {
// AJAX
- deletePost(id, "", function() {
+ deletePost(id, "", loc, function() {
// Remove post from list
var $postEl = document.getElementById('post-' + id);
$postEl.parentNode.removeChild($postEl);
// TODO: add next post from this collection at the bottom
});
}
}
-var deletePost = function(postID, token, callback) {
+var deletePost = function(postID, token, loc, callback) {
deleting = true;
var $delBtn = document.getElementById('post-' + postID).getElementsByClassName('delete action')[0];
$delBtn.innerHTML = '...';
var http = new XMLHttpRequest();
var url = "/api/posts/" + postID;
http.open("DELETE", url, true);
http.onreadystatechange = function() {
if (http.readyState == 4) {
deleting = false;
if (http.status == 204) {
callback();
} else if (http.status == 409) {
$delBtn.innerHTML = 'delete';
- alert("Post is synced to another account. Delete the post from that account instead.");
+ alert(loc["Post is synced to another account. Delete the post from that account instead."]);
// TODO: show "remove" button instead of "delete" now
// Persist that state.
// Have it remove the post locally only.
} else {
$delBtn.innerHTML = 'delete';
- alert("Failed to delete." + (http.status>=500?" Please try again.":""));
+ alert(loc["Failed to pin."] + (http.status>=500?" " + loc["Please try again."]:""));
}
}
}
http.send();
};
var pinning = false;
-function pinPost(e, postID, slug, title) {
+function pinPost(e, postID, slug, title, loc) {
e.preventDefault();
if (pinning) {
return;
}
pinning = true;
+ var loc = JSON.parse(loc)
+
var callback = function() {
// Visibly remove post from collection
var $postEl = document.getElementById('post-' + postID);
$postEl.parentNode.removeChild($postEl);
var $header = document.getElementsByTagName('header')[0];
var $pinnedNavs = $header.getElementsByTagName('nav');
// Add link to nav
var link = '<a class="pinned" href="{{if not .SingleUser}}/{{.Alias}}/{{end}}'+slug+'">'+title+'</a>';
if ($pinnedNavs.length == 0) {
$header.insertAdjacentHTML("beforeend", '<nav>'+link+'</nav>');
} else {
$pinnedNavs[0].insertAdjacentHTML("beforeend", link);
}
};
var $pinBtn = document.getElementById('post-' + postID).getElementsByClassName('pin action')[0];
$pinBtn.innerHTML = '...';
var http = new XMLHttpRequest();
var url = "/api/collections/{{.Alias}}/pin";
var params = [ { "id": postID } ];
http.open("POST", url, true);
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
pinning = false;
if (http.status == 200) {
callback();
} else if (http.status == 409) {
$pinBtn.innerHTML = 'pin';
- alert("Post is synced to another account. Delete the post from that account instead.");
+ alert(loc["Post is synced to another account. Delete the post from that account instead."]);
// TODO: show "remove" button instead of "delete" now
// Persist that state.
// Have it remove the post locally only.
} else {
$pinBtn.innerHTML = 'pin';
- alert("Failed to pin." + (http.status>=500?" Please try again.":""));
+ alert(loc["Failed to pin."] + (http.status>=500?" " + loc["Please try again."]:""));
}
}
}
http.send(JSON.stringify(params));
};
{{end}}
try { // Fonts
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
(function() {
var wf = document.createElement('script');
wf.src = '/js/webfont.js';
wf.type = 'text/javascript';
wf.async = 'true';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) { /* ¯\_(ツ)_/¯ */ }
</script>
</html>{{end}}
diff --git a/templates/collection.tmpl b/templates/collection.tmpl
index 6e3d2dc..6593193 100644
--- a/templates/collection.tmpl
+++ b/templates/collection.tmpl
@@ -1,252 +1,255 @@
{{define "collection"}}<!DOCTYPE HTML>
<html {{if .Language}}lang="{{.Language}}"{{end}} dir="{{.Direction}}">
<head>
<meta charset="utf-8">
<title>{{.DisplayTitle}}{{if not .SingleUser}} &mdash; {{.SiteName}}{{end}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
- {{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="canonical" href="{{.CanonicalURL}}">
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}}
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}}
{{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} &raquo; Feed" href="{{.CanonicalURL}}feed/" />{{end}}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="generator" content="WriteFreely">
<meta name="description" content="{{.PlainDescription}}">
<meta itemprop="name" content="{{.DisplayTitle}}">
<meta itemprop="description" content="{{.PlainDescription}}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.DisplayTitle}}">
<meta name="twitter:image" content="{{.AvatarURL}}">
<meta name="twitter:description" content="{{.PlainDescription}}">
<meta property="og:title" content="{{.DisplayTitle}}" />
<meta property="og:site_name" content="{{.DisplayTitle}}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{.CanonicalURL}}" />
<meta property="og:description" content="{{.PlainDescription}}" />
<meta property="og:image" content="{{.AvatarURL}}">
{{template "collection-meta" .}}
{{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}}
{{if .RenderMathJax}}
<!-- Add mathjax logic -->
{{template "mathjax" .}}
{{end}}
<!-- Add highlighting logic -->
{{template "highlighting" . }}
</head>
<body id="collection" itemscope itemtype="http://schema.org/WebPage">
{{if or .IsOwner .SingleUser}}
<nav id="manage"><ul>
<li class="has-submenu"><a onclick="void(0)">&#9776; Menu</a>
<ul>
{{ if .IsOwner }}
{{if .SingleUser}}
<li><a href="/me/new">New Post</a></li>
{{else}}
<li><a href="/#{{.Alias}}" class="write">{{.SiteName}}</a></li>
{{end}}
- {{if .SimpleNav}}<li><a href="/new#{{.Alias}}">New Post</a></li>{{end}}
- <li><a href="/me/c/{{.Alias}}">Customize</a></li>
- <li><a href="/me/c/{{.Alias}}/stats">Stats</a></li>
+ {{if .SimpleNav}}<li><a href="/new#{{.Alias}}">{{call .Tr "New Post"}}</a></li>{{end}}
+ <li><a href="/me/c/{{.Alias}}">{{call .Tr "Customize"}}</a></li>
+ <li><a href="/me/c/{{.Alias}}/stats">{{call .Tr "Stats"}}</a></li>
<li class="separator"><hr /></li>
- {{if not .SingleUser}}<li><a href="/me/c/"><img class="ic-18dp" src="/img/ic_blogs_dark@2x.png" /> View Blogs</a></li>{{end}}
- <li><a href="/me/posts/"><img class="ic-18dp" src="/img/ic_list_dark@2x.png" /> View Drafts</a></li>
+ {{if not .SingleUser}}<li><a href="/me/c/"><img class="ic-18dp" src="/img/ic_blogs_dark@2x.png" /> {{call .Tr "View Blog" 2}}</a></li>{{end}}
+ <li><a href="/me/posts/"><img class="ic-18dp" src="/img/ic_list_dark@2x.png" /> {{call .Tr "View Draft" 2}}</a></li>
{{ else }}
<li><a href="/login">Log in{{if .IsProtected}} to {{.DisplayTitle}}{{end}}</a></li>
{{if .IsProtected}}
<li class="separator"><hr /></li>
- <li><a href="/logout">Log out</a></li>
+ <li><a href="/logout">{{call .Tr "Log out"}}</a></li>
{{end}}
{{ end }}
</ul>
</li>
</ul></nav>
{{else if .IsCollLoggedIn}}
<nav id="manage" class="shiny"><ul>
<li class="has-submenu"><a onclick="void(0)">&#9776; Menu</a>
<ul>
<li class="menu-heading" style="padding: .5rem .75rem; box-sizing: border-box;">{{.DisplayTitle}}</li>
- <li><a href="{{.CanonicalURL}}logout">Log out</a></li>
+ <li><a href="{{.CanonicalURL}}logout">{{call .Tr "Log out"}}</a></li>
</ul>
</li>
</ul></nav>
{{end}}
<header>
{{if .Silenced}}
- {{template "user-silenced"}}
+ {{template "user-silenced" (dict "Tr" $.Tr)}}
{{end}}
<h1 dir="{{.Direction}}" id="blog-title">{{if .Posts}}{{else}}<span class="writeas-prefix"><a href="/">write.as</a></span> {{end}}<a href="/{{if .IsTopLevel}}{{else}}{{.Prefix}}{{.Alias}}/{{end}}" class="h-card p-author u-url" rel="me author">{{.DisplayTitle}}</a></h1>
{{if .Description}}<p class="description p-note">{{.DisplayDescription}}</p>{{end}}
{{/*if not .Public/*}}
<!--p class="meta-note"><span>Private collection</span>. Only you can see this page.</p-->
{{/*end*/}}
{{if .PinnedPosts}}<nav>
{{range .PinnedPosts}}<a class="pinned" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}</nav>
{{end}}
</header>
{{if .Posts}}<section id="wrapper" itemscope itemtype="http://schema.org/Blog">{{else}}<div id="wrapper">{{end}}
{{if .IsWelcome}}
<div id="welcome">
<h2>Welcome, <strong>{{.Username}}</strong>!</h2>
<p>This is your new blog.</p>
<p><a class="simple-cta" href="/#{{.Alias}}">Start writing</a>, or <a class="simple-cta" href="/me/c/{{.Alias}}">customize</a> your blog.</p>
<p>Check out our <a class="simple-cta" href="https://guides.write.as/writing/?pk_campaign=welcome">writing guide</a> to see what else you can do, and <a class="simple-cta" href="/contact">get in touch</a> anytime with questions or feedback.</p>
</div>
{{end}}
{{template "posts" .}}
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
{{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}}
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">&#8672; {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} &#8674;</a>{{end}}
{{else}}
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">&#8672; Older</a>{{end}}
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">Newer &#8674;</a>{{end}}
{{end}}
</nav>{{end}}
{{if .Posts}}</section>{{else}}</div>{{end}}
{{if .ShowFooterBranding }}
<footer>
<hr />
<nav dir="ltr">
{{if not .SingleUser}}<a class="home pubd" href="/">{{.SiteName}}</a> &middot; {{end}}powered by <a style="margin-left:0" href="https://writefreely.org">writefreely</a>
</nav>
</footer>
{{ end }}
</body>
{{if .CanShowScript}}
{{range .ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
{{if .Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}}
{{end}}
<script src="/js/h.js"></script>
<script src="/js/postactions.js"></script>
<script src="/js/localdate.js"></script>
<script type="text/javascript" src="/js/menu.js"></script>
<script type="text/javascript">
var deleting = false;
-function delPost(e, id, owned) {
+function delPost(e, id, owned, loc) {
e.preventDefault();
if (deleting) {
return;
}
+ var loc = JSON.parse(loc);
+
// TODO: UNDO!
- if (window.confirm('Are you sure you want to delete this post?')) {
+ if (window.confirm(loc['Are you sure you want to delete this post?'])) {
// AJAX
deletePost(id, "", function() {
// Remove post from list
var $postEl = document.getElementById('post-' + id);
$postEl.parentNode.removeChild($postEl);
// TODO: add next post from this collection at the bottom
});
}
}
-var deletePost = function(postID, token, callback) {
+var deletePost = function(postID, token, loc, callback) {
deleting = true;
var $delBtn = document.getElementById('post-' + postID).getElementsByClassName('delete action')[0];
$delBtn.innerHTML = '...';
var http = new XMLHttpRequest();
var url = "/api/posts/" + postID;
http.open("DELETE", url, true);
http.onreadystatechange = function() {
if (http.readyState == 4) {
deleting = false;
if (http.status == 204) {
callback();
} else if (http.status == 409) {
$delBtn.innerHTML = 'delete';
- alert("Post is synced to another account. Delete the post from that account instead.");
+ alert(loc["Post is synced to another account. Delete the post from that account instead."]);
// TODO: show "remove" button instead of "delete" now
// Persist that state.
// Have it remove the post locally only.
} else {
$delBtn.innerHTML = 'delete';
- alert("Failed to delete." + (http.status>=500?" Please try again.":""));
+ alert(loc["Failed to delete."] + (http.status>=500?" " + loc["Please try again."]:""));
}
}
}
http.send();
};
var pinning = false;
-function pinPost(e, postID, slug, title) {
+function pinPost(e, postID, slug, title, loc) {
e.preventDefault();
if (pinning) {
return;
}
pinning = true;
+
+ var loc = JSON.parse(loc)
var callback = function() {
// Visibly remove post from collection
var $postEl = document.getElementById('post-' + postID);
$postEl.parentNode.removeChild($postEl);
var $header = document.getElementsByTagName('header')[0];
var $pinnedNavs = $header.getElementsByTagName('nav');
// Add link to nav
var link = '<a class="pinned" href="{{if not .SingleUser}}/{{.Alias}}/{{end}}'+slug+'">'+title+'</a>';
if ($pinnedNavs.length == 0) {
$header.insertAdjacentHTML("beforeend", '<nav>'+link+'</nav>');
} else {
$pinnedNavs[0].insertAdjacentHTML("beforeend", link);
}
};
var $pinBtn = document.getElementById('post-' + postID).getElementsByClassName('pin action')[0];
$pinBtn.innerHTML = '...';
var http = new XMLHttpRequest();
var url = "/api/collections/{{.Alias}}/pin";
var params = [ { "id": postID } ];
http.open("POST", url, true);
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
pinning = false;
if (http.status == 200) {
callback();
} else if (http.status == 409) {
$pinBtn.innerHTML = 'pin';
- alert("Post is synced to another account. Delete the post from that account instead.");
+ alert(loc["Post is synced to another account. Delete the post from that account instead."]);
// TODO: show "remove" button instead of "delete" now
// Persist that state.
// Have it remove the post locally only.
} else {
$pinBtn.innerHTML = 'pin';
- alert("Failed to pin." + (http.status>=500?" Please try again.":""));
+ alert(loc["Failed to pin."] + (http.status>=500?" " + loc["Please try again."]:""));
}
}
}
http.send(JSON.stringify(params));
};
try {
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
(function() {
var wf = document.createElement('script');
wf.src = '/js/webfont.js';
wf.type = 'text/javascript';
wf.async = 'true';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) {}
</script>
</html>{{end}}
diff --git a/templates/edit-meta.tmpl b/templates/edit-meta.tmpl
index d3f93a8..ef35c01 100644
--- a/templates/edit-meta.tmpl
+++ b/templates/edit-meta.tmpl
@@ -1,375 +1,375 @@
{{define "edit-meta"}}<!DOCTYPE HTML>
<html>
<head>
- <title>Edit metadata: {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}} &mdash; {{.SiteName}}</title>
+ <title>{{call .Tr "Edit metadata"}}: {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}} &mdash; {{.SiteName}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style type="text/css">
dt {
width: 8em;
}
.error {
display: none;
}
.mono {
font-style: normal;
}
#set-now {
font-style: italic;
margin-left: 0.25rem;
}
.content-container h2 a {
font-size: .6em;
font-weight: normal;
margin-left: 1em;
}
.content-container h2 a:link, .content-container h2 a:visited {
color: blue;
}
.content-container h2 a:hover {
text-decoration: underline;
}
</style>
</head>
<body id="pad-sub" class="light">
<header id="tools">
<div id="clip">
- <h1><a href="/me/c/" title="View blogs"><img class="ic-24dp" src="/img/ic_blogs_dark@2x.png" /></a></h1>
+ <h1><a href="/me/c/" title={{call .Tr "View blog" 2}}><img class="ic-24dp" src="/img/ic_blogs_dark@2x.png" /></a></h1>
<nav id="target" class=""><ul>
- <li>{{if .EditCollection}}<a href="{{.EditCollection.CanonicalURL}}">{{.EditCollection.Title}}</a>{{else}}<a>Draft</a>{{end}}</li>
+ <li>{{if .EditCollection}}<a href="{{.EditCollection.CanonicalURL}}">{{.EditCollection.Title}}</a>{{else}}<a>{{call .Tr "Draft"}}</a>{{end}}</li>
</ul></nav>
</div>
<div id="belt">
- <div class="tool if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit{{else}}/{{.Post.Id}}/edit{{end}}" title="Edit post" id="edit"><img class="ic-24dp" src="/img/ic_edit_dark@2x.png" /></a></div>
- <div class="tool if-room room-2"><a href="#theme" title="Toggle theme" id="toggle-theme"><img class="ic-24dp" src="/img/ic_brightness_dark@2x.png" /></a></div>
- <div class="tool if-room room-1"><a href="/me/posts/" title="View posts" id="view-posts"><img class="ic-24dp" src="/img/ic_list_dark@2x.png" /></a></div>
+ <div class="tool if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit{{else}}/{{.Post.Id}}/edit{{end}}" title={{call .Tr "Edit post"}} id="edit"><img class="ic-24dp" src="/img/ic_edit_dark@2x.png" /></a></div>
+ <div class="tool if-room room-2"><a href="#theme" title={{call .Tr "Toggle theme"}} id="toggle-theme"><img class="ic-24dp" src="/img/ic_brightness_dark@2x.png" /></a></div>
+ <div class="tool if-room room-1"><a href="/me/posts/" title={{call .Tr "View post" 2}} id="view-posts"><img class="ic-24dp" src="/img/ic_list_dark@2x.png" /></a></div>
</div>
</header>
<div class="content-container tight">
<form action="/api/{{if .EditCollection}}collections/{{.EditCollection.Alias}}/{{end}}posts/{{.Post.Id}}" method="post" onsubmit="return updateMeta()">
- <h2>Edit metadata: {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}} <a href="/{{if .EditCollection}}{{if not .SingleUser}}{{.EditCollection.Alias}}/{{end}}{{.Post.Slug}}{{else}}{{if .SingleUser}}d/{{end}}{{.Post.Id}}{{end}}">view post</a></h2>
+ <h2>{{call .Tr "Edit metadata"}}: {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}} <a href="/{{if .EditCollection}}{{if not .SingleUser}}{{.EditCollection.Alias}}/{{end}}{{.Post.Slug}}{{else}}{{if .SingleUser}}d/{{end}}{{.Post.Id}}{{end}}">{{call .Tr "View post"}}</a></h2>
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
<dl class="dl-horizontal">
{{if .EditCollection}}
- <dt><label for="slug">Slug</label></dt>
+ <dt><label for="slug">{{call .Tr "Slug"}}</label></dt>
<dd><input type="text" id="slug" name="slug" value="{{.Post.Slug}}" /></dd>
{{end}}
- <dt><label for="lang">Language</label></dt>
+ <dt><label for="lang">{{call .Tr "Language"}}</label></dt>
<dd>
<select name="lang" id="lang" dir="auto" class="inputform">
<option value=""></option>
<option value="ab"{{if eq "ab" .Post.Language.String}} selected="selected"{{end}}>аҧсуа бызшәа, аҧсшәа</option>
<option value="aa"{{if eq "aa" .Post.Language.String}} selected="selected"{{end}}>Afaraf</option>
<option value="af"{{if eq "af" .Post.Language.String}} selected="selected"{{end}}>Afrikaans</option>
<option value="ak"{{if eq "ak" .Post.Language.String}} selected="selected"{{end}}>Akan</option>
<option value="sq"{{if eq "sq" .Post.Language.String}} selected="selected"{{end}}>Shqip</option>
<option value="am"{{if eq "am" .Post.Language.String}} selected="selected"{{end}}>አማርኛ</option>
<option dir="rtl" value="ar"{{if eq "ar" .Post.Language.String}} selected="selected"{{end}}>العربية</option>
<option value="an"{{if eq "an" .Post.Language.String}} selected="selected"{{end}}>aragonés</option>
<option value="hy"{{if eq "hy" .Post.Language.String}} selected="selected"{{end}}>Հայերեն</option>
<option value="as"{{if eq "as" .Post.Language.String}} selected="selected"{{end}}>অসমীয়া</option>
<option value="av"{{if eq "av" .Post.Language.String}} selected="selected"{{end}}>авар мацӀ, магӀарул мацӀ</option>
<option value="ae"{{if eq "ae" .Post.Language.String}} selected="selected"{{end}}>avesta</option>
<option value="ay"{{if eq "ay" .Post.Language.String}} selected="selected"{{end}}>aymar aru</option>
<option value="az"{{if eq "az" .Post.Language.String}} selected="selected"{{end}}>azərbaycan dili</option>
<option value="bm"{{if eq "bm" .Post.Language.String}} selected="selected"{{end}}>bamanankan</option>
<option value="ba"{{if eq "ba" .Post.Language.String}} selected="selected"{{end}}>башҡорт теле</option>
<option value="eu"{{if eq "eu" .Post.Language.String}} selected="selected"{{end}}>euskara, euskera</option>
<option value="be"{{if eq "be" .Post.Language.String}} selected="selected"{{end}}>беларуская мова</option>
<option value="bn"{{if eq "bn" .Post.Language.String}} selected="selected"{{end}}>বাংলা</option>
<option value="bh"{{if eq "bh" .Post.Language.String}} selected="selected"{{end}}>भोजपुरी</option>
<option value="bi"{{if eq "bi" .Post.Language.String}} selected="selected"{{end}}>Bislama</option>
<option value="bs"{{if eq "bs" .Post.Language.String}} selected="selected"{{end}}>bosanski jezik</option>
<option value="br"{{if eq "br" .Post.Language.String}} selected="selected"{{end}}>brezhoneg</option>
<option value="bg"{{if eq "bg" .Post.Language.String}} selected="selected"{{end}}>български език</option>
<option value="my"{{if eq "my" .Post.Language.String}} selected="selected"{{end}}>ဗမာစာ</option>
<option value="ca"{{if eq "ca" .Post.Language.String}} selected="selected"{{end}}>català</option>
<option value="ch"{{if eq "ch" .Post.Language.String}} selected="selected"{{end}}>Chamoru</option>
<option value="ce"{{if eq "ce" .Post.Language.String}} selected="selected"{{end}}>нохчийн мотт</option>
<option value="ny"{{if eq "ny" .Post.Language.String}} selected="selected"{{end}}>chiCheŵa, chinyanja</option>
<option value="zh"{{if eq "zh" .Post.Language.String}} selected="selected"{{end}}>中文 (Zhōngwén), 汉语, 漢語</option>
<option value="cv"{{if eq "cv" .Post.Language.String}} selected="selected"{{end}}>чӑваш чӗлхи</option>
<option value="kw"{{if eq "kw" .Post.Language.String}} selected="selected"{{end}}>Kernewek</option>
<option value="co"{{if eq "co" .Post.Language.String}} selected="selected"{{end}}>corsu, lingua corsa</option>
<option value="cr"{{if eq "cr" .Post.Language.String}} selected="selected"{{end}}>ᓀᐦᐃᔭᐍᐏᐣ</option>
<option value="hr"{{if eq "hr" .Post.Language.String}} selected="selected"{{end}}>hrvatski jezik</option>
<option value="cs"{{if eq "cs" .Post.Language.String}} selected="selected"{{end}}>čeština, český jazyk</option>
<option value="da"{{if eq "da" .Post.Language.String}} selected="selected"{{end}}>dansk</option>
<option dir="rtl" value="dv"{{if eq "dv" .Post.Language.String}} selected="selected"{{end}}>ދިވެހި</option>
<option value="nl"{{if eq "nl" .Post.Language.String}} selected="selected"{{end}}>Nederlands, Vlaams</option>
<option value="dz"{{if eq "dz" .Post.Language.String}} selected="selected"{{end}}>རྫོང་ཁ</option>
<option value="en"{{if eq "en" .Post.Language.String}} selected="selected"{{end}}>English</option>
<option value="eo"{{if eq "eo" .Post.Language.String}} selected="selected"{{end}}>Esperanto</option>
<option value="et"{{if eq "et" .Post.Language.String}} selected="selected"{{end}}>eesti, eesti keel</option>
<option value="ee"{{if eq "ee" .Post.Language.String}} selected="selected"{{end}}>Eʋegbe</option>
<option value="fo"{{if eq "fo" .Post.Language.String}} selected="selected"{{end}}>føroyskt</option>
<option value="fj"{{if eq "fj" .Post.Language.String}} selected="selected"{{end}}>vosa Vakaviti</option>
<option value="fi"{{if eq "fi" .Post.Language.String}} selected="selected"{{end}}>suomi, suomen kieli</option>
<option value="fr"{{if eq "fr" .Post.Language.String}} selected="selected"{{end}}>français, langue française</option>
<option value="ff"{{if eq "ff" .Post.Language.String}} selected="selected"{{end}}>Fulfulde, Pulaar, Pular</option>
<option value="gl"{{if eq "gl" .Post.Language.String}} selected="selected"{{end}}>Galego</option>
<option value="ka"{{if eq "ka" .Post.Language.String}} selected="selected"{{end}}>ქართული</option>
<option value="de"{{if eq "de" .Post.Language.String}} selected="selected"{{end}}>Deutsch</option>
<option value="el"{{if eq "el" .Post.Language.String}} selected="selected"{{end}}>ελληνικά</option>
<option value="gn"{{if eq "gn" .Post.Language.String}} selected="selected"{{end}}>Avañe'ẽ</option>
<option value="gu"{{if eq "gu" .Post.Language.String}} selected="selected"{{end}}>ગુજરાતી</option>
<option value="ht"{{if eq "ht" .Post.Language.String}} selected="selected"{{end}}>Kreyòl ayisyen</option>
<option dir="rtl" value="ha"{{if eq "ha" .Post.Language.String}} selected="selected"{{end}}>(Hausa) هَوُسَ</option>
<option dir="rtl" value="he"{{if eq "he" .Post.Language.String}} selected="selected"{{end}}>עברית</option>
<option value="hz"{{if eq "hz" .Post.Language.String}} selected="selected"{{end}}>Otjiherero</option>
<option value="hi"{{if eq "hi" .Post.Language.String}} selected="selected"{{end}}>हिन्दी, हिंदी</option>
<option value="ho"{{if eq "ho" .Post.Language.String}} selected="selected"{{end}}>Hiri Motu</option>
<option value="hu"{{if eq "hu" .Post.Language.String}} selected="selected"{{end}}>magyar</option>
<option value="ia"{{if eq "ia" .Post.Language.String}} selected="selected"{{end}}>Interlingua</option>
<option value="id"{{if eq "id" .Post.Language.String}} selected="selected"{{end}}>Bahasa Indonesia</option>
<option value="ie"{{if eq "ie" .Post.Language.String}} selected="selected"{{end}}>Interlingue</option>
<option value="ga"{{if eq "ga" .Post.Language.String}} selected="selected"{{end}}>Gaeilge</option>
<option value="ig"{{if eq "ig" .Post.Language.String}} selected="selected"{{end}}>Asụsụ Igbo</option>
<option value="ik"{{if eq "ik" .Post.Language.String}} selected="selected"{{end}}>Iñupiaq, Iñupiatun</option>
<option value="io"{{if eq "io" .Post.Language.String}} selected="selected"{{end}}>Ido</option>
<option value="is"{{if eq "is" .Post.Language.String}} selected="selected"{{end}}>Íslenska</option>
<option value="it"{{if eq "it" .Post.Language.String}} selected="selected"{{end}}>Italiano</option>
<option value="iu"{{if eq "iu" .Post.Language.String}} selected="selected"{{end}}>ᐃᓄᒃᑎᑐᑦ</option>
<option value="ja"{{if eq "ja" .Post.Language.String}} selected="selected"{{end}}>日本語 (にほんご)</option>
<option value="jv"{{if eq "jv" .Post.Language.String}} selected="selected"{{end}}>ꦧꦱꦗꦮ, Basa Jawa</option>
<option value="kl"{{if eq "kl" .Post.Language.String}} selected="selected"{{end}}>kalaallisut, kalaallit oqaasii</option>
<option value="kn"{{if eq "kn" .Post.Language.String}} selected="selected"{{end}}>ಕನ್ನಡ</option>
<option value="kr"{{if eq "kr" .Post.Language.String}} selected="selected"{{end}}>Kanuri</option>
<option value="ks"{{if eq "ks" .Post.Language.String}} selected="selected"{{end}}>कश्मीरी, كشميري‎</option>
<option value="kk"{{if eq "kk" .Post.Language.String}} selected="selected"{{end}}>қазақ тілі</option>
<option value="km"{{if eq "km" .Post.Language.String}} selected="selected"{{end}}>ខ្មែរ, ខេមរភាសា, ភាសាខ្មែរ</option>
<option value="ki"{{if eq "ki" .Post.Language.String}} selected="selected"{{end}}>Gĩkũyũ</option>
<option value="rw"{{if eq "rw" .Post.Language.String}} selected="selected"{{end}}>Ikinyarwanda</option>
<option value="ky"{{if eq "ky" .Post.Language.String}} selected="selected"{{end}}>Кыргызча, Кыргыз тили</option>
<option value="kv"{{if eq "kv" .Post.Language.String}} selected="selected"{{end}}>коми кыв</option>
<option value="kg"{{if eq "kg" .Post.Language.String}} selected="selected"{{end}}>Kikongo</option>
<option value="ko"{{if eq "ko" .Post.Language.String}} selected="selected"{{end}}>한국어</option>
<option value="ku"{{if eq "ku" .Post.Language.String}} selected="selected"{{end}}>Kurdî, كوردی‎</option>
<option value="kj"{{if eq "kj" .Post.Language.String}} selected="selected"{{end}}>Kuanyama</option>
<option value="la"{{if eq "la" .Post.Language.String}} selected="selected"{{end}}>latine, lingua latina</option>
<option value="lb"{{if eq "lb" .Post.Language.String}} selected="selected"{{end}}>Lëtzebuergesch</option>
<option value="lg"{{if eq "lg" .Post.Language.String}} selected="selected"{{end}}>Luganda</option>
<option value="li"{{if eq "li" .Post.Language.String}} selected="selected"{{end}}>Limburgs</option>
<option value="ln"{{if eq "ln" .Post.Language.String}} selected="selected"{{end}}>Lingála</option>
<option value="lo"{{if eq "lo" .Post.Language.String}} selected="selected"{{end}}>ພາສາລາວ</option>
<option value="lt"{{if eq "lt" .Post.Language.String}} selected="selected"{{end}}>lietuvių kalba</option>
<option value="lu"{{if eq "lu" .Post.Language.String}} selected="selected"{{end}}>Kiluba</option>
<option value="lv"{{if eq "lv" .Post.Language.String}} selected="selected"{{end}}>Latviešu Valoda</option>
<option value="gv"{{if eq "gv" .Post.Language.String}} selected="selected"{{end}}>Gaelg, Gailck</option>
<option value="mk"{{if eq "mk" .Post.Language.String}} selected="selected"{{end}}>македонски јазик</option>
<option value="mg"{{if eq "mg" .Post.Language.String}} selected="selected"{{end}}>fiteny malagasy</option>
<option value="ms"{{if eq "ms" .Post.Language.String}} selected="selected"{{end}}>Bahasa Melayu, بهاس ملايو‎</option>
<option value="ml"{{if eq "ml" .Post.Language.String}} selected="selected"{{end}}>മലയാളം</option>
<option value="mt"{{if eq "mt" .Post.Language.String}} selected="selected"{{end}}>Malti</option>
<option value="mi"{{if eq "mi" .Post.Language.String}} selected="selected"{{end}}>te reo Māori</option>
<option value="mr"{{if eq "mr" .Post.Language.String}} selected="selected"{{end}}>मराठी</option>
<option value="mh"{{if eq "mh" .Post.Language.String}} selected="selected"{{end}}>Kajin M̧ajeļ</option>
<option value="mn"{{if eq "mn" .Post.Language.String}} selected="selected"{{end}}>Монгол хэл</option>
<option value="na"{{if eq "na" .Post.Language.String}} selected="selected"{{end}}>Dorerin Naoero</option>
<option value="nv"{{if eq "nv" .Post.Language.String}} selected="selected"{{end}}>Diné bizaad</option>
<option value="nd"{{if eq "nd" .Post.Language.String}} selected="selected"{{end}}>isiNdebele</option>
<option value="ne"{{if eq "ne" .Post.Language.String}} selected="selected"{{end}}>नेपाली</option>
<option value="ng"{{if eq "ng" .Post.Language.String}} selected="selected"{{end}}>Owambo</option>
<option value="nb"{{if eq "nb" .Post.Language.String}} selected="selected"{{end}}>Norsk Bokmål</option>
<option value="nn"{{if eq "nn" .Post.Language.String}} selected="selected"{{end}}>Norsk Nynorsk</option>
<option value="no"{{if eq "no" .Post.Language.String}} selected="selected"{{end}}>Norsk</option>
<option value="ii"{{if eq "ii" .Post.Language.String}} selected="selected"{{end}}>ꆈꌠ꒿ Nuosuhxop</option>
<option value="nr"{{if eq "nr" .Post.Language.String}} selected="selected"{{end}}>isiNdebele</option>
<option value="oc"{{if eq "oc" .Post.Language.String}} selected="selected"{{end}}>occitan, lenga d'òc</option>
<option value="oj"{{if eq "oj" .Post.Language.String}} selected="selected"{{end}}>ᐊᓂᔑᓈᐯᒧᐎᓐ</option>
<option value="cu"{{if eq "cu" .Post.Language.String}} selected="selected"{{end}}>ѩзыкъ словѣньскъ</option>
<option value="om"{{if eq "om" .Post.Language.String}} selected="selected"{{end}}>Afaan Oromoo</option>
<option value="or"{{if eq "or" .Post.Language.String}} selected="selected"{{end}}>ଓଡ଼ିଆ</option>
<option value="os"{{if eq "os" .Post.Language.String}} selected="selected"{{end}}>ирон æвзаг</option>
<option value="pa"{{if eq "pa" .Post.Language.String}} selected="selected"{{end}}>ਪੰਜਾਬੀ</option>
<option value="pi"{{if eq "pi" .Post.Language.String}} selected="selected"{{end}}>पाऴि</option>
<option dir="rtl" value="fa"{{if eq "fa" .Post.Language.String}} selected="selected"{{end}}>فارسی</option>
<option value="pl"{{if eq "pl" .Post.Language.String}} selected="selected"{{end}}>Język Polski, Polszczyzna</option>
<option dir="rtl" value="ps"{{if eq "ps" .Post.Language.String}} selected="selected"{{end}}>پښتو</option>
<option value="pt"{{if eq "pt" .Post.Language.String}} selected="selected"{{end}}>Português</option>
<option value="qu"{{if eq "qu" .Post.Language.String}} selected="selected"{{end}}>Runa Simi, Kichwa</option>
<option value="rm"{{if eq "rm" .Post.Language.String}} selected="selected"{{end}}>Rumantsch Grischun</option>
<option value="rn"{{if eq "rn" .Post.Language.String}} selected="selected"{{end}}>Ikirundi</option>
<option value="ro"{{if eq "ro" .Post.Language.String}} selected="selected"{{end}}>Română</option>
<option value="ru"{{if eq "ru" .Post.Language.String}} selected="selected"{{end}}>Русский</option>
<option value="sa"{{if eq "sa" .Post.Language.String}} selected="selected"{{end}}>संस्कृतम्</option>
<option value="sc"{{if eq "sc" .Post.Language.String}} selected="selected"{{end}}>sardu</option>
<option value="sd"{{if eq "sd" .Post.Language.String}} selected="selected"{{end}}>सिन्धी, سنڌي، سندھی‎</option>
<option value="se"{{if eq "se" .Post.Language.String}} selected="selected"{{end}}>Davvisámegiella</option>
<option value="sm"{{if eq "sm" .Post.Language.String}} selected="selected"{{end}}>gagana fa'a Samoa</option>
<option value="sg"{{if eq "sg" .Post.Language.String}} selected="selected"{{end}}>yângâ tî sängö</option>
<option value="sr"{{if eq "sr" .Post.Language.String}} selected="selected"{{end}}>српски језик</option>
<option value="gd"{{if eq "gd" .Post.Language.String}} selected="selected"{{end}}>Gàidhlig</option>
<option value="sn"{{if eq "sn" .Post.Language.String}} selected="selected"{{end}}>chiShona</option>
<option value="si"{{if eq "si" .Post.Language.String}} selected="selected"{{end}}>සිංහල</option>
<option value="sk"{{if eq "sk" .Post.Language.String}} selected="selected"{{end}}>Slovenčina, Slovenský Jazyk</option>
<option value="sl"{{if eq "sl" .Post.Language.String}} selected="selected"{{end}}>Slovenski Jezik, Slovenščina</option>
<option value="so"{{if eq "so" .Post.Language.String}} selected="selected"{{end}}>Soomaaliga, af Soomaali</option>
<option value="st"{{if eq "st" .Post.Language.String}} selected="selected"{{end}}>Sesotho</option>
<option value="es"{{if eq "es" .Post.Language.String}} selected="selected"{{end}}>Español</option>
<option value="su"{{if eq "su" .Post.Language.String}} selected="selected"{{end}}>Basa Sunda</option>
<option value="sw"{{if eq "sw" .Post.Language.String}} selected="selected"{{end}}>Kiswahili</option>
<option value="ss"{{if eq "ss" .Post.Language.String}} selected="selected"{{end}}>SiSwati</option>
<option value="sv"{{if eq "sv" .Post.Language.String}} selected="selected"{{end}}>Svenska</option>
<option value="ta"{{if eq "ta" .Post.Language.String}} selected="selected"{{end}}>தமிழ்</option>
<option value="te"{{if eq "te" .Post.Language.String}} selected="selected"{{end}}>తెలుగు</option>
<option value="tg"{{if eq "tg" .Post.Language.String}} selected="selected"{{end}}>тоҷикӣ, toçikī, تاجیکی‎</option>
<option value="th"{{if eq "th" .Post.Language.String}} selected="selected"{{end}}>ไทย</option>
<option value="ti"{{if eq "ti" .Post.Language.String}} selected="selected"{{end}}>ትግርኛ</option>
<option value="bo"{{if eq "bo" .Post.Language.String}} selected="selected"{{end}}>བོད་ཡིག</option>
<option value="tk"{{if eq "tk" .Post.Language.String}} selected="selected"{{end}}>Türkmen, Түркмен</option>
<option value="tl"{{if eq "tl" .Post.Language.String}} selected="selected"{{end}}>Wikang Tagalog</option>
<option value="tn"{{if eq "tn" .Post.Language.String}} selected="selected"{{end}}>Setswana</option>
<option value="to"{{if eq "to" .Post.Language.String}} selected="selected"{{end}}>Faka Tonga</option>
<option value="tr"{{if eq "tr" .Post.Language.String}} selected="selected"{{end}}>Türkçe</option>
<option value="ts"{{if eq "ts" .Post.Language.String}} selected="selected"{{end}}>Xitsonga</option>
<option value="tt"{{if eq "tt" .Post.Language.String}} selected="selected"{{end}}>татар теле, tatar tele</option>
<option value="tw"{{if eq "tw" .Post.Language.String}} selected="selected"{{end}}>Twi</option>
<option value="ty"{{if eq "ty" .Post.Language.String}} selected="selected"{{end}}>Reo Tahiti</option>
<option value="ug"{{if eq "ug" .Post.Language.String}} selected="selected"{{end}}>ئۇيغۇرچە‎, Uyghurche</option>
<option value="uk"{{if eq "uk" .Post.Language.String}} selected="selected"{{end}}>Українська</option>
<option dir="rtl" value="ur"{{if eq "ur" .Post.Language.String}} selected="selected"{{end}}>اردو</option>
<option value="uz"{{if eq "uz" .Post.Language.String}} selected="selected"{{end}}>Oʻzbek, Ўзбек, أۇزبېك‎</option>
<option value="ve"{{if eq "ve" .Post.Language.String}} selected="selected"{{end}}>Tshivenḓa</option>
<option value="vi"{{if eq "vi" .Post.Language.String}} selected="selected"{{end}}>Tiếng Việt</option>
<option value="vo"{{if eq "vo" .Post.Language.String}} selected="selected"{{end}}>Volapük</option>
<option value="wa"{{if eq "wa" .Post.Language.String}} selected="selected"{{end}}>Walon</option>
<option value="cy"{{if eq "cy" .Post.Language.String}} selected="selected"{{end}}>Cymraeg</option>
<option value="wo"{{if eq "wo" .Post.Language.String}} selected="selected"{{end}}>Wollof</option>
<option value="fy"{{if eq "fy" .Post.Language.String}} selected="selected"{{end}}>Frysk</option>
<option value="xh"{{if eq "xh" .Post.Language.String}} selected="selected"{{end}}>isiXhosa</option>
<option dir="rtl" value="yi"{{if eq "yi" .Post.Language.String}} selected="selected"{{end}}>ייִדיש</option>
<option value="yo"{{if eq "yo" .Post.Language.String}} selected="selected"{{end}}>Yorùbá</option>
<option value="za"{{if eq "za" .Post.Language.String}} selected="selected"{{end}}>Saɯ cueŋƅ, Saw cuengh</option>
<option value="zu"{{if eq "zu" .Post.Language.String}} selected="selected"{{end}}>isiZulu</option>
</select>
</dd>
- <dt><label for="rtl">Direction</label></dt>
- <dd><input type="checkbox" id="rtl" name="rtl" {{if .Post.IsRTL.Bool}}checked="checked"{{end}} /><label for="rtl"> right-to-left</label></dd>
- <dt><label for="created">Created</label></dt>
+ <dt><label for="rtl">{{call .Tr "Direction"}}</label></dt>
+ <dd><input type="checkbox" id="rtl" name="rtl" {{if .Post.IsRTL.Bool}}checked="checked"{{end}} /><label for="rtl"> {{call .Tr "right-to-left"}}</label></dd>
+ <dt><label for="created">{{call .Tr "Created"}}</label></dt>
<dd>
- <input type="text" id="created" name="created" value="{{.Post.UserFacingCreated}}" data-time="{{.Post.Created8601}}" placeholder="YYYY-MM-DD HH:MM:SS" maxlength="19" /> <span id="tz">UTC</span> <a href="#" id="set-now">now</a>
+ <input type="text" id="created" name="created" value="{{.Post.UserFacingCreated}}" data-time="{{.Post.Created8601}}" placeholder="YYYY-MM-DD HH:MM:SS" maxlength="19" /> <span id="tz">UTC</span> <a href="#" id="set-now">{{call .Tr "now"}}</a>
<p class="error" id="create-error">Date format should be: <span class="mono"><abbr title="The full year">YYYY</abbr>-<abbr title="The numeric month of the year, where January = 1, with a zero in front if less than 10">MM</abbr>-<abbr title="The day of the month, with a zero in front if less than 10">DD</abbr> <abbr title="The hour (00-23), with a zero in front if less than 10.">HH</abbr>:<abbr title="The minute of the hour (00-59), with a zero in front if less than 10.">MM</abbr>:<abbr title="The seconds (00-59), with a zero in front if less than 10.">SS</abbr></span></p>
</dd>
- <dt>&nbsp;</dt><dd><input type="submit" value="Save changes" /></dd>
+ <dt>&nbsp;</dt><dd><input type="submit" value={{call .Tr "Save changes"}} /></dd>
</dl>
<input type="hidden" name="web" value="true" />
</form>
</div>
<script src="/js/h.js"></script>
<script>
function updateMeta() {
if ({{.Silenced}}) {
alert("Your account is silenced, so you can't edit posts.");
return
}
document.getElementById('create-error').style.display = 'none';
var $created = document.getElementById('created');
var dateStr = $created.value.trim();
var m = dateStr.match(/^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}( [0-9]{1,2}:[0-9]{1,2}(:[0-9]{1,2})?)?$/);
if (!m) {
document.getElementById('create-error').style.display = 'block';
return false;
}
// Break up the date and parse. This ensures cross-browser compatibility
var p = dateStr.split(/[^0-9]/);
var d = new Date(p[0], p[1]-1, p[2], p[3] ? p[3] : 0, p[4] ? p[4] : 0, p[5] ? p[5] : 0);
$created.value = d.getUTCFullYear() + '-' + ('0' + (d.getUTCMonth()+1)).slice(-2) + '-' + ('0' + d.getUTCDate()).slice(-2)+' '+('0'+d.getUTCHours()).slice(-2)+':'+('0'+d.getUTCMinutes()).slice(-2)+':'+('0'+d.getUTCSeconds()).slice(-2);
var $tz = document.getElementById('tz');
$tz.style.display = "inline";
var $submit = document.querySelector('input[type=submit]');
$submit.value = "Saving...";
$submit.disabled = true;
return true;
}
function dateToStr(d) {
return d.getFullYear() + '-' + ('0' + (d.getMonth()+1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2)+' '+('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)+':'+('0'+d.getSeconds()).slice(-2);
}
function setLocalTime() {
var $created = document.getElementById('created');
var d = new Date($created.getAttribute('data-time'));
$created.value = dateToStr(d);
var $tz = document.getElementById('tz');
$tz.style.display = "none";
}
setLocalTime();
function setToNow() {
var $created = document.getElementById('created');
$created.value = dateToStr(new Date());
}
H.getEl('set-now').on('click', function(e) {
e.preventDefault();
setToNow();
});
function toggleTheme() {
var btns = Array.prototype.slice.call(document.getElementById('tools').querySelectorAll('a img'));
if (document.body.className == 'light') {
document.body.className = 'dark';
for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('_dark@2x.png', '@2x.png');
}
} else {
document.body.className = 'light';
for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('@2x.png', '_dark@2x.png');
}
}
H.set('padTheme', document.body.className);
}
if (H.get('padTheme', 'light') != 'light') {
toggleTheme();
}
var setButtonStates = function() {
if (!canPublish) {
$btnPublish.el.className = 'disabled';
return;
}
if ($writer.el.value.length === 0 || (draftDoc != 'lastDoc' && $writer.el.value == origDoc)) {
$btnPublish.el.className = 'disabled';
} else {
$btnPublish.el.className = '';
}
};
H.getEl('toggle-theme').on('click', function(e) {
e.preventDefault();
try {
var newTheme = 'light';
if (document.body.className == 'light') {
newTheme = 'dark';
}
} catch(e) {}
toggleTheme();
});
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
try {
(function() {
var wf=document.createElement('script');
wf.src = '/js/webfont.js';
wf.type='text/javascript';
wf.async='true';
var s=document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) {
// whatevs
}
</script>
<link href="/css/icons.css" rel="stylesheet">
</body>
</html>{{end}}
diff --git a/templates/include/footer.tmpl b/templates/include/footer.tmpl
index c6f4b87..d0b6ead 100644
--- a/templates/include/footer.tmpl
+++ b/templates/include/footer.tmpl
@@ -1,44 +1,44 @@
{{define "footer"}}
<footer{{if not (or .SingleUser .WFModesty)}} class="contain-me"{{end}}>
<hr />
{{if or .SingleUser .WFModesty}}
<nav>
<a class="home" href="/">{{.SiteName}}</a>
{{if not .SingleUser}}
<a href="/about">about</a>
- {{if and .LocalTimeline .CanViewReader}}<a href="/read">reader</a>{{end}}
- {{if .Username}}<a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a>{{end}}
- <a href="/privacy">privacy</a>
+ {{if and .LocalTimeline .CanViewReader}}<a href="/read">{{call .Tr "Reader"}}</a>{{end}}
+ {{if .Username}}<a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">{{call .Tr "writer's guide"}}</a>{{end}}
+ <a href="/privacy">{{call .Tr "privacy"}}</a>
<p style="font-size: 0.9em">powered by <a href="https://writefreely.org">writefreely</a></p>
{{else}}
- <a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a>
- <a href="https://developers.write.as/" title="Build on WriteFreely with our open developer API.">developers</a>
- <a href="https://github.com/writefreely/writefreely">source code</a>
+ <a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">{{call .Tr "writer's guide"}}</a>
+ <a href="https://developers.write.as/" title="Build on WriteFreely with our open developer API.">{{call .Tr "developers"}}</a>
+ <a href="https://github.com/writefreely/writefreely">{{call .Tr "source code"}}</a>
<a href="https://writefreely.org">writefreely {{.Version}}</a>
{{end}}
</nav>
{{else}}
<div class="marketing-section">
<div class="clearfix blurbs">
<div class="half">
<h3><a class="home" href="/">{{.SiteName}}</a></h3>
<ul>
- <li><a href="/about">about</a></li>
- {{if and (and (not .SingleUser) .LocalTimeline) .CanViewReader}}<a href="/read">reader</a>{{end}}
- <li><a href="/privacy">privacy</a></li>
+ <li><a href="/about">{{call .Tr "About"}}</a></li>
+ {{if and (and (not .SingleUser) .LocalTimeline) .CanViewReader}}<a href="/read">{{call .Tr "Reader"}}</a>{{end}}
+ <li><a href="/privacy">{{call .Tr "privacy"}}</a></li>
</ul>
</div>
<div class="half">
<h3><a href="https://writefreely.org" style="color:#444;text-transform:lowercase;">WriteFreely</a></h3>
<ul>
- <li><a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a></li>
- <li><a href="https://developers.write.as/" title="Build on WriteFreely with our open developer API.">developers</a></li>
- <li><a href="https://github.com/writefreely/writefreely">source code</a></li>
+ <li><a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">{{call .Tr "writer's guide"}}</a></li>
+ <li><a href="https://developers.write.as/" title="Build on WriteFreely with our open developer API.">{{call .Tr "developers"}}</a></li>
+ <li><a href="https://github.com/writefreely/writefreely">{{call .Tr "source code"}}</a></li>
<li style="margin-top:0.8em">{{.Version}}</li>
</ul>
</div>
</div>
</div>
{{end}}
</footer>
{{end}}
diff --git a/templates/include/oauth.tmpl b/templates/include/oauth.tmpl
index 9a8d05e..a7d9d48 100644
--- a/templates/include/oauth.tmpl
+++ b/templates/include/oauth.tmpl
@@ -1,37 +1,31 @@
{{define "oauth-buttons"}}
{{ if or .SlackEnabled .WriteAsEnabled .GitLabEnabled .GiteaEnabled .GenericEnabled }}
<div class="row content-container signinbtns">
{{ if .SlackEnabled }}
<a class="loginbtn" href="/oauth/slack"><img alt="Sign in with Slack" height="40" width="172" src="/img/sign_in_with_slack.png" srcset="/img/sign_in_with_slack.png 1x, /img/sign_in_with_slack@2x.png 2x" /></a>
{{ end }}
{{ if .WriteAsEnabled }}
<a class="btn cta loginbtn" id="writeas-login" href="/oauth/write.as">
- <img src="/img/mark/writeas-white.png" />
- Sign in with <strong>Write.as</strong>
- </a>
+ <img src="/img/mark/writeas-white.png" />{{call .Tr "Sign in with **%s**" true (variables "Write.as")}}</a>
{{ end }}
{{ if .GitLabEnabled }}
<a class="btn cta loginbtn" id="gitlab-login" href="/oauth/gitlab">
- <img src="/img/mark/gitlab.png" />
- Sign in with <strong>{{.GitLabDisplayName}}</strong>
- </a>
+ <img src="/img/mark/gitlab.png" />{{call .Tr "Sign in with **%s**" true (variables .GitLabDisplayName)}}</a>
{{ end }}
{{ if .GiteaEnabled }}
- <a class="btn cta loginbtn" id="gitea-login" href="/oauth/gitea">
- <img src="/img/mark/gitea.png" />
- Sign in with <strong>{{.GiteaDisplayName}}</strong>
- </a>
+ <a class="btn cta loginbtn" id="gitea-login" href="/oauth/gitea">GitLabDisplayName
+ <img src="/img/mark/gitea.png" />{{call .Tr "Sign in with **%s**" true (variables .GiteaDisplayName)}}</a>
{{ end }}
{{ if .GenericEnabled }}
- <a class="btn cta loginbtn" id="generic-oauth-login" href="/oauth/generic">Sign in with <strong>{{.GenericDisplayName}}</strong></a>
+ <a class="btn cta loginbtn" id="generic-oauth-login" href="/oauth/generic">{{call .Tr "Sign in with **%s**" true (variables .GenericDisplayName)}}</a>
{{ end }}
</div>
{{if not .DisablePasswordAuth}}
<div class="or">
- <p>or</p>
+ <p>{{call .Tr "or"}}</p>
<hr class="short" />
</div>
{{end}}
{{ end }}
{{end}}
\ No newline at end of file
diff --git a/templates/include/posts.tmpl b/templates/include/posts.tmpl
index c3401fa..9aff2ad 100644
--- a/templates/include/posts.tmpl
+++ b/templates/include/posts.tmpl
@@ -1,64 +1,69 @@
{{ define "posts" }}
+<style type="text/css">
+a.hidden.action{
+ text-transform: lowercase;
+}
+</style>
{{ range $el := .Posts }}<article id="post-{{.ID}}" class="{{.Font}} h-entry" itemscope itemtype="http://schema.org/BlogPosting">
- {{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}
+ {{if .IsScheduled}}<p class="badge">{{call $.Tr "Scheduled"}}</p>{{end}}
{{if .Title.String}}<h2 class="post-title" itemprop="name" class="p-name">
{{- if .HasTitleLink -}}
- {{.HTMLTitle}} <a class="user hidden action" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">view{{if .IsPaid}} {{template "paid-badge" .}}{{end}}</a>
+ {{.HTMLTitle}} <a class="user hidden action" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">{{call $.Tr "View"}}{{if .IsPaid}} {{template "paid-badge" .}}{{end}}</a>
{{- else -}}
{{- if .IsPaid}}{{template "paid-badge" .}}{{end -}}
<a href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}" itemprop="url" class="u-url">{{.HTMLTitle}}</a>
{{- end}}
{{if $.IsOwner}}
- <a class="user hidden action" href="/{{if not $.SingleUser}}{{$.Alias}}/{{end}}{{.Slug.String}}/edit">edit</a>
- {{if $.CanPin}}<a class="user hidden pin action" href="/{{$.Alias}}/{{.Slug.String}}/pin" onclick="pinPost(event, '{{.ID}}', '{{.Slug.String}}', '{{.PlainDisplayTitle}}')">pin</a>{{end}}
- <a class="user hidden delete action" onclick="delPost(event, '{{.ID}}')" href="/{{$.Alias}}/{{.Slug.String}}/delete">delete</a>
+ <a class="user hidden action" href="/{{if not $.SingleUser}}{{$.Alias}}/{{end}}{{.Slug.String}}/edit">{{call $.Tr "Edit"}}</a>
+ {{if $.CanPin}}<a class="user hidden pin action" href="/{{$.Alias}}/{{.Slug.String}}/pin" onclick="pinPost(event, '{{.ID}}', '{{.Slug.String}}', '{{.PlainDisplayTitle}}', '{{$.Locales}}')">{{call $.Tr "Pin"}}</a>{{end}}
+ <a class="user hidden delete action" onclick="delPost(event, '{{.ID}}', null, '{{$.Locales}}')" href="/{{$.Alias}}/{{.Slug.String}}/delete">{{call $.Tr "Delete"}}</a>
{{if gt (len $.Collections) 1}}<div class="user hidden action flat-select">
- <select id="move-{{.ID}}" onchange="postActions.multiMove(this, '{{.ID}}', {{if $.SingleUser}}true{{else}}false{{end}})" title="Move this post to another blog">
+ <select id="move-{{.ID}}" onchange="postActions.multiMove(this, '{{.ID}}', {{if $.SingleUser}}true{{else}}false{{end}}, '{{$.Locales}}')" title="{{call $.Tr "Move this post to another blog"}}">
<option style="display:none"></option>
- <option value="|anonymous|" style="font-style:italic">Draft</option>
+ <option value="|anonymous|" style="font-style:italic">{{call $.Tr "Draft"}}</option>
{{range $.Collections}}{{if ne .Alias $.Alias}}<option value="{{.Alias}}">{{.DisplayTitle}}</option>{{end}}{{end}}
</select>
- <label for="move-{{.ID}}">move to...</label>
+ <label for="move-{{.ID}}">{{call $.Tr "move to..."}}</label>
<img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" />
</div>{{else}}
{{range $.Collections}}
- <a class="user hidden action" href="/{{$el.ID}}" title="Change to a draft" onclick="postActions.move(this, '{{$el.ID}}', '|anonymous|', {{if $.SingleUser}}true{{else}}false{{end}});return false">change to <em>draft</em></a>
+ <a class="user hidden action" href="/{{$el.ID}}" title="{{call $.Tr "Change to a draft"}}" onclick="postActions.move(this, '{{$el.ID}}', '|anonymous|', {{if $.SingleUser}}true{{else}}false{{end}}, '{{$.Locales}}');return false">{{call $.Tr "change to _%s_" true (variables "Draft")}}</a>
{{end}}
{{end}}
{{end}}
</h2>
{{if $.Format.ShowDates}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{if not .Title.String}}<a href="{{$.CanonicalURL}}{{.Slug.String}}" itemprop="url">{{end}}{{.DisplayDate}}{{if not .Title.String}}</a>{{end}}</time>{{end}}
{{else}}
<h2 class="post-title" itemprop="name">
{{if $.Format.ShowDates -}}
{{- if .IsPaid}}{{template "paid-badge" .}}{{end -}}
<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}"><a href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}" itemprop="url" class="u-url">{{.DisplayDate}}</a></time>
{{- end}}
{{if $.IsOwner}}
- {{if not $.Format.ShowDates}}<a class="user hidden action" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">view</a>{{end}}
- <a class="user hidden action" href="/{{if not $.SingleUser}}{{$.Alias}}/{{end}}{{.Slug.String}}/edit">edit</a>
- {{if $.CanPin}}<a class="user hidden pin action" href="/{{if not $.SingleUser}}{{$.Alias}}/{{end}}{{.Slug.String}}/pin" onclick="pinPost(event, '{{.ID}}', '{{.Slug.String}}', '{{.PlainDisplayTitle}}')">pin</a>{{end}}
- <a class="user hidden delete action" onclick="delPost(event, '{{.ID}}')" href="/{{$.Alias}}/{{.Slug.String}}/delete">delete</a>
+ {{if not $.Format.ShowDates}}<a class="user hidden action" href="{{if not $.SingleUser}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">{{call $.Tr "View"}}</a>{{end}}
+ <a class="user hidden action" href="/{{if not $.SingleUser}}{{$.Alias}}/{{end}}{{.Slug.String}}/edit">{{call $.Tr "Edit"}}</a>
+ {{if $.CanPin}}<a class="user hidden pin action" href="/{{if not $.SingleUser}}{{$.Alias}}/{{end}}{{.Slug.String}}/pin" onclick="pinPost(event, '{{.ID}}', '{{.Slug.String}}', '{{.PlainDisplayTitle}}', '{{$.Locales}}')">{{call $.Tr "Pin"}}</a>{{end}}
+ <a class="user hidden delete action" onclick="delPost(event, '{{.ID}}', null, '{{$.Locales}}')" href="/{{$.Alias}}/{{.Slug.String}}/delete">{{call $.Tr "Delete"}}</a>
{{if gt (len $.Collections) 1}}<div class="user hidden action flat-select">
- <select id="move-{{.ID}}" onchange="postActions.multiMove(this, '{{.ID}}', {{if $.SingleUser}}true{{else}}false{{end}})" title="Move this post to another blog">
+ <select id="move-{{.ID}}" onchange="postActions.multiMove(this, '{{.ID}}', {{if $.SingleUser}}true{{else}}false{{end}}, '{{$.Locales}}')" title="{{call $.Tr "Move this post to another blog"}}">
<option style="display:none"></option>
- <option value="|anonymous|" style="font-style:italic">Draft</option>
+ <option value="|anonymous|" style="font-style:italic">{{call $.Tr "Draft"}}</option>
{{range $.Collections}}{{if ne .Alias $.Alias}}<option value="{{.Alias}}">{{.DisplayTitle}}</option>{{end}}{{end}}
</select>
- <label for="move-{{.ID}}">move to...</label>
+ <label for="move-{{.ID}}">{{call $.Tr "move to..."}}</label>
<img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" />
</div>{{else}}
{{range $.Collections}}
- <a class="user hidden action" href="/{{$el.ID}}" title="Change to a draft" onclick="postActions.move(this, '{{$el.ID}}', '|anonymous|', {{if $.SingleUser}}true{{else}}false{{end}});return false">change to <em>draft</em></a>
+ <a class="user hidden action" href="/{{$el.ID}}" title="{{call $.Tr "Change to a draft"}}" onclick="postActions.move(this, '{{$el.ID}}', '|anonymous|', {{if $.SingleUser}}true{{else}}false{{end}}, '{{$.Locales}}');return false">{{call $.Tr "change to _%s_" true (variables "Draft")}}</a>
{{end}}
{{end}}
{{end}}
</h2>
{{end}}
-{{if .Excerpt}}<div {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}" class="book p-summary">{{if and (and (not $.IsOwner) (not $.Format.ShowDates)) (not .Title.String)}}<a class="hidden action" href="{{if $.IsOwner}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">view</a>{{end}}{{.Excerpt}}</div>
+{{if .Excerpt}}<div {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}" class="book p-summary">{{if and (and (not $.IsOwner) (not $.Format.ShowDates)) (not .Title.String)}}<a class="hidden action" href="{{if $.IsOwner}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">{{call $.Tr "View"}}</a>{{end}}{{.Excerpt}}</div>
-<a class="read-more" href="{{$.CanonicalURL}}{{.Slug.String}}">{{localstr "Read more..." .Language.String}}</a>{{else}}<div {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}" class="book e-content">{{if and (and (not $.IsOwner) (not $.Format.ShowDates)) (not .Title.String)}}<a class="hidden action" href="{{if $.IsOwner}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">view</a>{{end}}{{.HTMLContent}}</div>{{end}}</article>{{ end }}
+<a class="read-more" href="{{$.CanonicalURL}}{{.Slug.String}}">{{localstr "Read more..." .Language.String}}</a>{{else}}<div {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}" class="book e-content">{{if and (and (not $.IsOwner) (not $.Format.ShowDates)) (not .Title.String)}}<a class="hidden action" href="{{if $.IsOwner}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">{{call $.Tr "View"}}</a>{{end}}{{.HTMLContent}}</div>{{end}}</article>{{ end }}
{{ end }}
{{define "paid-badge"}}<img class="paid" alt="Paid article" src="/img/paidarticle.svg" /> {{end}}
\ No newline at end of file
diff --git a/templates/pad.tmpl b/templates/pad.tmpl
index 555bbb3..0601f1b 100644
--- a/templates/pad.tmpl
+++ b/templates/pad.tmpl
@@ -1,421 +1,426 @@
{{define "pad"}}<!DOCTYPE HTML>
<html>
<head>
- <title>{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} &mdash; {{.SiteName}}</title>
+ <title>{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}{{call .Tr "New Post"}}{{end}} &mdash; {{.SiteName}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="google" value="notranslate">
</head>
<body id="pad" class="light">
<div id="overlay"></div>
- <textarea id="writer" placeholder="Write..." class="{{.Post.Font}}" autofocus>{{if .Post.Title}}# {{.Post.Title}}
+ <textarea id="writer" placeholder={{call .Tr "Write..."}} class="{{.Post.Font}}" autofocus>{{if .Post.Title}}# {{.Post.Title}}
{{end}}{{.Post.Content}}</textarea>
- <div class="alert success hidden" id="edited-elsewhere">This post has been updated elsewhere since you last published! <a href="#" id="erase-edit">Delete draft and reload</a>.</div>
+ <div class="alert success hidden" id="edited-elsewhere">{{call .Tr "This post has been updated elsewhere since you last published!"}} <a href="#" id="erase-edit">{{call .Tr "Delete draft and reload"}}</a>.</div>
<header id="tools">
<div id="clip">
- {{if not .SingleUser}}<h1><a href="/me/c/" title="View blogs"><img class="ic-24dp" src="/img/ic_blogs_dark@2x.png" /></a></h1>{{end}}
+ {{if not .SingleUser}}<h1><a href="/me/c/" title={{call .Tr "View Blog" 2}}><img class="ic-24dp" src="/img/ic_blogs_dark@2x.png" /></a></h1>{{end}}
<nav id="target" {{if .SingleUser}}style="margin-left:0"{{end}}><ul>
- {{if .Editing}}<li>{{if .EditCollection}}<a href="{{.EditCollection.CanonicalURL}}">{{.EditCollection.Title}}</a>{{else}}<a>Draft</a>{{end}}</li>
- {{else}}<li class="has-submenu"><a href="#" id="publish-to" onclick="return false"><span id="target-name">Draft</span> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /></a>
+ {{if .Editing}}<li>{{if .EditCollection}}<a href="{{.EditCollection.CanonicalURL}}">{{.EditCollection.Title}}</a>{{else}}<a>{{call .Tr "Draft"}}</a>{{end}}</li>
+ {{else}}<li class="has-submenu"><a href="#" id="publish-to" onclick="return false"><span id="target-name">{{call .Tr "Draft"}}</span> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /></a>
<ul>
- <li class="menu-heading">Publish to...</li>
+ <li class="menu-heading">{{call .Tr "Publish to..."}}</li>
{{if .Blogs}}{{range $idx, $el := .Blogs}}
<li class="target{{if eq $idx 0}} selected{{end}}" id="blog-{{$el.Alias}}"><a href="#{{$el.Alias}}"><i class="material-icons md-18">public</i> {{if $el.Title}}{{$el.Title}}{{else}}{{$el.Alias}}{{end}}</a></li>
{{end}}{{end}}
- <li class="target" id="blog-anonymous"><a href="#anonymous"><i class="material-icons md-18">description</i> <em>Draft</em></a></li>
+ <li class="target" id="blog-anonymous"><a href="#anonymous"><i class="material-icons md-18">description</i> <em>{{call .Tr "Draft"}}</em></a></li>
<li id="user-separator" class="separator"><hr /></li>
{{ if .SingleUser }}
- <li><a href="/"><i class="material-icons md-18">launch</i> View Blog</a></li>
- <li><a href="/me/c/{{.Username}}"><i class="material-icons md-18">palette</i> Customize</a></li>
- <li><a href="/me/c/{{.Username}}/stats"><i class="material-icons md-18">trending_up</i> Stats</a></li>
+ <li><a href="/"><i class="material-icons md-18">launch</i> {{call .Tr "View Blog"}}</a></li>
+ <li><a href="/me/c/{{.Username}}"><i class="material-icons md-18">palette</i> {{call .Tr "Customize"}}</a></li>
+ <li><a href="/me/c/{{.Username}}/stats"><i class="material-icons md-18">trending_up</i> {{call .Tr "Stats"}}</a></li>
{{ else }}
- <li><a href="/me/c/"><i class="material-icons md-18">library_books</i> View Blogs</a></li>
+ <li><a href="/me/c/"><i class="material-icons md-18">library_books</i> {{call .Tr "View Blog" 2}}</a></li>
{{ end }}
- <li><a href="/me/posts/"><i class="material-icons md-18">view_list</i> View Drafts</a></li>
- <li><a href="/me/logout"><i class="material-icons md-18">power_settings_new</i> Log out</a></li>
+ <li><a href="/me/posts/"><i class="material-icons md-18">view_list</i> {{call .Tr "View Draft" 2}}</a></li>
+ <li><a href="/me/logout"><i class="material-icons md-18">power_settings_new</i> {{call .Tr "Log out"}}</a></li>
</ul>
</li>{{end}}
</ul></nav>
<nav id="font-picker" class="if-room room-3 hidden" style="margin-left:-1em"><ul>
<li class="has-submenu"><a href="#" id="" onclick="return false"><img class="ic-24dp" src="/img/ic_font_dark@2x.png" /> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /></a>
<ul style="text-align: center">
- <li class="menu-heading">Font</li>
+ <li class="menu-heading">{{call .Tr "Font"}}</li>
<li class="selected"><a class="font norm" href="#norm">Serif</a></li>
<li><a class="font sans" href="#sans">Sans-serif</a></li>
<li><a class="font wrap" href="#wrap">Monospace</a></li>
</ul>
</li>
</ul></nav>
- <span id="wc" class="hidden if-room room-4">0 words</span>
+ {{ $N := 2 }}{{ if eq .AppCfg.Lang "eu_ES" }}{{ $N = 1}}{{ end }}
+ <span id="wc" class="hidden if-room room-4">0 {{call .Tr "words" $N}}</span>
</div>
- <noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need Javascript enabled to post.</noscript>
+ <noscript style="margin-left: 2em;"><strong>{{call .Tr "NOTE"}}</strong>: {{call .Tr "for now, you'll need Javascript enabled to post."}}</noscript>
<div id="belt">
- {{if .Editing}}<div class="tool hidden if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit/meta{{else}}/{{if .SingleUser}}d/{{end}}{{.Post.Id}}/meta{{end}}" title="Edit post metadata" id="edit-meta"><img class="ic-24dp" src="/img/ic_info_dark@2x.png" /></a></div>{{end}}
- <div class="tool hidden if-room room-2"><a href="#theme" title="Toggle theme" id="toggle-theme"><img class="ic-24dp" src="/img/ic_brightness_dark@2x.png" /></a></div>
- <div class="tool if-room room-1"><a href="{{if not .User}}/pad/posts{{else}}/me/posts/{{end}}" title="View posts" id="view-posts"><img class="ic-24dp" src="/img/ic_list_dark@2x.png" /></a></div>
- <div class="tool"><a href="#publish" title="Publish" id="publish"><img class="ic-24dp" src="/img/ic_send_dark@2x.png" /></a></div>
+ {{if .Editing}}<div class="tool hidden if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit/meta{{else}}/{{if .SingleUser}}d/{{end}}{{.Post.Id}}/meta{{end}}" title={{call .Tr "Edit post metadata"}} id="edit-meta"><img class="ic-24dp" src="/img/ic_info_dark@2x.png" /></a></div>{{end}}
+ <div class="tool hidden if-room room-2"><a href="#theme" title={{call .Tr "Toggle theme"}} id="toggle-theme"><img class="ic-24dp" src="/img/ic_brightness_dark@2x.png" /></a></div>
+ <div class="tool if-room room-1"><a href="{{if not .User}}/pad/posts{{else}}/me/posts/{{end}}" title={{call .Tr "View post" 2}} id="view-posts"><img class="ic-24dp" src="/img/ic_list_dark@2x.png" /></a></div>
+ <div class="tool"><a href="#publish" title={{call .Tr "Publish"}} id="publish"><img class="ic-24dp" src="/img/ic_send_dark@2x.png" /></a></div>
</div>
</header>
<script src="/js/h.js"></script>
<script type="text/javascript" src="/js/menu.js"></script>
<script>
function toggleTheme() {
if (document.body.classList.contains('light')) {
setTheme('dark');
} else {
setTheme('light');
}
H.set('padTheme', newTheme);
}
function setTheme(newTheme) {
document.body.classList.remove('light');
document.body.classList.remove('dark');
document.body.classList.add(newTheme);
var btns = Array.prototype.slice.call(document.getElementById('tools').querySelectorAll('a img'));
if (newTheme == 'light') {
// check if current theme is dark otherwise we'll get `_dark_dark@2x.png`
if (H.get('padTheme', 'auto') == 'dark'){
for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('@2x.png', '_dark@2x.png');
}
}
} else {
for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('_dark@2x.png', '@2x.png');
}
}
H.set('padTheme', newTheme);
}
if (H.get('padTheme', 'auto') == 'light') {
setTheme('light');
} else if (H.get('padTheme', 'auto') == 'dark') {
setTheme('dark');
} else {
const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches
if (isDarkMode) {
setTheme('dark');
} else {
setTheme('light');
}
}
var $writer = H.getEl('writer');
var $btnPublish = H.getEl('publish');
var $btnEraseEdit = H.getEl('edited-elsewhere');
var $wc = H.getEl("wc");
var updateWordCount = function() {
var words = 0;
var val = $writer.el.value.trim();
if (val != '') {
words = $writer.el.value.trim().replace(/\s+/gi, ' ').split(' ').length;
}
- $wc.el.innerText = words + " word" + (words != 1 ? "s" : "");
+ //$wc.el.innerText = words + " word" + (words != 1 ? "s" : "");
+
+ {{ $N := 2 }}{{ if eq .AppCfg.Lang "eu_ES" }}{{ $N = 1}}{{ end }} //don't need to pluralize for the basque language
+ $wc.el.innerText = words + " " + (words !=1 ? {{call .Tr "word" $N}} : {{call .Tr "word" 1}})
+
};
var setButtonStates = function() {
if (!canPublish) {
$btnPublish.el.className = 'disabled';
return;
}
if ($writer.el.value.length === 0 || (draftDoc != 'lastDoc' && $writer.el.value == origDoc)) {
$btnPublish.el.className = 'disabled';
} else {
$btnPublish.el.className = '';
}
};
{{if .Post.Id}}var draftDoc = 'draft{{.Post.Id}}';
var origDoc = '{{.Post.Content}}';{{else}}var draftDoc = 'lastDoc';{{end}}
var updatedStr = '{{.Post.Updated8601}}';
var updated = null;
if (updatedStr != '') {
updated = new Date(updatedStr);
}
var ok = H.load($writer, draftDoc, true, updated);
if (!ok) {
// Show "edited elsewhere" warning
$btnEraseEdit.el.classList.remove('hidden');
}
var defaultTimeSet = false;
updateWordCount();
var typingTimer;
var doneTypingInterval = 200;
var posts;
{{if and .Post.Id (not .Post.Slug)}}
var token = null;
var curPostIdx;
posts = JSON.parse(H.get('posts', '[]'));
for (var i=0; i<posts.length; i++) {
if (posts[i].id == "{{.Post.Id}}") {
token = posts[i].token;
break;
}
}
var canPublish = token != null;
{{else}}var canPublish = true;{{end}}
var publishing = false;
var justPublished = false;
var silenced = {{.Silenced}};
var publish = function(content, font) {
if (silenced === true) {
- alert("Your account is silenced, so you can't publish or update posts.");
+ alert({{call .Tr "Your account is silenced, so you can't publish or update posts."}});
return;
}
{{if and (and .Post.Id (not .Post.Slug)) (not .User)}}
if (!token) {
- alert("You don't have permission to update this post.");
+ alert({{call .Tr "You don't have permission to update this post."}});
return;
}
if ($btnPublish.el.className == 'disabled') {
return;
}
{{end}}
$btnPublish.el.children[0].textContent = 'more_horiz';
publishing = true;
var xpostTarg = H.get('crosspostTarget', '[]');
var http = new XMLHttpRequest();
var post = H.getTitleStrict(content);
var params = {
body: post.content,
title: post.title,
font: font
};
{{ if .Post.Slug }}
var url = "/api/collections/{{.EditCollection.Alias}}/posts/{{.Post.Id}}";
{{ else if .Post.Id }}
var url = "/api/posts/{{.Post.Id}}";
if (typeof token === 'undefined' || !token) {
token = "";
}
params.token = token;
{{ else }}
var lang = navigator.languages ? navigator.languages[0] : (navigator.language || navigator.userLanguage);
lang = lang.substring(0, 2);
params.lang = lang;
var url = "/api/posts";
var postTarget = H.get('postTarget', 'anonymous');
if (postTarget != 'anonymous') {
url = "/api/collections/" + postTarget + "/posts";
}
params.crosspost = JSON.parse(xpostTarg);
{{ end }}
http.open("POST", url, true);
// Send the proper header information along with the request
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
publishing = false;
if (http.status == 200 || http.status == 201) {
data = JSON.parse(http.responseText);
id = data.data.id;
nextURL = '{{if .SingleUser}}/d{{end}}/'+id;
localStorage.setItem('draft'+id+'-published', new Date().toISOString());
{{ if not .Post.Id }}
// Post created
if (postTarget != 'anonymous') {
nextURL = {{if not .SingleUser}}'/'+postTarget+{{end}}'/'+data.data.slug;
}
editToken = data.data.token;
{{ if not .User }}if (postTarget == 'anonymous') {
// Save the data
var posts = JSON.parse(H.get('posts', '[]'));
{{if .Post.Id}}var newPost = H.createPost("{{.Post.Id}}", token, content);
for (var i=0; i<posts.length; i++) {
if (posts[i].id == "{{.Post.Id}}") {
posts[i].title = newPost.title;
posts[i].summary = newPost.summary;
break;
}
}
nextURL = "/pad/posts";{{else}}posts.push(H.createPost(id, editToken, content));{{end}}
H.set('posts', JSON.stringify(posts));
}
{{ end }}
{{ end }}
justPublished = true;
if (draftDoc != 'lastDoc') {
H.remove(draftDoc);
{{if .Editing}}H.remove('draft{{.Post.Id}}font');{{end}}
} else {
H.set(draftDoc, '');
}
{{if .EditCollection}}
window.location = '{{.EditCollection.CanonicalURL}}{{.Post.Slug}}';
{{else}}
window.location = nextURL;
{{end}}
} else {
$btnPublish.el.children[0].textContent = 'send';
- alert("Failed to post. Please try again.");
+ alert({{call .Tr "Failed to post. Please try again."}});
}
}
}
http.send(JSON.stringify(params));
};
setButtonStates();
$writer.on('keyup input', function() {
setButtonStates();
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
}, false);
$writer.on('keydown', function(e) {
clearTimeout(typingTimer);
if (e.keyCode == 13 && (e.metaKey || e.ctrlKey)) {
$btnPublish.el.click();
}
});
$btnPublish.on('click', function(e) {
e.preventDefault();
if (!publishing && $writer.el.value) {
var content = $writer.el.value;
publish(content, selectedFont);
}
});
H.getEl('erase-edit').on('click', function(e) {
e.preventDefault();
H.remove(draftDoc);
H.remove(draftDoc+'-published');
justPublished = true; // Block auto-save
location.reload();
});
H.getEl('toggle-theme').on('click', function(e) {
e.preventDefault();
var newTheme = 'light';
if (document.body.className == 'light') {
newTheme = 'dark';
}
toggleTheme();
});
var targets = document.querySelectorAll('#target li.target a');
for (var i=0; i<targets.length; i++) {
targets[i].addEventListener('click', function(e) {
e.preventDefault();
var targetName = this.href.substring(this.href.indexOf('#')+1);
H.set('postTarget', targetName);
document.querySelector('#target li.target.selected').classList.remove('selected');
this.parentElement.classList.add('selected');
var newText = this.innerText.split(' ');
newText.shift();
document.getElementById('target-name').innerText = newText.join(' ');
});
}
var postTarget = H.get('postTarget', '{{if .Blogs}}{{$blog := index .Blogs 0}}{{$blog.Alias}}{{else}}anonymous{{end}}');
if (location.hash != '') {
postTarget = location.hash.substring(1);
// TODO: pushState to /pad (or whatever the URL is) so we live on a clean URL
location.hash = '';
}
var pte = document.querySelector('#target li.target#blog-'+postTarget+' a');
if (pte != null) {
pte.click();
} else {
postTarget = 'anonymous';
H.set('postTarget', postTarget);
}
var sansLoaded = false;
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
var loadSans = function() {
if (sansLoaded) return;
sansLoaded = true;
WebFontConfig.custom.families.push('Open+Sans:400,700:latin');
try {
(function() {
var wf=document.createElement('script');
wf.src = '/js/webfont.js';
wf.type='text/javascript';
wf.async='true';
var s=document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) {}
};
var fonts = document.querySelectorAll('nav#font-picker a.font');
for (var i=0; i<fonts.length; i++) {
fonts[i].addEventListener('click', function(e) {
e.preventDefault();
selectedFont = this.href.substring(this.href.indexOf('#')+1);
$writer.el.className = selectedFont;
document.querySelector('nav#font-picker li.selected').classList.remove('selected');
this.parentElement.classList.add('selected');
H.set('{{if .Editing}}draft{{.Post.Id}}font{{else}}padFont{{end}}', selectedFont);
if (selectedFont == 'sans') {
loadSans();
}
});
}
var selectedFont = H.get('{{if .Editing}}draft{{.Post.Id}}font{{else}}padFont{{end}}', '{{.Post.Font}}');
var sfe = document.querySelector('nav#font-picker a.font.'+selectedFont);
if (sfe != null) {
sfe.click();
}
var doneTyping = function() {
if (draftDoc == 'lastDoc' || $writer.el.value != origDoc) {
H.save($writer, draftDoc);
if (!defaultTimeSet) {
var lastLocalPublishStr = localStorage.getItem(draftDoc+'-published');
if (lastLocalPublishStr == null || lastLocalPublishStr == '') {
localStorage.setItem(draftDoc+'-published', updatedStr);
}
defaultTimeSet = true;
}
updateWordCount();
}
};
window.addEventListener('beforeunload', function(e) {
if (draftDoc != 'lastDoc' && $writer.el.value == origDoc) {
H.remove(draftDoc);
H.remove(draftDoc+'-published');
} else if (!justPublished) {
doneTyping();
}
});
try {
(function() {
var wf=document.createElement('script');
wf.src = '/js/webfont.js';
wf.type='text/javascript';
wf.async='true';
var s=document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) {
// whatevs
}
</script>
<link href="/css/icons.css" rel="stylesheet">
</body>
</html>{{end}}
diff --git a/templates/password-collection.tmpl b/templates/password-collection.tmpl
index fde4edc..3a90e03 100644
--- a/templates/password-collection.tmpl
+++ b/templates/password-collection.tmpl
@@ -1,88 +1,87 @@
{{define "password-collection"}}<!DOCTYPE HTML>
<html {{if .Language}}lang="{{.Language}}"{{end}} dir="{{.Direction}}">
<head>
<meta charset="utf-8">
<title>{{.DisplayTitle}}{{if not .SingleUser}} &mdash; {{.SiteName}}{{end}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
- {{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="canonical" href="{{.CanonicalURL}}">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="{{.Description}}">
<meta itemprop="name" content="{{.DisplayTitle}}">
<meta itemprop="description" content="{{.Description}}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.DisplayTitle}}">
<meta name="twitter:description" content="{{.Description}}">
<meta property="og:title" content="{{.DisplayTitle}}" />
<meta property="og:site_name" content="{{.DisplayTitle}}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{.CanonicalURL}}" />
<meta property="og:description" content="{{.Description}}" />
{{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}}
</head>
<body id="collection" itemscope itemtype="http://schema.org/WebPage">
{{if .SingleUser}}
<nav id="manage">
<ul>
- <li class="has-submenu"><a onclick="void(0)">&#9776; Menu</a>
+ <li class="has-submenu"><a onclick="void(0)">&#9776; {{call .Tr "Menu"}}</a>
<ul>
- <li><a href="/login">Log in</a></li>
+ <li><a href="/login">{{call .Tr "Log in"}}</a></li>
</ul>
</li>
</ul>
</nav>
{{end}}
<header>
<h1 dir="{{.Direction}}" id="blog-title"><a href="/{{.Alias}}/" class="h-card p-author u-url" rel="me author">{{.DisplayTitle}}</a></h1>
</header>
<div id="wrapper">
<div class="access">
<form method="post" action="/api/auth/read">
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{else}}
- <h2>This blog requires a password.</h2>
+ <h2>{{call .Tr "This blog requires a password."}}</h2>
{{end}}
<input type="hidden" name="alias" value="{{.Alias}}" />
<input type="hidden" name="to" value="{{.Next}}" />
<input type="password" autocomplete="new-password" name="password" tabindex="1" autofocus />
<p><input type="submit" value="Enter" /></p>
</form>
</div>
</div>
<footer>
<hr />
<nav dir="ltr">
<a class="home pubd" href="/">{{.SiteName}}</a> &middot; powered by <a style="margin-left:0" href="https://writefreely.org">writefreely</a>
</nav>
</footer>
</body>
{{if and .Script .CanShowScript}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}}
<script src="/js/h.js"></script>
<script src="/js/postactions.js"></script>
<script type="text/javascript">
try {
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
(function() {
var wf = document.createElement('script');
wf.src = '/js/webfont.js';
wf.type = 'text/javascript';
wf.async = 'true';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) {}
</script>
</html>{{end}}
diff --git a/templates/post.tmpl b/templates/post.tmpl
index 9398425..e9e9efc 100644
--- a/templates/post.tmpl
+++ b/templates/post.tmpl
@@ -1,102 +1,101 @@
{{define "post"}}<!DOCTYPE HTML>
<html {{if .Language}}lang="{{.Language}}"{{end}} dir="{{.Direction}}">
<head prefix="og: http://ogp.me/ns#">
<meta charset="utf-8">
<title>{{if .Title}}{{.Title}}{{else}}{{.GenTitle}}{{end}} {{localhtml "title dash" .Language}} {{.SiteName}}</title>
{{if .IsCode}}
<link rel="stylesheet" href="/css/lib/mono-blue.min.css">
{{end}}
<link rel="stylesheet" type="text/css" href="/css/write.css" />
- {{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="canonical" href="{{.Host}}/{{if .SingleUser}}d/{{end}}{{.ID}}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="generator" content="{{.SiteName}}">
<meta name="title" content="{{if .Title}}{{.Title}}{{else}}{{.GenTitle}}{{end}}">
<meta name="description" content="{{.Description}}">
<meta itemprop="name" content="{{.SiteName}}">
<meta itemprop="description" content="{{.Description}}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{if .Title}}{{.Title}}{{else}}{{.GenTitle}}{{end}}">
<meta name="twitter:description" content="{{.Description}}">
{{if gt .Views 1}}<meta name="twitter:label1" value="Views">
<meta name="twitter:data1" value="{{largeNumFmt .Views}}">{{end}}
{{if gt (len .Images) 0}}<meta name="twitter:image" content="{{index .Images 0}}">{{else}}<meta name="twitter:image" content="{{.Host}}/img/wf-sq.png">{{end}}
<meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.GenTitle}}{{end}}" />
<meta property="og:site_name" content="{{.SiteName}}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{.Host}}/{{if .SingleUser}}d/{{end}}{{.ID}}" />
<meta property="og:description" content="{{.Description}}" />
{{range .Images}}<meta property="og:image" content="{{.}}" />{{else}}<meta property="og:image" content="{{.Host}}/img/wf-sq.png">{{end}}
{{if .Author}}<meta property="article:author" content="https://{{.Author}}" />{{end}}
<!-- Add highlighting logic -->
{{template "highlighting" .}}
</head>
<body id="post">
<header>
<h1 dir="{{.Direction}}"><a href="/">{{.SiteName}}</a></h1>
<nav>
- <span class="views{{if not .IsOwner}} owner-visible{{end}}" dir="ltr"><strong>{{largeNumFmt .Views}}</strong> {{pluralize "view" "views" .Views}}</span>
- {{if .IsCode}}<a href="/{{.ID}}.txt" rel="noindex" dir="{{.Direction}}">View raw</a>{{end}}
+ <span class="views{{if not .IsOwner}} owner-visible{{end}}" dir="ltr"><strong>{{largeNumFmt .Views}}</strong> {{call .Tr "View" 2}}</span>
+ {{if .IsCode}}<a href="/{{.ID}}.txt" rel="noindex" dir="{{.Direction}}">{{call .Tr "View raw"}}</a>{{end}}
{{ if .Username }}
{{if .IsOwner}}
- <a href="/{{if .SingleUser}}d/{{end}}{{.ID}}/edit" dir="{{.Direction}}">Edit</a>
+ <a href="/{{if .SingleUser}}d/{{end}}{{.ID}}/edit" dir="{{.Direction}}">{{call .Tr "Edit"}}</a>
{{end}}
- <a class="xtra-feature dash-nav" href="/me/posts/" dir="{{.Direction}}">Drafts</a>
+ <a class="xtra-feature dash-nav" href="/me/posts/" dir="{{.Direction}}">{{call .Tr "Draft" 2}}</a>
{{ end }}
</nav>
</header>
{{if .Silenced}}
- {{template "user-silenced"}}
+ {{template "user-silenced" (dict "Tr" $.Tr)}}
{{end}}
<article class="{{.Font}} h-entry">{{if .Title}}<h2 id="title" class="p-name">{{.Title}}</h2>{{end}}{{ if .IsPlainText }}<p id="post-body" class="e-content">{{.Content}}</p>{{ else }}<div id="post-body" class="e-content">{{.HTMLContent}}</div>{{ end }}</article>
<footer dir="ltr"><hr><nav><p style="font-size: 0.9em">{{localhtml "published with write.as" .Language}}</p></nav></footer>
</body>
{{if .IsCode}}
<script src="/js/highlight.min.js"></script>
<script>
hljs.highlightBlock(document.getElementById('post-body'));
</script>
{{else}}
<script src="/js/h.js"></script>
{{if .IsPlainText}}<script src="/js/twitter-text.min.js"></script>{{end}}
{{end}}
<script type="text/javascript">
try {
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin'{{if eq .Font "sans"}}, 'Open+Sans:400,700:latin'{{end}} ], urls: [ '/css/fonts.css' ] }
};
(function() {
var wf = document.createElement('script');
wf.src = '/js/webfont.js';
wf.type = 'text/javascript';
wf.async = 'true';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) { /* ¯\_(ツ)_/¯ */ }
var posts = localStorage.getItem('posts');
if (posts != null) {
posts = JSON.parse(posts);
var $nav = document.getElementsByTagName('nav')[0];
for (var i=0; i<posts.length; i++) {
if (posts[i].id == "{{.ID}}") {
- $nav.innerHTML = $nav.innerHTML + '<a class="xtra-feature" href="/edit/{{.ID}}" dir="{{.Direction}}">Edit</a>';
+ $nav.innerHTML = $nav.innerHTML + '<a class="xtra-feature" href="/edit/{{.ID}}" dir="{{.Direction}}">{{call .Tr "Edit"}}</a>';
var $ownerVis = document.querySelectorAll('.owner-visible');
for (var i=0; i<$ownerVis.length; i++) {
$ownerVis[i].classList.remove('owner-visible');
}
break;
}
}
}
</script>
</html>{{end}}
diff --git a/templates/read.tmpl b/templates/read.tmpl
index c032970..539fec8 100644
--- a/templates/read.tmpl
+++ b/templates/read.tmpl
@@ -1,142 +1,142 @@
-{{define "head"}}<title>{{.SiteName}} Reader</title>
+{{define "head"}}<title>{{.SiteName}} {{call .Tr "Reader"}}</title>
<link rel="alternate" type="application/rss+xml" title="{{.SiteName}} Reader" href="/read/feed/" />
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .CurrentPage}}">{{end}}
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .CurrentPage}}">{{end}}
<meta name="description" content="Read the latest posts from {{.SiteName}}.">
<meta itemprop="name" content="{{.SiteName}} Reader">
<meta itemprop="description" content="Read the latest posts from {{.SiteName}}.">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{.SiteName}} Reader">
<meta name="twitter:description" content="Read the latest posts from {{.SiteName}}.">
<meta property="og:title" content="{{.SiteName}} Reader" />
<meta property="og:type" content="object" />
<meta property="og:description" content="Read the latest posts from {{.SiteName}}." />
<style>
.heading h1 {
font-weight: 300;
text-align: center;
margin: 3em 0 0;
}
.heading p {
text-align: center;
margin: 1.5em 0 4.5em;
font-size: 1.1em;
color: #777;
}
#wrapper {
font-size: 1.2em;
}
.preview {
max-height: 180px;
overflow: hidden;
position: relative;
}
.preview .over {
position: absolute;
top: 5em;
bottom: 0;
left: 0;
right: 0;
/* Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#ffffff+0,ffffff+100&0+0,1+100 */
background: -moz-linear-gradient(top, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(top, rgba(255,255,255,0) 0%,rgba(255,255,255,1) 100%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to bottom, rgba(255,255,255,0) 0%,rgba(255,255,255,1) 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#00ffffff', endColorstr='#ffffff',GradientType=0 ); /* IE6-9 */
}
p.source {
font-size: 0.86em;
margin-top: 0.25em;
margin-bottom: 0;
}
.attention-box {
text-align: center;
font-size: 1.1em;
}
.attention-box hr { margin: 4rem auto; }
hr { max-width: 40rem; }
header {
padding: 0 !important;
text-align: left !important;
margin: 1em !important;
max-width: 100% !important;
}
body#collection header nav {
display: inline !important;
}
body#collection header nav:not(#full-nav):not(#user-nav) {
margin: 0 0 0 1em !important;
}
header nav#user-nav {
margin-left: 0 !important;
}
body#collection header nav.tabs a:first-child {
margin-left: 1em;
}
body#collection article {
max-width: 40em !important;
}
</style>
{{end}}
{{define "body-attrs"}}id="collection"{{end}}
{{define "content"}}
<div class="content-container snug">
<h1>{{.ContentTitle}}</h1>
<p{{if .SelTopic}} style="text-align:center"{{end}}>{{if .SelTopic}}#{{.SelTopic}} posts{{else}}{{.Content}}{{end}}</p>
</div>
<div id="wrapper">
{{ if gt (len .Posts) 0 }}
<section itemscope itemtype="http://schema.org/Blog">
{{range .Posts}}<article class="{{.Font}} h-entry" itemscope itemtype="http://schema.org/BlogPosting">
{{if .Title.String -}}
<h2 class="post-title" itemprop="name" class="p-name">
{{- if .IsPaid}}{{template "paid-badge" .}}{{end -}}
<a href="{{if .Slug.String}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}.md{{end}}" itemprop="url" class="u-url">{{.PlainDisplayTitle}}</a>
</h2>
<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{if not .Title.String}}<a href="{{.Collection.CanonicalURL}}{{.Slug.String}}" itemprop="url">{{end}}{{.DisplayDate}}{{if not .Title.String}}</a>{{end}}</time>
{{- else -}}
<h2 class="post-title" itemprop="name">
{{- if .IsPaid}}{{template "paid-badge" .}}{{end -}}
<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}"><a href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}.md{{end}}" itemprop="url" class="u-url">{{.DisplayDate}}</a></time>
</h2>
{{- end}}
- <p class="source">{{if .Collection}}from <a href="{{.Collection.CanonicalURL}}">{{.Collection.DisplayTitle}}</a>{{else}}<em>Anonymous</em>{{end}}</p>
+ <p class="source">{{if .Collection}}{{call $.Tr "from"}} <a href="{{.Collection.CanonicalURL}}">{{.Collection.DisplayTitle}}</a>{{else}}<em>{{call $.Tr "Anonymous"}}</em>{{end}}</p>
{{if .Excerpt}}<div class="p-summary" {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">{{.Excerpt}}</div>
<a class="read-more" href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}.md{{end}}">{{localstr "Read more..." .Language.String}}</a>{{else}}<div class="e-content preview" {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}">{{ if not .HTMLContent }}<p id="post-body" class="e-content preview">{{.Content}}</p>{{ else }}{{.HTMLContent}}{{ end }}<div class="over">&nbsp;</div></div>
<a class="read-more maybe" href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}{{.CanonicalURL .Host}}.md{{end}}">{{localstr "Read more..." .Language.String}}</a>{{end}}</article>
{{end}}
</section>
{{ else }}
<div class="attention-box">
<p>No posts here yet!</p>
</div>
{{ end }}
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
- {{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .CurrentPage}}">&#8672; Older</a>{{end}}
- {{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .CurrentPage}}">Newer &#8674;</a>{{end}}
+ {{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .CurrentPage}}">&#8672; {{call .Tr "Older"}}</a>{{end}}
+ {{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .CurrentPage}}">{{call .Tr "Newer"}} &#8674;</a>{{end}}
</nav>{{end}}
</div>
<script src="/js/localdate.js">
<script type="text/javascript">
(function() {
var $articles = document.querySelectorAll('article');
for (var i=0; i<$articles.length; i++) {
var $art = $articles[i];
var $more = $art.querySelector('.read-more.maybe');
if ($more != null) {
if ($art.querySelector('.e-content.preview').clientHeight < 180) {
$more.parentNode.removeChild($more);
var $overlay = $art.querySelector('.over');
$overlay.parentNode.removeChild($overlay);
}
}
}
})();
</script>
{{end}}
diff --git a/templates/user/admin.tmpl b/templates/user/admin.tmpl
index 1a0e6b4..a5b52b3 100644
--- a/templates/user/admin.tmpl
+++ b/templates/user/admin.tmpl
@@ -1,68 +1,72 @@
{{define "admin"}}
{{template "header" .}}
<style type="text/css">
h2 {font-weight: normal;}
ul.pagenav {list-style: none;}
form {
margin: 0 0 2em;
}
form dt {
line-height: inherit;
}
.ui.divider:not(.vertical):not(.horizontal) {
border-top: 1px solid rgba(34,36,38,.15);
border-bottom: 1px solid rgba(255,255,255,.1);
}
.ui.divider {
margin: 1rem 0;
line-height: 1;
height: 0;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: rgba(0,0,0,.85);
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
font-size: 1rem;
}
.invisible {
display: none;
}
p.docs {
font-size: 0.86em;
}
.stats {
font-size: 1.2em;
margin: 1em 0;
+ text-transform: lowercase;
}
.num {
font-weight: bold;
font-size: 1.5em;
}
</style>
<div class="content-container snug">
{{template "admin-header" .}}
{{if .Message}}<p>{{.Message}}</p>{{end}}
<div class="row stats">
- <div><span class="num">{{largeNumFmt .UsersCount}}</span> {{pluralize "user" "users" .UsersCount}}</div>
- <div><span class="num">{{largeNumFmt .CollectionsCount}}</span> {{pluralize "blog" "blogs" .CollectionsCount}}</div>
- <div><span class="num">{{largeNumFmt .PostsCount}}</span> {{pluralize "post" "posts" .PostsCount}}</div>
+ {{ $N := .UsersCount }}{{ if eq .AppCfg.Lang "eu_ES" }}{{ $N = 1}}{{ end }}
+ {{ $C := .CollectionsCount }}{{ if eq .AppCfg.Lang "eu_ES" }}{{ $C = 1}}{{ end }}
+ {{ $P := .PostsCount }}{{ if eq .AppCfg.Lang "eu_ES" }}{{ $P = 1}}{{ end }}
+ <div><span class="num">{{largeNumFmt .UsersCount}}</span> {{call .Tr "User" $N}}</div>
+ <div><span class="num">{{largeNumFmt .CollectionsCount}}</span> {{call .Tr "Blog" $C}}</div>
+ <div><span class="num">{{largeNumFmt .PostsCount}}</span> {{call .Tr "post" $P}}</div>
</div>
</div>
<script>
history.replaceState(null, "", "/admin"+window.location.hash);
</script>
{{template "footer" .}}
{{template "body-end" .}}
{{end}}
diff --git a/templates/user/admin/app-settings.tmpl b/templates/user/admin/app-settings.tmpl
index 50c50ec..7c4ec7f 100644
--- a/templates/user/admin/app-settings.tmpl
+++ b/templates/user/admin/app-settings.tmpl
@@ -1,176 +1,176 @@
{{define "app-settings"}}
{{template "header" .}}
<style type="text/css">
h2 {font-weight: normal;}
form {
margin: 0 0 2em;
}
form dt {
line-height: inherit;
}
.invisible {
display: none;
}
p.docs {
font-size: 0.86em;
}
input[type=checkbox] {
height: 1em;
width: 1em;
}
select {
font-size: 1em;
}
</style>
<div class="content-container snug">
{{template "admin-header" .}}
{{if .Message}}<p><a name="config"></a>{{.Message}}</p>{{end}}
{{if .ConfigMessage}}<p class="success" style="text-align: center">{{.ConfigMessage}}</p>{{end}}
<form action="/admin/update/config" method="post">
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}>
- Site Title
- <p>Your public site name.</p>
+ {{call .Tr "Site Title"}}
+ <p>{{call .Tr "Your public site name."}}</p>
</div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="text" name="site_name" id="site_name" class="inline" value="{{.Config.SiteName}}" style="width: 14em;"/></div>
</div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}>
- Site Description
- <p>Describe your site &mdash; this shows in your site's metadata.</p>
+ {{call .Tr "Site Description"}}
+ <p>{{call .Tr "Describe your site — this shows in your site's metadata."}}</p>
</div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="text" name="site_desc" id="site_desc" class="inline" value="{{.Config.SiteDesc}}" style="width: 14em;"/></div>
</div>
<div class="features row">
<div>
- Host
- <p>The public address where users will access your site, starting with <code>http://</code> or <code>https://</code>.</p>
+ {{call .Tr "Host"}}
+ <p>{{call .Tr "The public address where users will access your site, starting with `http://` or `https://`." true}}</p>
</div>
<div>{{.Config.Host}}</div>
</div>
<div class="features row">
<div>
- Community Mode
- <p>Whether your site is made for one person or many.</p>
+ {{call .Tr "Community Mode"}}
+ <p>{{call .Tr "Whether your site is made for one person or many."}}</p>
</div>
- <div>{{if .Config.SingleUser}}Single user{{else}}Multiple users{{end}}</div>
+ <div>{{if .Config.SingleUser}}{{call .Tr "Single user"}}{{else}}{{call .Tr "Multiple users"}}{{end}}</div>
</div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}>
- Landing Page
- <p>The page that logged-out visitors will see first. This should be an absolute path like: <code>/read</code></p>
+ {{call .Tr "Landing Page"}}
+ <p>{{call .Tr "The page that logged-out visitors will see first. This should be an absolute path like: `/read`." true}}</p>
</div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="text" name="landing" id="landing" class="inline" value="{{.Config.Landing}}" style="width: 14em;"/></div>
</div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="open_registration">
- Open Registrations
- <p>Allow anyone who visits the site to create an account.</p>
+ {{call .Tr "Open Registrations"}}
+ <p>{{call .Tr "Allow anyone who visits the site to create an account."}}</p>
</label></div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="open_registration" id="open_registration" {{if .Config.OpenRegistration}}checked="checked"{{end}} />
</div>
</div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="open_deletion">
- Allow account deletion
- <p>Allow all users to delete their account. Admins can always delete users.</p>
+ {{call .Tr "Allow account deletion"}}
+ <p>{{call .Tr "Allow all users to delete their account. Admins can always delete users."}}</p>
</label></div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="open_deletion" id="open_deletion" {{if .Config.OpenDeletion}}checked="checked"{{end}} />
</div>
</div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="user_invites">
- Allow invitations from...
- <p>Choose who is allowed to invite new people.</p>
+ {{call .Tr "Allow invitations from..."}}
+ <p>{{call .Tr "Choose who is allowed to invite new people."}}</p>
</label></div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}>
<select name="user_invites" id="user_invites">
- <option value="none" {{if eq .Config.UserInvites ""}}selected="selected"{{end}}>No one</option>
- <option value="admin" {{if eq .Config.UserInvites "admin"}}selected="selected"{{end}}>Only Admins</option>
- <option value="user" {{if eq .Config.UserInvites "user"}}selected="selected"{{end}}>All Users</option>
+ <option value="none" {{if eq .Config.UserInvites ""}}selected="selected"{{end}}>{{call .Tr "No one"}}</option>
+ <option value="admin" {{if eq .Config.UserInvites "admin"}}selected="selected"{{end}}>{{call .Tr "Only Admins"}}</option>
+ <option value="user" {{if eq .Config.UserInvites "user"}}selected="selected"{{end}}>{{call .Tr "All Users"}}</option>
</select>
</div>
</div>
<div class="features row">
<div><label for="private">
- Private Instance
- <p>Limit site access to people with an account.</p>
+ {{call .Tr "Private Instance"}}
+ <p>{{call .Tr "Limit site access to people with an account."}}</p>
</label></div>
<div><input type="checkbox" name="private" id="private" {{if .Config.Private}}checked="checked"{{end}} /></div>
</div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="local_timeline">
- Reader
- <p>Show a feed of user posts for anyone who chooses to share there.</p>
+ {{call .Tr "Reader"}}
+ <p>{{call .Tr "Show a feed of user posts for anyone who chooses to share there."}}</p>
</label></div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="local_timeline" id="local_timeline" {{if .Config.LocalTimeline}}checked="checked"{{end}} /></div>
</div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="default_visibility">
- Default blog visibility
- <p>The default setting for new accounts and blogs.</p>
+ {{call .Tr "Default blog visibility"}}
+ <p>{{call .Tr "The default setting for new accounts and blogs."}}</p>
</label></div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}>
<select name="default_visibility" id="default_visibility">
- <option value="unlisted" {{if eq .Config.DefaultVisibility "unlisted"}}selected="selected"{{end}}>Unlisted</option>
- <option value="public" {{if eq .Config.DefaultVisibility "public"}}selected="selected"{{end}}>Public</option>
- <option value="private" {{if eq .Config.DefaultVisibility "private"}}selected="selected"{{end}}>Private</option>
+ <option value="unlisted" {{if eq .Config.DefaultVisibility "unlisted"}}selected="selected"{{end}}>{{call .Tr "Unlisted"}}</option>
+ <option value="public" {{if eq .Config.DefaultVisibility "public"}}selected="selected"{{end}}>{{call .Tr "Public"}}</option>
+ <option value="private" {{if eq .Config.DefaultVisibility "private"}}selected="selected"{{end}}>{{call .Tr "Private"}}</option>
</select>
</div>
</div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="max_blogs">
- Maximum Blogs per User
- <p>Keep things simple by setting this to <strong>1</strong>, unlimited by setting to <strong>0</strong>, or pick another amount.</p>
+ {{call .Tr "Maximum Blogs per User"}}
+ <p>{{call .Tr "Keep things simple by setting this to **1**, unlimited by setting to **0**, or pick another amount." true}}</p>
</label></div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="number" name="max_blogs" id="max_blogs" class="inline" min="0" value="{{.Config.MaxBlogs}}"/></div>
</div>
<div class="features row">
<div><label for="federation">
- Federation
- <p>Enable accounts on this site to propagate their posts via the ActivityPub protocol.</p>
+ {{call .Tr "Federation"}}
+ <p>{{call .Tr "Enable accounts on this site to propagate their posts via the ActivityPub protocol."}}</p>
</label></div>
<div><input type="checkbox" name="federation" id="federation" {{if .Config.Federation}}checked="checked"{{end}} /></div>
</div>
<div class="features row">
<div><label for="public_stats">
- Public Stats
- <p>Publicly display the number of users and posts on your <strong>About</strong> page.</p>
+ {{call .Tr "Public Stats"}}
+ <p>{{call .Tr "Erakutsi publikoki erabiltzaile eta post kopurua **%s** orrian." true (variables "About")}}</p>
</label></div>
<div><input type="checkbox" name="public_stats" id="public_stats" {{if .Config.PublicStats}}checked="checked"{{end}} /></div>
</div>
<div class="features row">
<div><label for="monetization">
- Monetization
- <p>Enable blogs on this site to receive micro&shy;pay&shy;ments from readers via <a target="wm" href="https://webmonetization.org/">Web Monetization</a>.</p>
+ {{call .Tr "Monetization"}}
+ <p>{{call .Tr "Enable blogs on this site to receive micropayments from readers via %s." true (variables "Web Monetization;https://webmonetization.org/" )}}</p>
</label></div>
<div><input type="checkbox" name="monetization" id="monetization" {{if .Config.Monetization}}checked="checked"{{end}} /></div>
</div>
<div class="features row">
<div><label for="min_username_len">
- Minimum Username Length
- <p>The minimum number of characters allowed in a username. (Recommended: 2 or more.)</p>
+ {{call .Tr "Minimum Username Length"}}
+ <p>{{call .Tr "The minimum number of characters allowed in a username. (Recommended: 2 or more.)"}}</p>
</label></div>
<div><input type="number" name="min_username_len" id="min_username_len" class="inline" min="1" max="100" value="{{.Config.MinUsernameLen}}"/></div>
</div>
<div class="features row">
- <input type="submit" value="Save Settings" />
+ <input type="submit" value="{{call .Tr "Save Settings"}}" />
</div>
</form>
-
- <p class="docs">Still have questions? Read more details in the <a href="https://writefreely.org/docs/{{.OfficialVersion}}/admin/config">configuration docs</a>.</p>
+ {{ $link := print "configuration docs;https://writefreely.org/docs/" .OfficialVersion "/admin/config" }}
+ <p class="docs">{{call .Tr "Still have questions? Read more details in the %s." true (variables $link)}}</p>
</div>
<script>
history.replaceState(null, "", "/admin/settings"+window.location.hash);
</script>
{{template "footer" .}}
{{template "body-end" .}}
{{end}}
diff --git a/templates/user/admin/app-updates.tmpl b/templates/user/admin/app-updates.tmpl
index 62fd83d..7403ab4 100644
--- a/templates/user/admin/app-updates.tmpl
+++ b/templates/user/admin/app-updates.tmpl
@@ -1,48 +1,49 @@
{{define "app-updates"}}
{{template "header" .}}
<style type="text/css">
p.intro {
text-align: left;
}
p.disabled {
font-style: italic;
color: #999;
}
</style>
<div class="content-container snug">
{{template "admin-header" .}}
{{ if .UpdateChecks }}
{{if .CheckFailed}}
- <p class="intro"><span class="ex failure">&times;</span> Automated update check failed.</p>
- <p>Installed version: <strong>{{.Version}}</strong> (<a href="{{.CurReleaseNotesURL}}" target="changelog-wf">release notes</a>).</p>
- <p>Learn about latest releases on the <a href="https://blog.writefreely.org/tag:release" target="changelog-wf">WriteFreely blog</a> or <a href="https://discuss.write.as/c/writefreely/updates" target="forum-wf">forum</a>.</p>
+ <p class="intro"><span class="ex failure">&times;</span> {{call .Tr "Automated update check failed."}}</p>
+ <p>{{call .Tr "Installed version: **%s** (%s)." true (variables .Version "release notes; .CurReleaseNotesURL") }}</p>
+ <p>{{call .Tr "Learn about latest releases on the %s or %s." true (variables "Writefreely blog;https://blog.writefreely.org/tag:release" "forum;https://discuss.write.as/c/writefreely/updates" )}}</p>
{{else if not .UpdateAvailable}}
- <p class="intro"><span class="check">&check;</span> WriteFreely is <strong>up to date</strong>.</p>
- <p>Installed version: <strong>{{.Version}}</strong> (<a href="{{.LatestReleaseNotesURL}}" target="changelog-wf">release notes</a>).</p>
+ <p class="intro"><span class="check">&check;</span>{{call .Tr "WriteFreely is **up to date**."}}</p>
+ <p>{{call .Tr "Installed version: **%s** (%s)." true (variables .Version "release notes; .CurReleaseNotesURL") }}</p>
{{else}}
- <p class="intro">A new version of WriteFreely is available! <a href="{{.LatestReleaseURL}}" target="download-wf" style="font-weight: bold;">Get {{.LatestVersion}}</a></p>
- <p class="changelog">
- <a href="{{.LatestReleaseNotesURL}}" target="changelog-wf">Read the release notes</a> for details on features, bug fixes, and notes on upgrading from your current version, <strong>{{.Version}}</strong>.
- </p>
+ {{ $link := print .LatestVersion ";" .LatestReleaseURL }}
+ <p class="intro">{{call .Tr "A new version of WriteFreely is available! **%s %s**" true (variables "Get" $link) }}</p>
+ {{ $link2 := print "release notes;" .LatestReleaseNotesURL }}
+ <p class="changelog">{{call .Tr "Read the %s for details on features, bug fixes, and notes on upgrading from your current version, **%s**." true (variables $link2 .Version) }}</p>
{{end}}
- <p style="font-size: 0.86em;"><em>Last checked</em>: <time class="dt-published" datetime="{{.LastChecked8601}}">{{.LastChecked}}</time>. <a href="/admin/updates?check=now">Check now</a>.</p>
+ <p style="font-size: 0.86em;"><em>{{call .Tr "Last checked"}}</em>: <time class="dt-published" datetime="{{.LastChecked8601}}">{{.LastChecked}}</time>. <a href="/admin/updates?check=now">{{call .Tr "Check now"}}</a>.</p>
<script>
// Code modified from /js/localdate.js
- var displayEl = document.querySelector("time");
- var d = new Date(displayEl.getAttribute("datetime"));
- displayEl.textContent = d.toLocaleDateString(navigator.language || "en-US", { dateStyle: 'long', timeStyle: 'short' });
+ //var displayEl = document.querySelector("time");
+ //var d = new Date(displayEl.getAttribute("datetime"));
+ //displayEl.textContent = d.toLocaleDateString(navigator.language || "en-US", { dateStyle: 'long', timeStyle: 'short' });
</script>
+ <script src="/js/localdate.js"></script>
{{ else }}
- <p class="intro disabled">Automated update checks are disabled.</p>
- <p>Installed version: <strong>{{.Version}}</strong> (<a href="{{.CurReleaseNotesURL}}" target="changelog-wf">release notes</a>).</p>
- <p>Learn about latest releases on the <a href="https://blog.writefreely.org/tag:release" target="changelog-wf">WriteFreely blog</a> or <a href="https://discuss.write.as/c/writefreely/updates" target="forum-wf">forum</a>.</p>
+ <p class="intro disabled">{{call .Tr "Automated update checks are disabled."}}</p>
+ <p>{{call .Tr "Installed version: **%s** (%s)." true (variables .Version "release notes; .CurReleaseNotesURL") }}</p>
+ <p>{{call .Tr "Learn about latest releases on the %s or %s." true (variables "Writefreely blog;https://blog.writefreely.org/tag:release" "forum;https://discuss.write.as/c/writefreely/updates" )}}</p>
{{ end }}
{{template "footer" .}}
{{template "body-end" .}}
{{end}}
diff --git a/templates/user/admin/pages.tmpl b/templates/user/admin/pages.tmpl
index 7a9e66a..a431c2e 100644
--- a/templates/user/admin/pages.tmpl
+++ b/templates/user/admin/pages.tmpl
@@ -1,37 +1,37 @@
{{define "pages"}}
{{template "header" .}}
<style>
table.classy.export .disabled, table.classy.export a {
text-transform: initial;
}
</style>
<div class="snug content-container">
{{template "admin-header" .}}
- <h2 id="posts-header" style="display: flex; justify-content: space-between;">Pages</h2>
+ <h2 id="posts-header" style="display: flex; justify-content: space-between;">{{call .Tr "Page" 2}}</h2>
<table class="classy export" style="width:100%">
<tr>
- <th>Page</th>
- <th>Last Modified</th>
+ <th>{{call .Tr "Page"}}</th>
+ <th>{{call .Tr "last modified"}}</th>
</tr>
<tr>
- <td colspan="2"><a href="/admin/page/landing">Home</a></td>
+ <td colspan="2"><a href="/admin/page/landing">{{call .Tr "Home"}}</a></td>
</tr>
{{if .LocalTimeline}}<tr>
- <td colspan="2"><a href="/admin/page/reader">Reader</a></td>
+ <td colspan="2"><a href="/admin/page/reader">{{call .Tr "Reader"}}</a></td>
</tr>{{end}}
{{range .Pages}}
<tr>
<td><a href="/admin/page/{{.ID}}">{{if .Title.Valid}}{{.Title.String}}{{else}}{{.ID}}{{end}}</a></td>
<td style="text-align:right">{{.UpdatedFriendly}}</td>
</tr>
{{end}}
</table>
</div>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/admin/users.tmpl b/templates/user/admin/users.tmpl
index f6b218c..7393887 100644
--- a/templates/user/admin/users.tmpl
+++ b/templates/user/admin/users.tmpl
@@ -1,42 +1,44 @@
{{define "users"}}
{{template "header" .}}
<div class="snug content-container">
{{template "admin-header" .}}
<!-- TODO: if other use for flashes use patern like account_import.go -->
{{if .Flashes}}
<p class="alert success">
{{range .Flashes}}{{.}}{{end}}
</p>
{{end}}
<div class="row admin-actions" style="justify-content: space-between;">
- <span style="font-style: italic; font-size: 1.2em">{{.TotalUsers}} {{pluralize "user" "users" .TotalUsers}}</span>
- <a class="btn cta" href="/me/invites">+ Invite people</a>
+ {{ $T := .TotalUsers }}{{ if eq .AppCfg.Lang "eu_ES" }}{{ $T = 1}}{{ end }}
+ <span style="font-style: italic; font-size: 1.2em">{{.TotalUsers}} {{call .Tr "User" $T}}</span>
+ <a class="btn cta" href="/me/invites">+ {{call .Tr "Invite people"}}</a>
</div>
<table class="classy export" style="width:100%">
<tr>
- <th>User</th>
- <th>Joined</th>
- <th>Type</th>
- <th>Status</th>
+ <th>{{call .Tr "User"}}{{ if eq .AppCfg.Lang "eu_ES" }}a{{end}}</th>
+ <th>{{call .Tr "joined"}}</th>
+ <th>{{call .Tr "type"}}</th>
+ <th>{{call .Tr "status"}}</th>
</tr>
{{range .Users}}
<tr>
<td><a href="/admin/user/{{.Username}}">{{.Username}}</a></td>
- <td>{{.CreatedFriendly}}</td>
- <td style="text-align:center">{{if .IsAdmin}}Admin{{else}}User{{end}}</td>
- <td style="text-align:center">{{if .IsSilenced}}Silenced{{else}}Active{{end}}</td>
+ <td><time datetime="{{.CreatedFriendly}}" content="{{.CreatedFriendly}}"></td>
+ <td style="text-align:center">{{if .IsAdmin}}Admin{{else}}{{call $.Tr "User"}}{{ if eq $.AppCfg.Lang "eu_ES" }}a{{end}}{{end}}</td>
+ <td style="text-align:center">{{if .IsSilenced}}{{call $.Tr "Silenced"}}{{else}}{{call $.Tr "Active"}}{{end}}</td>
</tr>
{{end}}
</table>
<nav class="pager pages">
{{range $n := .TotalPages}}<a href="/admin/users{{if ne $n 1}}?p={{$n}}{{end}}" {{if eq $.CurPage $n}}class="selected"{{end}}>{{$n}}</a>{{end}}
</nav>
</div>
+<script src="/js/localdate.js"></script>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/admin/view-page.tmpl b/templates/user/admin/view-page.tmpl
index 161e40b..c909bd1 100644
--- a/templates/user/admin/view-page.tmpl
+++ b/templates/user/admin/view-page.tmpl
@@ -1,77 +1,77 @@
{{define "view-page"}}
{{template "header" .}}
<style>
label {
display: block;
margin-top: 1em;
padding: 0 0 1em;
color: #666;
}
.content-desc {
font-size: 0.95em;
}
.page-desc {
margin: 0 0 0.5em;
}
textarea + .content-desc {
margin: 0.5em 0 1em;
font-style: italic;
}
input[type=text] {
/* Match textarea color. TODO: y is it like this thooo */
border-color: #ccc;
}
</style>
<div class="snug content-container">
{{template "admin-header" .}}
- <h2 id="posts-header">{{if eq .Content.ID "landing"}}Home page{{else}}{{.Content.ID}} page{{end}}</h2>
+ <h2 id="posts-header">{{if eq .Content.ID "landing"}}{{call .Tr "Home"}} {{call .Tr "Page"}}{{else}}{{.Content.Title.String}} {{call .Tr "Page"}}{{end}}</h2>
{{if eq .Content.ID "about"}}
- <p class="page-desc content-desc">Describe what your instance is <a href="/about" target="page">about</a>.</p>
+ <p class="page-desc content-desc">{{call .Tr "Describe what your instance is %s." true (variables "About;/about") }}</p>
{{else if eq .Content.ID "privacy"}}
- <p class="page-desc content-desc">Outline your <a href="/privacy" target="page">privacy policy</a>.</p>
+ <p class="page-desc content-desc">{{call .Tr "Outline your %s." true (variables "Privacy Policy;/privacy") }}</p>
{{else if eq .Content.ID "reader"}}
- <p class="page-desc content-desc">Customize your <a href="/read" target="page">Reader</a> page.</p>
+ <p class="page-desc content-desc">{{call .Tr "Customize your %s page." true (variables "Reader;/read") }}</p>
{{else if eq .Content.ID "landing"}}
- <p class="page-desc content-desc">Customize your <a href="/?landing=1" target="page">home page</a>.</p>
+ <p class="page-desc content-desc">{{call .Tr "Customize your %s page." true (variables "Home;/landing=1") }}</p>
{{end}}
{{if .Message}}<p>{{.Message}}</p>{{end}}
<form method="post" action="/admin/update/{{.Content.ID}}" onsubmit="savePage(this)">
{{if .Banner}}
<label for="banner">
- Banner
+ {{call .Tr "Banner"}}
</label>
<textarea id="banner" class="section codable norm edit-page" style="min-height: 5em; height: 5em;" name="banner">{{.Banner.Content}}</textarea>
- <p class="content-desc">We suggest a header (e.g. <code># Welcome</code>), optionally followed by a small bit of text. Accepts Markdown and HTML.</p>
+ <p class="content-desc">{{call .Tr "We suggest a header (e.g. `# Welcome`), optionally followed by a small bit of text. Accepts Markdown and HTML." true}}</p>
{{else}}
<label for="title">
- Title
+ {{call .Tr "Title"}}
</label>
<input type="text" name="title" id="title" value="{{.Content.Title.String}}" />
{{end}}
<label for="content">
- {{if .Banner}}Body{{else}}Content{{end}}
+ {{if .Banner}}{{call .Tr "Body"}}{{else}}{{call .Tr "Content"}}{{end}}
</label>
<textarea id="content" class="section codable norm edit-page" name="content">{{.Content.Content}}</textarea>
- <p class="content-desc">Accepts Markdown and HTML.</p>
+ <p class="content-desc">{{call .Tr "Accepts Markdown and HTML."}}</p>
- <input type="submit" value="Save" />
+ <input type="submit" value="{{call .Tr "Save"}}" />
</form>
</div>
<script>
function savePage(el) {
var $btn = el.querySelector('input[type=submit]');
- $btn.value = 'Saving...';
+ $btn.value = '{{call .Tr "Saving..."}}';
$btn.disabled = true;
}
</script>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/admin/view-user.tmpl b/templates/user/admin/view-user.tmpl
index dac88bf..495a322 100644
--- a/templates/user/admin/view-user.tmpl
+++ b/templates/user/admin/view-user.tmpl
@@ -1,215 +1,216 @@
{{define "view-user"}}
{{template "header" .}}
<style>
table.classy th {
text-align: left;
}
h3 {
font-weight: normal;
}
td.active-silence {
display: flex;
align-items: center;
}
td.active-silence > input[type="submit"] {
margin-left: auto;
margin-right: 5%;
}
@media only screen and (max-width: 500px) {
td.active-silence {
flex-wrap: wrap;
}
td.active-silence > input[type="submit"] {
margin: auto;
}
}
input.copy-text {
text-align: center;
font-size: 1.2em;
color: #555;
width: 100%;
box-sizing: border-box;
}
.modal {
position: fixed;
}
</style>
<div class="snug content-container">
<div id="overlay"></div>
{{template "admin-header" .}}
<h2 id="posts-header">{{.User.Username}}</h2>
{{if .NewPassword}}<div class="alert success">
- <p>This user's password has been reset to:</p>
+ <p>{{call .Tr "This user's password has been reset to:"}}</p>
<p><input type="text" class="copy-text" value="{{.NewPassword}}" onfocus="if (this.select) this.select(); else this.setSelectionRange(0, this.value.length);" readonly /></p>
- <p>They can use this new password to log in to their account. <strong>This will only be shown once</strong>, so be sure to copy it and send it to them now.</p>
- {{if .ClearEmail}}<p>Their email address is: <a href="mailto:{{.ClearEmail}}">{{.ClearEmail}}</a></p>{{end}}
+ <p>{{call .Tr "They can use this new password to log in to their account. **This will only be shown once**, so be sure to copy it and send it to them now." true}}</p>
+ {{if .ClearEmail}}<p>{{call .Tr "Their email address is:"}} <a href="mailto:{{.ClearEmail}}">{{.ClearEmail}}</a></p>{{end}}
</div>
{{end}}
<table class="classy export">
<tr>
- <th>No.</th>
+ <th>{{call .Tr "No."}}</th>
<td>{{.User.ID}}</td>
</tr>
<tr>
- <th>Type</th>
- <td>{{if .User.IsAdmin}}Admin{{else}}User{{end}}</td>
+ <th>{{call .Tr "type"}}</th>
+ <td>{{if .User.IsAdmin}}Admin{{else}}{{call .Tr "User"}}{{ if eq .AppCfg.Lang "eu_ES" }}a{{end}}{{end}}</td>
</tr>
<tr>
- <th>Username</th>
+ <th>{{call .Tr "Username"}}</th>
<td>{{.User.Username}}</td>
</tr>
<tr>
- <th>Joined</th>
- <td>{{.User.CreatedFriendly}}</td>
+ <th>{{call .Tr "joined"}}</th>
+ <td><time datetime="{{.User.CreatedFriendly}}" content="{{.User.CreatedFriendly}}"></td>
</tr>
<tr>
- <th>Total Posts</th>
+ <th>{{call .Tr "total posts"}}</th>
<td>{{.TotalPosts}}</td>
</tr>
<tr>
- <th>Last Post</th>
- <td>{{if .LastPost}}{{.LastPost}}{{else}}Never{{end}}</td>
+ <th>{{call .Tr "last post"}}</th>
+ <td>{{if .LastPost}}<time datetime="{{.LastPost}}" content="{{.LastPost}}">{{else}}{{call .Tr "Never"}}{{end}}</td>
</tr>
<tr>
<form action="/admin/user/{{.User.Username}}/status" method="POST" {{if not .User.IsSilenced}}onsubmit="return confirmSilence()"{{end}}>
- <th><a id="status"></a>Status</th>
+ <th><a id="status"></a>{{call .Tr "status"}}</th>
<td class="active-silence">
{{if .User.IsSilenced}}
- <p>Silenced</p>
- <input type="submit" value="Unsilence"/>
+ <p>{{call .Tr "Silenced"}}</p>
+ <input type="submit" value="{{call .Tr "Unsilence"}}"/>
{{else}}
- <p>Active</p>
- <input class="danger" type="submit" value="Silence" {{if .User.IsAdmin}}disabled{{end}}/>
+ <p>{{call .Tr "Active"}}</p>
+ <input class="danger" type="submit" value="{{call .Tr "Silence"}}" {{if .User.IsAdmin}}{{call .Tr "disabled"}}{{end}}/>
{{end}}
</td>
</form>
</tr>
<tr>
- <th>Password</th>
+ <th>{{call .Tr "Password"}}</th>
<td>
{{if ne .Username .User.Username}}
<form id="reset-form" action="/admin/user/{{.User.Username}}/passphrase" method="post" autocomplete="false">
<input type="hidden" name="user" value="{{.User.ID}}"/>
- <button type="submit">Reset</button>
+ <button type="submit">{{call .Tr "Reset"}}</button>
</form>
{{else}}
- <a href="/me/settings" title="Go to reset password page">Change your password</a>
+ <a href="/me/settings" title='{{call .Tr "Go to reset password page"}}'>{{call .Tr "Change your password"}}</a>
{{end}}
</td>
</tr>
</table>
- <h2>Blogs</h2>
+ <h2>{{call .Tr "Blog" 2}}</h2>
{{range .Colls}}
<h3><a href="/{{.Alias}}/">{{.Title}}</a></h3>
<table class="classy export">
<tr>
- <th>Alias</th>
+ <th>{{call $.Tr "Alias"}}</th>
<td>{{.Alias}}</td>
</tr>
<tr>
- <th>Title</th>
+ <th>{{call $.Tr "Title"}}</th>
<td>{{.Title}}</td>
</tr>
<tr>
- <th>Description</th>
+ <th>{{call $.Tr "Description"}}</th>
<td>{{.Description}}</td>
</tr>
<tr>
- <th>Visibility</th>
- <td>{{.FriendlyVisibility}}</td>
+ <th>{{call $.Tr "Visibility"}}</th>
+ <td>{{call $.Tr .FriendlyVisibility}}</td>
</tr>
<tr>
- <th>Views</th>
+ <th>{{call $.Tr "View" 2}}</th>
<td>{{.Views}}</td>
</tr>
<tr>
- <th>Posts</th>
+ <th>{{call $.Tr "Post" 2}}</th>
<td>{{.TotalPosts}}</td>
</tr>
<tr>
- <th>Last Post</th>
- <td>{{if .LastPost}}{{.LastPost}}{{else}}Never{{end}}</td>
+ <th>{{call $.Tr "last post"}}</th>
+ <td>{{if .LastPost}}<time datetime="{{.LastPost}}" content="{{.LastPost}}">{{else}}{{call .Tr "Never"}}{{end}}</td>
</tr>
{{if $.Config.Federation}}
<tr>
- <th>Fediverse Followers</th>
+ <th>{{call $.Tr "Fediverse followers"}}</th>
<td>{{.Followers}}</td>
</tr>
{{end}}
</table>
{{end}}
{{ if not .User.IsAdmin }}
- <h2>Incinerator</h2>
+ <h2>{{call .Tr "Incinerator"}}</h2>
<div class="alert danger">
<div class="row">
<div>
- <h3>Delete this user</h3>
- <p>Permanently erase all user data, with no way to recover it.</p>
+ <h3>{{call .Tr "Delete this user"}}</h3>
+ <p>{{call .Tr "Permanently erase all user data, with no way to recover it."}}</p>
</div>
- <button class="cta danger" onclick="prepareDeleteUser()">Delete this user...</button>
+ <button class="cta danger" onclick="prepareDeleteUser()">{{call .Tr "Delete this user"}}...</button>
</div>
</div>
{{end}}
</div>
<div id="modal-delete-user" class="modal">
- <h2>Are you sure?</h2>
+ <h2>{{call .Tr "Are you sure?"}}</h2>
<div class="body">
- <p style="text-align:left">This action <strong>cannot</strong> be undone. It will permanently erase all traces of this user, <strong>{{.User.Username}}</strong>, including their account information, blogs, and posts.</p>
- <p>Please type <strong>{{.User.Username}}</strong> to confirm.</p>
+ <p style="text-align:left">{{call .Tr "This action **cannot**be undone. It will permanently erase all traces of this user, **%s**, including their account information, blogs, and posts." true (variables .User.Username)}}</p>
+ <p>{{call .Tr "Please type **%s** to confirm." true (variables .User.Username)}}</p>
<ul id="delete-errors" class="errors"></ul>
<form action="/admin/user/{{.User.Username}}/delete" method="post" onsubmit="confirmDeletion()">
<input id="confirm-text" placeholder="{{.User.Username}}" type="text" class="confirm boxy" name="confirm-username" style="margin-top: 0.5em;" />
<div style="text-align:right; margin-top: 1em;">
- <a id="cancel-delete" style="margin-right:2em" href="#">Cancel</a>
- <input class="danger" type="submit" id="confirm-delete" value="Delete this user" disabled />
+ <a id="cancel-delete" style="margin-right:2em" href="#">{{call .Tr "Cancel"}}</a>
+ <input class="danger" type="submit" id="confirm-delete" value='{{call .Tr "Delete this user"}}' disabled />
</div>
</div>
</div>
<script src="/js/h.js"></script>
<script src="/js/modals.js"></script>
+<script src="/js/localdate.js"></script>
<script type="text/javascript">
H.getEl('cancel-delete').on('click', closeModals);
let $confirmDelBtn = document.getElementById('confirm-delete');
let $confirmText = document.getElementById('confirm-text')
$confirmText.addEventListener('input', function() {
$confirmDelBtn.disabled = this.value !== '{{.User.Username}}'
});
function prepareDeleteUser() {
$confirmText.value = ''
showModal('delete-user')
$confirmText.focus()
}
function confirmDeletion() {
$confirmDelBtn.disabled = true
- $confirmDelBtn.value = 'Deleting...'
+ $confirmDelBtn.value = {{call .Tr "Deleting..."}}
}
function confirmSilence() {
- return confirm("Silence this user? They'll still be able to log in and access their posts, but no one else will be able to see them anymore. You can reverse this decision at any time.");
+ return confirm({{call .Tr "Silence this user? They'll still be able to log in and access their posts, but no one else will be able to see them anymore. You can reverse this decision at any time."}});
}
form = document.getElementById("reset-form");
form.addEventListener('submit', function(e) {
e.preventDefault();
- agreed = confirm("Reset this user's password? This will generate a new temporary password that you'll need to share with them, and invalidate their old one.");
+ agreed = confirm({{call .Tr "Reset this user's password? This will generate a new temporary password that you'll need to share with them, and invalidate their old one."}});
if (agreed === true) {
form.submit();
}
});
</script>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/articles.tmpl b/templates/user/articles.tmpl
index 92f9c40..3358906 100644
--- a/templates/user/articles.tmpl
+++ b/templates/user/articles.tmpl
@@ -1,227 +1,231 @@
{{define "articles"}}
{{template "header" .}}
<style type="text/css">
a.loading {
font-style: italic;
color: #666;
}
#move-tmpl {
display: none;
}
+ a.action {
+ text-transform: lowercase;
+ }
</style>
<div class="snug content-container">
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
{{if .Silenced}}
- {{template "user-silenced"}}
+ {{template "user-silenced" (dict "Tr" $.Tr)}}
{{end}}
-<h1 id="posts-header">Drafts</h1>
+<h1 id="posts-header">{{call .Tr "Draft" 2}}</h1>
{{ if .AnonymousPosts }}
- <p>These are your draft posts. You can share them individually (without a blog) or move them to your blog when you're ready.</p>
+ <p>{{call .Tr "These are your draft posts. You can share them individually (without a blog) or move them to your blog when you're ready."}}</p>
<div id="anon-posts" class="atoms posts">
{{ range $el := .AnonymousPosts }}<div id="post-{{.ID}}" class="post">
<h3><a href="/{{if $.SingleUser}}d/{{end}}{{.ID}}" itemprop="url">{{.DisplayTitle}}</a></h3>
<h4>
<date datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}">{{.DisplayDate}}</date>
- <a class="action" href="/{{if $.SingleUser}}d/{{end}}{{.ID}}/edit">edit</a>
- <a class="delete action" href="/{{.ID}}" onclick="delPost(event, '{{.ID}}', true)">delete</a>
+ <a class="action" href="/{{if $.SingleUser}}d/{{end}}{{.ID}}/edit">{{call $.Tr "Edit"}}</a>
+ <a class="delete action" href="/{{.ID}}" onclick="delPost(event, '{{.ID}}', true, {{$.Locales}})">{{call $.Tr "Delete"}}</a>
{{ if $.Collections }}
{{if gt (len $.Collections) 1}}<div class="action flat-select">
- <select id="move-{{.ID}}" onchange="postActions.multiMove(this, '{{.ID}}', {{if $.SingleUser}}true{{else}}false{{end}})" title="Move this post to one of your blogs">
+ <select id="move-{{.ID}}" onchange="postActions.multiMove(this, '{{.ID}}', {{if $.SingleUser}}true{{else}}false{{end}})" title={{call .Tr "Move this post to one of your blogs"}}>
<option style="display:none"></option>
{{range $.Collections}}<option value="{{.Alias}}">{{.DisplayTitle}}</option>{{end}}
</select>
- <label for="move-{{.ID}}">move to...</label>
+ <label for="move-{{.ID}}">{{call .Tr "move to..."}}</label>
<img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" />
</div>{{else}}
{{range $.Collections}}
- <a class="action" href="/{{$el.ID}}" title="Publish this post to your blog '{{.DisplayTitle}}'" onclick="postActions.move(this, '{{$el.ID}}', '{{.Alias}}', {{if $.SingleUser}}true{{else}}false{{end}});return false">move to {{.DisplayTitle}}</a>
+ <a class="action" href="/{{$el.ID}}" title={{call $.Tr "Publish this post to your blog %s" true (variables .DisplayTitle)}} onclick="postActions.move(this, '{{$el.ID}}', '{{.Alias}}', {{if $.SingleUser}}true{{else}}false{{end}}, {{$.Locales}});return false">{{call $.Tr "move to %s" (variables .DisplayTitle)}}</a>
{{end}}
{{end}}
{{ end }}
</h4>
{{if .Summary}}<p>{{.SummaryHTML}}</p>{{end}}
</div>{{end}}
+ <script src="/js/localdate.js"></script>
</div>
-{{if eq (len .AnonymousPosts) 10}}<p id="load-more-p"><a href="#load">Load more...</a></p>{{end}}
+{{if eq (len .AnonymousPosts) 10}}<p id="load-more-p"><a href="#load">{{call .Tr "Load more..."}}</a></p>{{end}}
{{ else }}<div id="no-posts-published">
- <p>Your anonymous and draft posts will show up here once you've published some. You'll be able to share them individually (without a blog) or move them to a blog when you're ready.</p>
- {{if not .SingleUser}}<p>Alternatively, see your blogs and their posts on your <a href="/me/c/">Blogs</a> page.</p>{{end}}
+ <p>{{call .Tr "Your anonymous and draft posts will show up here once you've published some. You'll be able to share them individually (without a blog) or move them to a blog when you're ready."}}</p>
+ {{if not .SingleUser}}<p>{{call .Tr "Alternatively, see your blogs and their posts on your %s page." true (variables true "Blog;/me/c/")}}</p>{{end}}
- <p class="text-cta"><a href="{{if .SingleUser}}/me/new{{else}}/{{end}}">Start writing</a></p></div>{{ end }}
+ <p class="text-cta"><a href="{{if .SingleUser}}/me/new{{else}}/{{end}}">{{call .Tr "Start writing"}}</a></p></div>{{ end }}
<div id="moving"></div>
-<h2 id="unsynced-posts-header" style="display: none">unsynced posts</h2>
+<h2 id="unsynced-posts-header" style="display: none">{{call .Tr "unsynced posts"}}</h2>
<div id="unsynced-posts-info" style="margin-top: 1em"></div>
<div id="unsynced-posts" class="atoms"></div>
</div>
{{ if .Collections }}
<div id="move-tmpl">
{{if gt (len .Collections) 1}}
<div class="action flat-select">
- <select id="move-POST_ID" onchange="postActions.multiMove(this, 'POST_ID', {{if .SingleUser}}true{{else}}false{{end}})" title="Move this post to one of your blogs">
+ <select id="move-POST_ID" onchange="postActions.multiMove(this, 'POST_ID', {{if .SingleUser}}true{{else}}false{{end}}, {{$.Locales}})" title={{call .Tr "Move this post to one of your blogs"}}>
<option style="display:none"></option>
{{range .Collections}}<option value="{{.Alias}}">{{.DisplayTitle}}</option>{{end}}
</select>
- <label for="move-POST_ID">move to...</label>
+ <label for="move-POST_ID">{{call .Tr "move to..."}}</label>
<img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" />
</div>
{{else}}
{{range .Collections}}
- <a class="action" href="/POST_ID" title="Publish this post to your blog '{{.DisplayTitle}}'" onclick="postActions.move(this, 'POST_ID', '{{.Alias}}', {{if $.SingleUser}}true{{else}}false{{end}});return false">move to {{.DisplayTitle}}</a>
+ <a class="action" href="/POST_ID" title={{call $.Tr "Publish this post to your blog %s" (variables .DisplayTitle)}} onclick="postActions.move(this, 'POST_ID', '{{.Alias}}', {{if $.SingleUser}}true{{else}}false{{end}}, {{$.Locales}});return false">{{call $.Tr "move to %s" (variables .DisplayTitle)}}</a>
{{end}}
{{end}}
</div>
{{ end }}
<script src="/js/h.js"></script>
<script src="/js/postactions.js"></script>
<script>
var auth = true;
function postsLoaded(n) {
if (n == 0) {
return;
}
document.getElementById('unsynced-posts-header').style.display = 'block';
var syncing = false;
var $pInfo = document.getElementById('unsynced-posts-info');
$pInfo.className = 'alert info';
var plural = n != 1;
$pInfo.innerHTML = '<p>You have <strong>'+n+'</strong> post'+(plural?'s that aren\'t':' that isn\'t')+' synced to your account yet. <a href="#" id="btn-sync">Sync '+(plural?'them':'it')+' now</a>.</p>';
var $noPosts = document.getElementById('no-posts-published');
if ($noPosts != null) {
$noPosts.style.display = 'none';
document.getElementById('posts-header').style.display = 'none';
}
H.getEl('btn-sync').on('click', function(e) {
e.preventDefault();
if (syncing) {
return;
}
var http = new XMLHttpRequest();
var params = [];
var posts = JSON.parse(H.get('posts', '[]'));
if (posts.length > 0) {
for (var i=0; i<posts.length; i++) {
params.push({id: posts[i].id, token: posts[i].token});
}
}
this.style.fontWeight = 'bold';
this.innerText = 'Syncing '+(plural?'them':'it')+' now...';
http.open("POST", "/api/posts/claim", true);
// Send the proper header information along with the request
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
syncing = false;
this.innerText = 'Importing '+(plural?'them':'it')+' now...';
if (http.status == 200) {
var res = JSON.parse(http.responseText);
if (res.data.length > 0) {
if (res.data.length != posts.length) {
// TODO: handle something that royally fucked up
console.error("Request and result array length didn't match!");
return;
}
for (var i=0; i<res.data.length; i++) {
if (res.data[i].code == 200) {
// Post successfully claimed.
for (var j=0; j<posts.length; j++) {
// Find post in local store
if (posts[j].id == res.data[i].post.id) {
// Remove this post
posts.splice(j, 1);
break;
}
}
} else {
for (var j=0; j<posts.length; j++) {
// Find post in local store
if (posts[j].id == res.data[i].id) {
// Note the error in the local post
posts[j].error = res.data[i].error_msg;
break;
}
}
}
}
H.set('posts', JSON.stringify(posts));
location.reload();
}
} else {
// TODO: handle error visually (option to retry)
console.error("Didn't work at all, man.");
this.style.fontWeight = 'normal';
this.innerText = 'Sync '+(plural?'them':'it')+' now';
}
}
}
http.send(JSON.stringify(params));
syncing = true;
});
}
var $loadMore = H.getEl("load-more-p");
var curPage = 1;
var isLoadingMore = false;
function loadMorePosts() {
if (isLoadingMore === true) {
return;
}
var $link = this;
isLoadingMore = true;
$link.className = 'loading';
$link.textContent = 'Loading posts...';
var $posts = H.getEl("anon-posts");
curPage++;
var http = new XMLHttpRequest();
var url = "/api/me/posts?anonymous=1&page=" + curPage;
http.open("GET", url, true);
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
if (http.status == 200) {
var data = JSON.parse(http.responseText);
for (var i=0; i<data.data.length; i++) {
$posts.el.appendChild(createPostEl(data.data[i], true));
}
if (data.data.length < 10) {
$loadMore.el.parentNode.removeChild($loadMore.el);
}
} else {
alert("Failed to load more posts. Please try again.");
curPage--;
}
isLoadingMore = false;
$link.className = '';
$link.textContent = 'Load more...';
}
}
http.send();
}
$loadMore.el.querySelector('a').addEventListener('click', loadMorePosts);
</script>
<script src="/js/posts.js"></script>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/collection.tmpl b/templates/user/collection.tmpl
index 40e9624..c029eac 100644
--- a/templates/user/collection.tmpl
+++ b/templates/user/collection.tmpl
@@ -1,266 +1,266 @@
{{define "collection"}}
{{template "header" .}}
<style>
textarea.section.norm {
font-family: Lora,'Palatino Linotype','Book Antiqua','New York','DejaVu serif',serif !important;
min-height: 10em;
max-height: 20em;
resize: vertical;
}
@media (pointer: coarse) {
.codable {
font-size: 0.75em !important;
height: 17em !important;
}
}
</style>
<div class="content-container snug">
<div id="overlay"></div>
{{if .Silenced}}
- {{template "user-silenced"}}
+ {{template "user-silenced" (dict "Tr" $.Tr)}}
{{end}}
{{template "collection-breadcrumbs" .}}
- <h1>Customize</h1>
+ <h1>{{(call .Tr "Customize")}}</h1>
- {{template "collection-nav" (dict "Alias" .Alias "Path" .Path "SingleUser" .SingleUser)}}
+ {{template "collection-nav" (dict "Alias" .Alias "Path" .Path "SingleUser" .SingleUser "Tr" $.Tr)}}
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
<form name="customize-form" action="/api/collections/{{.Alias}}" method="post" onsubmit="return disableSubmit()">
<div id="collection-options">
<div style="text-align:center">
- <h1><input type="text" name="title" id="title" value="{{.DisplayTitle}}" placeholder="Title" /></h1>
- <p><input type="text" name="description" id="description" value="{{.Description}}" placeholder="Description" maxlength="160" /></p>
+ <h1><input type="text" name="title" id="title" value="{{.DisplayTitle}}" placeholder={{call .Tr "Title"}} /></h1>
+ <p><input type="text" name="description" id="description" value="{{.Description}}" placeholder={{call .Tr "Description"}} maxlength="160" /></p>
</div>
<div class="option">
<h2><a name="preferred-url"></a>URL</h2>
<div class="section">
- {{if eq .Alias .Username}}<p style="font-size: 0.8em">This blog uses your username in its URL{{if .Federation}} and fediverse handle{{end}}. You can change it in your <a href="/me/settings">Account Settings</a>.</p>{{end}}
+ {{if eq .Alias .Username}}<p style="font-size: 0.8em">{{if .Federation}}{{call .Tr "This blog uses your username in its URL and fediverse handle."}}{{else}}{{call .Tr "This blog uses your username in its URL."}}{{end}} {{call .Tr "You can change it in your %s." true (variables "Account Settings;/me/settings")}}</p>{{end}}
<ul style="list-style:none">
<li>
{{.FriendlyHost}}/<strong>{{.Alias}}</strong>/
</li>
<li>
<strong id="normal-handle-env" class="fedi-handle" {{if not .Federation}}style="display:none"{{end}}>@<span id="fedi-handle">{{.Alias}}</span>@<span id="fedi-domain">{{.FriendlyHost}}</span></strong>
</li>
</ul>
</div>
</div>
<div class="option">
- <h2>Publicity</h2>
+ <h2>{{call .Tr "Publicity"}}</h2>
<div class="section">
<ul style="list-style:none">
<li>
<label><input type="radio" name="visibility" id="visibility-unlisted" value="0" {{if .IsUnlisted}}checked="checked"{{end}} />
- Unlisted
+ {{call .Tr "Unlisted"}}
</label>
- <p>This blog is visible to {{if .Private}}any registered user on this instance{{else}}anyone with its link{{end}}.</p>
+ <p>{{if .Private}}{{call .Tr "This blog is visible to any registered user on this instance."}}{{else}}{{call .Tr "This blog is visible to anyone with its link."}}{{end}}</p>
</li>
<li>
<label class="option-text"><input type="radio" name="visibility" id="visibility-private" value="2" {{if .IsPrivate}}checked="checked"{{end}} />
- Private
+ {{call .Tr "Private"}}
</label>
- <p>Only you may read this blog (while you're logged in).</p>
+ <p>{{call .Tr "Only you may read this blog (while you're logged in)."}}</p>
</li>
<li>
<label class="option-text"><input type="radio" name="visibility" id="visibility-protected" value="4" {{if .IsProtected}}checked="checked"{{end}} />
- Password-protected: <input type="password" class="low-profile" name="password" id="collection-pass" autocomplete="new-password" placeholder="{{if .IsProtected}}xxxxxxxxxxxxxxxx{{else}}a memorable password{{end}}" />
+ {{call .Tr "Password-protected:"}} <input type="password" class="low-profile" name="password" id="collection-pass" autocomplete="new-password" placeholder="{{if .IsProtected}}xxxxxxxxxxxxxxxx{{else}}{{call .Tr "a memorable password"}}{{end}}" />
</label>
- <p>A password is required to read this blog.</p>
+ <p>{{call .Tr "A password is required to read this blog."}}</p>
</li>
{{if not .SingleUser}}
<li>
<label class="option-text{{if not .LocalTimeline}} disabled{{end}}"><input type="radio" name="visibility" id="visibility-public" value="1" {{if .IsPublic}}checked="checked"{{end}} {{if not .LocalTimeline}}disabled="disabled"{{end}} />
- Public
+ {{call .Tr "Public"}}
</label>
- {{if .LocalTimeline}}<p>This blog is displayed on the public <a href="/read">reader</a>, and is visible to {{if .Private}}any registered user on this instance{{else}}anyone with its link{{end}}.</p>
- {{else}}<p>The public reader is currently turned off for this community.</p>{{end}}
+ {{if .LocalTimeline}}<p>{{if .Private}}{{call .Tr "This blog is displayed on the public %s, and is visible to any registered user on this instance." true (variables "Reader;/read")}}{{else}}{{call .Tr "This blog is displayed on the public %s, and is visible to any registered user on this instance." true (variables "Reader;/read")}}{{end}}</p>
+ {{else}}<p>{{call .Tr "The public reader is currently turned off for this community."}}</p>{{end}}
</li>
{{end}}
</ul>
</div>
</div>
<div class="option">
- <h2>Display Format</h2>
+ <h2>{{call .Tr "Display Format"}}</h2>
<div class="section">
- <p class="explain">Customize how your posts display on your page.
+ <p class="explain">{{call .Tr "Customize how your posts display on your page."}}
</p>
<ul style="list-style:none">
<li>
<label><input type="radio" name="format" id="format-blog" value="blog" {{if or (not .Format) (eq .Format "blog")}}checked="checked"{{end}} />
Blog
</label>
- <p>Dates are shown. Latest posts listed first.</p>
+ <p>{{call .Tr "Dates are shown. Latest posts listed first."}}</p>
</li>
<li>
<label class="option-text"><input type="radio" name="format" id="format-novel" value="novel" {{if eq .Format "novel"}}checked="checked"{{end}} />
Novel
</label>
- <p>No dates shown. Oldest posts first.</p>
+ <p>{{call .Tr "No dates shown. Oldest posts first."}}</p>
</li>
<li>
<label class="option-text"><input type="radio" name="format" id="format-notebook" value="notebook" {{if eq .Format "notebook"}}checked="checked"{{end}} />
Notebook
</label>
- <p>No dates shown. Latest posts first.</p>
+ <p>{{call .Tr "No dates shown. Latest posts first."}}</p>
</li>
</ul>
</div>
</div>
<div class="option">
- <h2>Text Rendering</h2>
+ <h2>{{call .Tr "Text Rendering"}}</h2>
<div class="section">
- <p class="explain">Customize how plain text renders on your blog.</p>
+ <p class="explain">{{call .Tr "Customize how plain text renders on your blog."}}</p>
<ul style="list-style:none">
<li>
<label class="option-text disabled"><input type="checkbox" name="markdown" checked="checked" disabled />
Markdown
</label>
</li>
<li>
<label><input type="checkbox" name="mathjax" {{if .RenderMathJax}}checked="checked"{{end}} />
MathJax
</label>
</li>
</ul>
</div>
</div>
<div class="option">
- <h2>Custom CSS</h2>
+ <h2>{{call .Tr "Custom CSS"}}</h2>
<div class="section">
<textarea id="css-editor" class="section codable" name="style_sheet">{{.StyleSheet}}</textarea>
- <p class="explain">See our guide on <a href="https://guides.write.as/customizing/#custom-css">customization</a>.</p>
+ <p class="explain">{{call .Tr "See our guide on %s." true (variables "customization;https://guides.write.as/customizing/#custom-css")}}</p>
</div>
</div>
<div class="option">
- <h2>Post Signature</h2>
+ <h2>{{call .Tr "Post Signature"}}</h2>
<div class="section">
- <p class="explain">This content will be added to the end of every post on this blog, as if it were part of the post itself. Markdown, HTML, and shortcodes are allowed.</p>
+ <p class="explain">{{call .Tr "This content will be added to the end of every post on this blog, as if it were part of the post itself. Markdown, HTML, and shortcodes are allowed."}}</p>
<textarea id="signature" class="section norm" name="signature">{{.Signature}}</textarea>
</div>
</div>
{{if .UserPage.StaticPage.AppCfg.Monetization}}
<div class="option">
- <h2>Web Monetization</h2>
+ <h2>{{call .Tr "Web Monetization"}}</h2>
<div class="section">
- <p class="explain">Web Monetization enables you to receive micropayments from readers that have a <a href="https://coil.com">Coil membership</a>. Add your payment pointer to enable Web Monetization on your blog.</p>
+ <p class="explain">{{call .Tr "Web Monetization enables you to receive micropayments from readers that have a %s. Add your payment pointer to enable Web Monetization on your blog." true (variables "Coil membership;https://coil.com")}}</p>
<input type="text" name="monetization_pointer" style="width:100%" value="{{.Collection.Monetization}}" placeholder="$wallet.example.com/alice" />
</div>
</div>
{{end}}
<div class="option" style="text-align: center; margin-top: 4em;">
- <input type="submit" id="save-changes" value="Save changes" />
- <p><a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">View Blog</a></p>
- {{if ne .Alias .Username}}<p><a class="danger" href="#modal-delete" onclick="promptDelete();">Delete Blog...</a></p>{{end}}
+ <input type="submit" id="save-changes" value="{{call .Tr "Save changes"}}" />
+ <p><a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">{{call .Tr "View Blog"}}</a></p>
+ {{if ne .Alias .Username}}<p><a class="danger" href="#modal-delete" onclick="promptDelete();">{{call .Tr "Delete Blog..."}}</a></p>{{end}}
</div>
</div>
</form>
</div>
<div id="modal-delete" class="modal">
- <h2>Are you sure you want to delete this blog?</h2>
+ <h2>{{call .Tr "Are you sure you want to delete this blog?"}}</h2>
<div class="body short">
- <p style="text-align:left">This will permanently erase <strong>{{.DisplayTitle}}</strong> ({{.FriendlyHost}}/{{.Alias}}) from the internet. Any posts on this blog will be saved and made into drafts (found on your <a href="/me/posts/">Drafts</a> page).</p>
- <p>If you're sure you want to delete this blog, enter its name in the box below and press <strong>Delete</strong>.</p>
+ <p style="text-align:left">{{call .Tr "This will permanently erase <strong>{{.DisplayTitle}}</strong> ({{.FriendlyHost}}/{{.Alias}}) from the internet. Any posts on this blog will be saved and made into drafts (found on your %s page)." true (variables true "Draft;/me/posts/")}}</p>
+ <p>{{call .Tr "If you're sure you want to delete this blog, enter its name in the box below and press **%s**." true (variables "Delete")}}</p>
<ul id="delete-errors" class="errors"></ul>
<input id="confirm-text" placeholder="{{.Alias}}" type="text" class="boxy" style="margin-top: 0.5em;" />
<div style="text-align:right; margin-top: 1em;">
- <a id="cancel-delete" style="margin-right:2em" href="#">Cancel</a>
- <button id="btn-delete" class="danger" onclick="deleteBlog(); return false;">Delete</button>
+ <a id="cancel-delete" style="margin-right:2em" href="#">{{call .Tr "Cancel"}}</a>
+ <button id="btn-delete" class="danger" onclick="deleteBlog(); return false;">{{call .Tr "Delete"}}</button>
</div>
</div>
</div>
<script src="/js/h.js"></script>
<script src="/js/modals.js"></script>
<script src="/js/ace.js" type="text/javascript" charset="utf-8"></script>
<script>
H.getEl('cancel-delete').on('click', closeModals);
var deleteBlog = function(e) {
if (document.getElementById('confirm-text').value != '{{.Alias}}') {
- document.getElementById('delete-errors').innerHTML = '<li class="urgent">Enter <strong>{{.Alias}}</strong> in the box below.</li>';
+ document.getElementById('delete-errors').innerHTML = '<li class="urgent">{{call .Tr "Enter **%s** in the box below." true (variables .Alias)}}</li>';
return;
}
// Clear errors
document.getElementById('delete-errors').innerHTML = '';
- document.getElementById('btn-delete').innerHTML = 'Deleting...';
+ document.getElementById('btn-delete').innerHTML = {{call .Tr "Deleting..."}};
var http = new XMLHttpRequest();
var url = "/api/collections/{{.Alias}}?web=1";
http.open("DELETE", url, true);
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
if (http.status == 204) {
window.location = '/me/c/';
} else {
var data = JSON.parse(http.responseText);
document.getElementById('delete-errors').innerHTML = '<li class="urgent">'+data.error_msg+'</li>';
- document.getElementById('btn-delete').innerHTML = 'Delete';
+ document.getElementById('btn-delete').innerHTML = {{call .Tr "Delete"}};
}
}
};
http.send(null);
};
function createHidden(theForm, key, value) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = value;
theForm.appendChild(input);
}
function disableSubmit() {
var $form = document.forms['customize-form'];
createHidden($form, 'style_sheet', cssEditor.getSession().getValue());
var $btn = document.getElementById("save-changes");
- $btn.value = "Saving changes...";
+ $btn.value = {{call .Tr "Saving changes..."}};
$btn.disabled = true;
return true;
}
function promptDelete() {
showModal("delete");
}
var $fediDomain = document.getElementById('fedi-domain');
var $fediCustomDomain = document.getElementById('fedi-custom-domain');
var $customDomain = document.getElementById('domain-alias');
var $customHandleEnv = document.getElementById('custom-handle-env');
var $normalHandleEnv = document.getElementById('normal-handle-env');
if (matchMedia('(pointer:fine)').matches) {
// Only initialize Ace editor on devices with a mouse
var opt = {
showLineNumbers: false,
showPrintMargin: 0,
minLines: 10,
maxLines: 40,
};
var theme = "ace/theme/chrome";
var cssEditor = ace.edit("css-editor");
cssEditor.setTheme(theme);
cssEditor.session.setMode("ace/mode/css");
cssEditor.setOptions(opt);
cssEditor.resize(true);
}
</script>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/collections.tmpl b/templates/user/collections.tmpl
index f371ce0..ae4046c 100644
--- a/templates/user/collections.tmpl
+++ b/templates/user/collections.tmpl
@@ -1,116 +1,116 @@
{{define "collections"}}
{{template "header" .}}
<div class="snug content-container">
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
{{if .Silenced}}
- {{template "user-silenced"}}
+ {{template "user-silenced" (dict "Tr" $.Tr)}}
{{end}}
-<h1>Blogs</h1>
+<h1>{{call .Tr "Blog" 2}}</h1>
<ul class="atoms collections">
{{range $i, $el := .Collections}}<li class="collection">
<div class="row lineitem">
<div>
<h3>
<a class="title" href="/{{.Alias}}/" >{{if .Title}}{{.Title}}{{else}}{{.Alias}}{{end}}</a>
<span class="electron" {{if .IsPrivate}}style="font-style: italic"{{end}}>{{if .IsPrivate}}private{{else}}{{.DisplayCanonicalURL}}{{end}}</span>
</h3>
- {{template "collection-nav" (dict "Alias" .Alias "Path" $.Path "SingleUser" $.SingleUser "CanPost" true )}}
+ {{template "collection-nav" (dict "Alias" .Alias "Path" $.Path "SingleUser" $.SingleUser "CanPost" true "Tr" $.Tr)}}
{{if .Description}}<p class="description">{{.Description}}</p>{{end}}
</div>
</div>
</li>{{end}}
<li id="create-collection">
{{if not .NewBlogsDisabled}}
<form method="POST" action="/api/collections" id="new-collection-form" onsubmit="return createCollection()">
<h4>
<input type="text" name="title" placeholder="Blog name" id="blog-name">
<input type="hidden" name="web" value="true" />
<input type="submit" value="Create" id="create-collection-btn">
</h4>
</form>
{{end}}
</li>
</ul>
{{if not .NewBlogsDisabled}}<p style="margin-top:0"><a id="new-collection" href="#new-collection">New blog</a></p>{{end}}
</div>
{{template "foot" .}}
<script src="/js/h.js"></script>
<script>
function createCollection() {
var input = He.get('blog-name');
if (input.value == "") {
return false;
}
var form = He.get('new-collection-form');
var submit = He.get('create-collection-btn');
submit.value = "Creating...";
submit.disabled = "disabled";
He.postJSON("/api/collections", {
title: input.value,
web: true
}, function(code, data) {
if (data.code == 201) {
location.reload();
} else {
var $createColl = document.getElementById('create-collection');
var $submit = $createColl.querySelector('input[type=submit]');
$submit.value = "Create";
$submit.disabled = "";
var $err = $createColl.querySelector('.error');
if (data.code == 409) {
if ($err === null) {
var url = He.create('span');
url.className = "error";
- url.innerText = "This name is taken.";
+ url.innerText = {{call .Tr "This name is taken."}};
$createColl.appendChild(url);
} else {
- $err.innerText = "This name is taken.";
+ $err.innerText = {{call .Tr "This name is taken."}};
}
} else {
if ($err === null) {
var url = He.create('span');
url.className = "error";
url.innerText = data.error_msg;
$createColl.appendChild(url);
} else {
- $err.innerText = "This name is taken.";
+ $err.innerText = {{call .Tr "This name is taken."}};
}
}
}
});
return false;
};
(function() {
H.getEl('new-collection').on('click', function(e) {
e.preventDefault();
var collForm = He.get('create-collection');
if (collForm.style.display == '' || collForm.style.display == 'none') {
// Show form
this.textContent = "Cancel";
collForm.style.display = 'list-item';
collForm.querySelector('input[type=text]').focus();
return;
}
// Hide form
this.textContent = "New blog";
collForm.style.display = 'none';
});
if (location.hash == '#new-collection' || location.hash == '#new') {
var collForm = He.get('create-collection');
collForm.style.display = 'list-item';
collForm.querySelector('input[type=text]').focus();
He.get('new-collection').textContent = "Cancel";
}
}());
</script>
{{template "body-end" .}}
{{end}}
diff --git a/templates/user/export.tmpl b/templates/user/export.tmpl
index fee97d6..e9bf077 100644
--- a/templates/user/export.tmpl
+++ b/templates/user/export.tmpl
@@ -1,28 +1,28 @@
{{define "export"}}
{{template "header" .}}
<div class="snug content-container">
- <h1 id="posts-header">Export</h1>
- <p>Your data on {{.SiteName}} is always free. Download and back-up your work any time.</p>
+ <h1 id="posts-header">{{call .Tr "Export"}}</h1>
+ <p>{{call .Tr "Your data on %s is always free. Download and back-up your work any time." (variables .SiteName)}}</p>
<table class="classy export">
<tr>
- <th style="width: 40%">Export</th>
- <th colspan="2">Format</th>
+ <th style="width: 40%">{{call .Tr "Export"}}</th>
+ <th colspan="2">{{call .Tr "Format"}}</th>
</tr>
<tr>
- <th>Posts</th>
+ <th>{{call .Tr "Post" 2}}</th>
<td><p class="text-cta"><a href="/me/posts/export.csv">CSV</a></p></td>
<td><p class="text-cta"><a href="/me/posts/export.zip">TXT</a></p></td>
</tr>
<tr>
- <th>User + Blogs + Posts</th>
+ <th>{{call .Tr "User"}} + {{call .Tr "Blog" 2}} + {{call .Tr "Post" 2}}</th>
<td><p class="text-cta"><a href="/me/export.json">JSON</a></p></td>
<td><p class="text-cta"><a href="/me/export.json?pretty=1">Prettified</a></p></td>
</tr>
</table>
</div>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/import.tmpl b/templates/user/import.tmpl
index 547d3dd..b60e11a 100644
--- a/templates/user/import.tmpl
+++ b/templates/user/import.tmpl
@@ -1,64 +1,64 @@
{{define "import"}}
{{template "header" .}}
<style>
input[type=file] {
padding: 0;
font-size: 0.86em;
display: block;
margin: 0.5rem 0;
}
label {
display: block;
margin: 1em 0;
}
</style>
<div class="snug content-container">
- <h1 id="import-header">Import posts</h1>
+ <h1 id="import-header">{{call .Tr "Import posts"}}</h1>
{{if .Message}}
<div class="alert {{if .InfoMsg}}info{{else}}success{{end}}">
<p>{{.Message}}</p>
</div>
{{end}}
{{if .Flashes}}
<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>
{{end}}
- <p>Publish plain text or Markdown files to your account by uploading them below.</p>
+ <p>{{call .Tr "Publish plain text or Markdown files to your account by uploading them below."}}</p>
<div class="formContainer">
<form id="importPosts" class="prominent" enctype="multipart/form-data" action="/api/me/import" method="POST">
- <label>Select some files to import:
+ <label>{{call .Tr "Select some files to import:"}}
<input id="fileInput" class="fileInput" name="files" type="file" multiple accept="text/*"/>
</label>
<input id="fileDates" name="fileDates" hidden/>
<label>
- Import these posts to:
+ {{call .Tr "Import these posts to:"}}
<select name="collection">
{{range $i, $el := .Collections}}
<option value="{{.Alias}}" {{if eq $i 0}}selected{{end}}>{{.DisplayTitle}}</option>
{{end}}
- <option value="">Drafts</option>
+ <option value="">{{call .Tr "Draft" 2}}</option>
</select>
</label>
<script>
// timezone offset in seconds
const tzOffsetSec = new Date().getTimezoneOffset() * 60;
const fileInput = document.getElementById('fileInput');
const fileDates = document.getElementById('fileDates');
fileInput.addEventListener('change', (e) => {
const files = e.target.files;
let dateMap = {};
for (let file of files) {
// convert from milliseconds to seconds and adjust for tz
dateMap[file.name] = Math.round(file.lastModified / 1000) + tzOffsetSec;
}
fileDates.value = JSON.stringify(dateMap);
})
</script>
- <input type="submit" value="Import" />
+ <input type="submit" value={{call .Tr "Import"}} />
</form>
</div>
</div>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/include/footer.tmpl b/templates/user/include/footer.tmpl
index a2fe989..49fb048 100644
--- a/templates/user/include/footer.tmpl
+++ b/templates/user/include/footer.tmpl
@@ -1,41 +1,41 @@
{{define "footer"}}
{{template "foot" .}}
{{template "body-end" .}}
{{end}}
{{define "foot"}}
</div>
<footer>
<hr />
<nav>
<a class="home" href="/">{{.SiteName}}</a>
- {{if not .SingleUser}}<a href="/about">about</a>{{end}}
- {{if and (not .SingleUser) .LocalTimeline}}<a href="/read">reader</a>{{end}}
- <a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a>
- {{if not .SingleUser}}<a href="/privacy">privacy</a>{{end}}
+ {{if not .SingleUser}}<a href="/about">{{call .Tr "About"}}</a>{{end}}
+ {{if and (not .SingleUser) .LocalTimeline}}<a href="/read">{{call .Tr "Reader"}}</a>{{end}}
+ <a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">{{call .Tr "writer's guide"}}</a>
+ {{if not .SingleUser}}<a href="/privacy">{{call .Tr "privacy"}}</a>{{end}}
{{if .WFModesty}}
<p style="font-size: 0.9em">powered by <a href="https://writefreely.org">writefreely</a></p>
{{else}}
<a href="https://writefreely.org">writefreely {{.Version}}</a>
{{end}}
</nav>
</footer>
<script type="text/javascript" src="/js/menu.js"></script>
<script type="text/javascript">
try { // Google Fonts
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
(function() {
var wf = document.createElement('script');
wf.src = '/js/webfont.js';
wf.type = 'text/javascript';
wf.async = 'true';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) { /* ¯\_(ツ)_/¯ */ }
</script>
{{end}}
{{define "body-end"}}</body>
</html>{{end}}
diff --git a/templates/user/include/header.tmpl b/templates/user/include/header.tmpl
index 66a2a84..63a0faf 100644
--- a/templates/user/include/header.tmpl
+++ b/templates/user/include/header.tmpl
@@ -1,116 +1,116 @@
{{define "user-navigation"}}
<header class="{{if .SingleUser}}singleuser{{else}}multiuser{{end}}">
<nav id="full-nav">
{{if .SingleUser}}
<nav id="user-nav">
<nav class="dropdown-nav">
- <ul><li><a href="/" title="View blog" class="title">{{.SiteName}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" />
+ <ul><li><a href="/" title={{call .Tr "View blog"}} class="title">{{.SiteName}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" />
<ul>
- {{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}}
- <li><a href="/me/settings">Account settings</a></li>
- <li><a href="/me/import">Import posts</a></li>
- <li><a href="/me/export">Export</a></li>
+ {{if .IsAdmin}}<li><a href="/admin">{{call .Tr "Admin dashboard"}}</a></li>{{end}}
+ <li><a href="/me/settings">{{call .Tr "Account settings"}}</a></li>
+ <li><a href="/me/import">{{call .Tr "Import posts"}}</a></li>
+ <li><a href="/me/export">{{call .Tr "Export"}}</a></li>
<li class="separator"><hr /></li>
- <li><a href="/me/logout">Log out</a></li>
+ <li><a href="/me/logout">{{call .Tr "Log out"}}</a></li>
</ul></li>
</ul>
</nav>
<nav class="tabs">
- <a href="/me/c/{{.Username}}" {{if and (hasPrefix .Path "/me/c/") (hasSuffix .Path .Username)}}class="selected"{{end}}>Customize</a>
- <a href="/me/c/{{.Username}}/stats" {{if hasSuffix .Path "/stats"}}class="selected"{{end}}>Stats</a>
- <a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>
+ <a href="/me/c/{{.Username}}" {{if and (hasPrefix .Path "/me/c/") (hasSuffix .Path .Username)}}class="selected"{{end}}>{{call .Tr "Customize"}}</a>
+ <a href="/me/c/{{.Username}}/stats" {{if hasSuffix .Path "/stats"}}class="selected"{{end}}>{{call .Tr "Stats"}}</a>
+ <a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>{{call .Tr "Draft" 2}}</a>
</nav>
</nav>
<div class="right-side">
- <a class="simple-btn" href="/me/new">New Post</a>
+ <a class="simple-btn" href="/me/new">{{call .Tr "New Post"}}</a>
</div>
{{else}}
<div class="left-side">
- <h1><a href="/" title="Return to editor">{{.SiteName}}</a></h1>
+ <h1><a href="/" title={{call .Tr "Return to editor"}}>{{.SiteName}}</a></h1>
</div>
<nav id="user-nav">
{{if .Username}}
<nav class="dropdown-nav">
<ul><li class="has-submenu"><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
- {{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}}
- <li><a href="/me/settings">Account settings</a></li>
- <li><a href="/me/import">Import posts</a></li>
- <li><a href="/me/export">Export</a></li>
- {{if .CanInvite}}<li><a href="/me/invites">Invite people</a></li>{{end}}
+ {{if .IsAdmin}}<li><a href="/admin">{{call .Tr "Admin dashboard"}}</a></li>{{end}}
+ <li><a href="/me/settings">{{call .Tr "Account settings"}}</a></li>
+ <li><a href="/me/import">{{call .Tr "Import posts"}}</a></li>
+ <li><a href="/me/export">{{call .Tr "Export"}}</a></li>
+ {{if .CanInvite}}<li><a href="/me/invites">{{call .Tr "Invite people"}}</a></li>{{end}}
<li class="separator"><hr /></li>
- <li><a href="/me/logout">Log out</a></li>
+ <li><a href="/me/logout">{{call .Tr "Log out"}}</a></li>
</ul></li>
</ul>
</nav>
{{end}}
<nav class="tabs">
{{if .SimpleNav}}
{{ if not .SingleUser }}
- {{if and (and .LocalTimeline .CanViewReader) .Chorus}}<a href="/"{{if eq .Path "/"}} class="selected"{{end}}>Home</a>{{end}}
+ {{if and (and .LocalTimeline .CanViewReader) .Chorus}}<a href="/"{{if eq .Path "/"}} class="selected"{{end}}>{{call .Tr "Home"}}</a>{{end}}
{{ end }}
- <a href="/about">About</a>
+ <a href="/about">{{call .Tr "About"}}</a>
{{ if not .SingleUser }}
{{ if .Username }}
- {{if gt .MaxBlogs 1}}<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a>{{end}}
- {{if and .Chorus (eq .MaxBlogs 1)}}<a href="/{{.Username}}/"{{if eq .Path (printf "/%s/" .Username)}} class="selected"{{end}}>My Posts</a>{{end}}
- {{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}}
+ {{if gt .MaxBlogs 1}}<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>{{call .Tr "Blog" 2}}</a>{{end}}
+ {{if and .Chorus (eq .MaxBlogs 1)}}<a href="/{{.Username}}/"{{if eq .Path (printf "/%s/" .Username)}} class="selected"{{end}}>{{call .Tr "My Posts"}}</a>{{end}}
+ {{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>{{call .Tr "Draft" 2}}</a>{{end}}
{{ end }}
- {{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read">Reader</a>{{end}}
- {{if and (and (and .Chorus .OpenRegistration) (not .Username)) (or (not .Private) (ne .Landing ""))}}<a href="/signup"{{if eq .Path "/signup"}} class="selected"{{end}}>Sign up</a>{{end}}
- {{if .Username}}<a href="/me/logout">Log out</a>{{else}}<a href="/login">Log in</a>{{end}}
+ {{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read">{{call .Tr "Reader"}}</a>{{end}}
+ {{if and (and (and .Chorus .OpenRegistration) (not .Username)) (or (not .Private) (ne .Landing ""))}}<a href="/signup"{{if eq .Path "/signup"}} class="selected"{{end}}>{{call .Tr "Sign up"}}</a>{{end}}
+ {{if .Username}}<a href="/me/logout">{{call .Tr "Log out"}}</a>{{else}}<a href="/login">{{call .Tr "Log in"}}</a>{{end}}
{{ end }}
{{else}}
- <a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a>
- {{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}}
- {{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read">Reader</a>{{end}}
+ <a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>{{call .Tr "Blog" 2}}</a>
+ {{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>{{call .Tr "Draft" 2}}</a>{{end}}
+ {{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read">{{call .Tr "Reader"}}</a>{{end}}
{{end}}
</nav>
</nav>
{{if .Username}}
<div class="right-side">
- <a class="simple-btn" href="/{{if .CollAlias}}#{{.CollAlias}}{{end}}">New Post</a>
+ <a class="simple-btn" href="/{{if .CollAlias}}#{{.CollAlias}}{{end}}">{{call .Tr "New Post"}}</a>
</div>
{{end}}
{{end}}
</nav>
</header>
{{end}}
{{define "header"}}<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>{{.PageTitle}} {{if .Separator}}{{.Separator}}{{else}}&mdash;{{end}} {{.SiteName}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#888888" />
<meta name="apple-mobile-web-app-title" content="{{.SiteName}}">
<link rel="apple-touch-icon" sizes="152x152" href="/img/touch-icon-152.png">
<link rel="apple-touch-icon" sizes="167x167" href="/img/touch-icon-167.png">
<link rel="apple-touch-icon" sizes="180x180" href="/img/touch-icon-180.png">
</head>
<body id="me">
{{template "user-navigation" .}}
<div id="official-writing">
{{end}}
{{define "admin-header"}}
<header class="admin">
- <h1>Admin</h1>
+ <h1>{{call .Tr "Admin"}}</h1>
<nav id="admin" class="pager">
- <a href="/admin" {{if eq .Path "/admin"}}class="selected"{{end}}>Dashboard</a>
- <a href="/admin/settings" {{if eq .Path "/admin/settings"}}class="selected"{{end}}>Settings</a>
+ <a href="/admin" {{if eq .Path "/admin"}}class="selected"{{end}}>{{call .Tr "Dashboard"}}</a>
+ <a href="/admin/settings" {{if eq .Path "/admin/settings"}}class="selected"{{end}}>{{call .Tr "Settings"}}</a>
{{if not .SingleUser}}
- <a href="/admin/users" {{if eq .Path "/admin/users"}}class="selected"{{end}}>Users</a>
- <a href="/admin/pages" {{if eq .Path "/admin/pages"}}class="selected"{{end}}>Pages</a>
- {{if .UpdateChecks}}<a href="/admin/updates" {{if eq .Path "/admin/updates"}}class="selected"{{end}}>Updates{{if .UpdateAvailable}}<span class="blip">!</span>{{end}}</a>{{end}}
+ <a href="/admin/users" {{if eq .Path "/admin/users"}}class="selected"{{end}}>{{call .Tr "User" 2}}</a>
+ <a href="/admin/pages" {{if eq .Path "/admin/pages"}}class="selected"{{end}}>{{call .Tr "Page" 2}}</a>
+ {{if .UpdateChecks}}<a href="/admin/updates" {{if eq .Path "/admin/updates"}}class="selected"{{end}}>{{call .Tr "Updates"}}{{if .UpdateAvailable}}<span class="blip">!</span>{{end}}</a>{{end}}
{{end}}
{{if not .Forest}}
- <a href="/admin/monitor" {{if eq .Path "/admin/monitor"}}class="selected"{{end}}>Monitor</a>
+ <a href="/admin/monitor" {{if eq .Path "/admin/monitor"}}class="selected"{{end}}>{{call .Tr "Monitor"}}</a>
{{end}}
</nav>
</header>
{{end}}
diff --git a/templates/user/include/nav.tmpl b/templates/user/include/nav.tmpl
index 057fc3c..d615306 100644
--- a/templates/user/include/nav.tmpl
+++ b/templates/user/include/nav.tmpl
@@ -1,16 +1,16 @@
{{define "collection-breadcrumbs"}}
- {{if and .Collection (not .SingleUser)}}<nav id="org-nav"><a href="/me/c/">Blogs</a> / <a class="coll-name" href="/{{.Collection.Alias}}/">{{.Collection.DisplayTitle}}</a></nav>{{end}}
+ {{if and .Collection (not .SingleUser)}}<nav id="org-nav"><a href="/me/c/">{{call .Tr "Blog" 2}}</a> / <a class="coll-name" href="/{{.Collection.Alias}}/">{{.Collection.DisplayTitle}}</a></nav>{{end}}
{{end}}
{{define "collection-nav"}}
{{if not .SingleUser}}
<header class="admin">
<nav class="pager">
- {{if .CanPost}}<a href="{{if .SingleUser}}/me/new{{else}}/#{{.Alias}}{{end}}" class="btn gentlecta">New Post</a>{{end}}
- <a href="/me/c/{{.Alias}}" {{if and (hasPrefix .Path "/me/c/") (hasSuffix .Path .Alias)}}class="selected"{{end}}>Customize</a>
- <a href="/me/c/{{.Alias}}/stats" {{if hasSuffix .Path "/stats"}}class="selected"{{end}}>Stats</a>
- <a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">View Blog &rarr;</a>
+ {{if .CanPost}}<a href="{{if .SingleUser}}/me/new{{else}}/#{{.Alias}}{{end}}" class="btn gentlecta">{{call .Tr "New Post"}}</a>{{end}}
+ <a href="/me/c/{{.Alias}}" {{if and (hasPrefix .Path "/me/c/") (hasSuffix .Path .Alias)}}class="selected"{{end}}>{{call .Tr "Customize"}}</a>
+ <a href="/me/c/{{.Alias}}/stats" {{if hasSuffix .Path "/stats"}}class="selected"{{end}}>{{call .Tr "Stats"}}</a>
+ <a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">{{call .Tr "View Blog"}} &rarr;</a>
</nav>
</header>
{{end}}
{{end}}
diff --git a/templates/user/include/silenced.tmpl b/templates/user/include/silenced.tmpl
index 3b2f3dc..db27588 100644
--- a/templates/user/include/silenced.tmpl
+++ b/templates/user/include/silenced.tmpl
@@ -1,5 +1,5 @@
{{define "user-silenced"}}
<div class="alert info">
- <p><strong>Your account has been silenced.</strong> You can still access all of your posts and blogs, but no one else can currently see them.</p>
+ <p><strong>{{call .Tr "Your account has been silenced."}}</strong> {{call .Tr "You can still access all of your posts and blogs, but no one else can currently see them."}}</p>
</div>
{{end}}
diff --git a/templates/user/invite-help.tmpl b/templates/user/invite-help.tmpl
index 978cfad..7f6159f 100644
--- a/templates/user/invite-help.tmpl
+++ b/templates/user/invite-help.tmpl
@@ -1,32 +1,32 @@
{{define "invite-help"}}
{{template "header" .}}
<style>
.copy-link {
width: 100%;
margin: 1rem 0;
text-align: center;
font-size: 1.2em;
color: #555;
}
</style>
<div class="snug content-container">
- <h1>Invite to {{.SiteName}}</h1>
+ <h1>{{call .Tr "Invite to %s" (variables .SiteName)}}</h1>
{{ if .Expired }}
- <p style="font-style: italic">This invite link is expired.</p>
+ <p style="font-style: italic">{{call .Tr "This invite link is expired."}}</p>
{{ else }}
- <p>Copy the link below and send it to anyone that you want to join <em>{{ .SiteName }}</em>. You could paste it into an email, instant message, text message, or write it down on paper. Anyone who navigates to this special page will be able to create an account.</p>
+ <p>{{call .Tr "Copy the link below and send it to anyone that you want to join *%s*. You could paste it into an email, instant message, text message, or write it down on paper. Anyone who navigates to this special page will be able to create an account." true (variables .SiteName)}}</p>
<input class="copy-link" type="text" name="invite-url" value="{{$.Host}}/invite/{{.Invite.ID}}" onfocus="if (this.select) this.select(); else this.setSelectionRange(0, this.value.length);" readonly />
<p>
{{ if gt .Invite.MaxUses.Int64 0 }}
- {{if eq .Invite.MaxUses.Int64 1}}Only <strong>one</strong> user{{else}}Up to <strong>{{.Invite.MaxUses.Int64}}</strong> users{{end}} can sign up with this link.
- {{if gt .Invite.Uses 0}}So far, <strong>{{.Invite.Uses}}</strong> {{pluralize "person has" "people have" .Invite.Uses}} used it.{{end}}
- {{if .Invite.Expires}}It expires on <strong>{{.Invite.ExpiresFriendly}}</strong>.{{end}}
+ {{if eq .Invite.MaxUses.Int64 1}}{{call .Tr "Only **one** user" true}}{{else}}{{call .Tr "Up to **%s** users" (variables .Invite.MaxUses.Int64)}}{{end}} {{call .Tr "can sign up with this link."}}
+ {{if gt .Invite.Uses 0}}{{$PLURAL:=false}}{{if gt .Invite.Uses 1}}{{$PLURAL = true}}{{end}}{{call .Tr "So far, **%d** %s used it." true (variables $PLURAL .Invite.Uses "person has")}}{{end}}
+ {{if .Invite.Expires}}{{call .Tr "It expires on **%s**." true (variables .Invite.ExpiresFriendly)}}{{end}}
{{ else }}
- It can be used as many times as you like{{if .Invite.Expires}} before <strong>{{.Invite.ExpiresFriendly}}</strong>, when it expires{{end}}.
+ {{call .Tr "It can be used as many times as you like"}}{{if .Invite.Expires}} {{call .Tr "before **%s**, when it expires" true (variables .Invite.ExpiresFriendly)}}{{end}}.
{{ end }}
</p>
{{ end }}
</div>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/invite.tmpl b/templates/user/invite.tmpl
index ea22f22..0e49ab8 100644
--- a/templates/user/invite.tmpl
+++ b/templates/user/invite.tmpl
@@ -1,84 +1,83 @@
{{define "invite"}}
{{template "header" .}}
<style>
.half {
margin-right: 0.5em;
}
.half + .half {
margin-left: 0.5em;
margin-right: 0;
}
table.classy {
width: 100%;
}
table.classy.export a {
text-transform: initial;
}
table td {
font-size: 0.86em;
}
</style>
<div class="snug content-container">
{{if .Silenced}}
- {{template "user-silenced"}}
+ {{template "user-silenced" (dict "Tr" $.Tr)}}
{{end}}
- <h1>Invite people</h1>
- <p>Invite others to join <em>{{.SiteName}}</em> by generating and sharing invite links below.</p>
-
+ <h1>{{call .Tr "Invite people"}}</h1>
+ <p>{{call .Tr "Invite others to join *%s* by generating and sharing invite links below." true (variables .SiteName)}}</p>
<form style="margin: 2em 0" class="prominent" action="/api/me/invites" method="post">
<div class="row">
<div class="half">
- <label for="uses">Maximum number of uses:</label>
+ <label for="uses">{{call .Tr "Maximum number of uses:"}}</label>
<select id="uses" name="uses" {{if .Silenced}}disabled{{end}}>
- <option value="0">No limit</option>
- <option value="1">1 use</option>
- <option value="5">5 uses</option>
- <option value="10">10 uses</option>
- <option value="25">25 uses</option>
- <option value="50">50 uses</option>
- <option value="100">100 uses</option>
+ <option value="0">{{call .Tr "No limit"}}</option>
+ <option value="1">1 {{call .Tr "use"}}</option>
+ <option value="5">5 {{call .Tr "use" 2}}</option>
+ <option value="10">10 {{call .Tr "use" 2}}</option>
+ <option value="25">25 {{call .Tr "use" 2}}</option>
+ <option value="50">50 {{call .Tr "use" 2}}</option>
+ <option value="100">100 {{call .Tr "use" 2}}</option>
</select>
</div>
<div class="half">
- <label for="expires">Expire after:</label>
+ <label for="expires">{{call .Tr "Expire after:"}}</label>
<select id="expires" name="expires" {{if .Silenced}}disabled{{end}}>
- <option value="0">Never</option>
- <option value="30">30 minutes</option>
- <option value="60">1 hour</option>
- <option value="360">6 hours</option>
- <option value="720">12 hours</option>
- <option value="1440">1 day</option>
- <option value="4320">3 days</option>
- <option value="10080">1 week</option>
+ <option value="0">{{call .Tr "Never"}}</option>
+ <option value="30">30 {{call .Tr "minute" 2}}</option>
+ <option value="60">1 {{call .Tr "hour"}}</option>
+ <option value="360">6 {{call .Tr "hour" 2}}</option>
+ <option value="720">12 {{call .Tr "hour" 2}}</option>
+ <option value="1440">1 {{call .Tr "day"}}</option>
+ <option value="4320">3 {{call .Tr "day" 2}}</option>
+ <option value="10080">1 {{call .Tr "week"}}</option>
</select>
</div>
</div>
<div class="row">
- <input type="submit" value="Generate" {{if .Silenced}}disabled title="You cannot generate invites while your account is silenced."{{end}} />
+ <input type="submit" value="{{call .Tr "Generate"}}" {{if .Silenced}}disabled title="{{call .Tr "You cannot generate invites while your account is silenced."}}"{{end}} />
</div>
</form>
<table class="classy export">
<tr>
- <th>Link</th>
- <th>Uses</th>
- <th>Expires</th>
+ <th>{{call .Tr "Link"}}</th>
+ <th>{{call .Tr "use" 2}}</th>
+ <th>{{call .Tr "Expires"}}</th>
</tr>
{{range .Invites}}
<tr>
<td><a href="{{$.Host}}/invite/{{.ID}}">{{$.Host}}/invite/{{.ID}}</a></td>
<td>{{.Uses}}{{if gt .MaxUses.Int64 0}} / {{.MaxUses.Int64}}{{end}}</td>
- <td>{{ if .Expires }}{{if .Expired}}Expired{{else}}{{.ExpiresFriendly}}{{end}}{{ else }}&infin;{{ end }}</td>
+ <td>{{ if .Expires }}{{if .Expired}}{{call .Tr "Expired"}}{{else}}{{.ExpiresFriendly}}{{end}}{{ else }}&infin;{{ end }}</td>
</tr>
{{else}}
<tr>
- <td colspan="3">No invites generated yet.</td>
+ <td colspan="3">{{call .Tr "No invites generated yet."}}</td>
</tr>
{{end}}
</table>
</div>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/settings.tmpl b/templates/user/settings.tmpl
index 338ea9a..9a9a74b 100644
--- a/templates/user/settings.tmpl
+++ b/templates/user/settings.tmpl
@@ -1,231 +1,231 @@
{{define "settings"}}
{{template "header" .}}
<style type="text/css">
.option { margin: 2em 0em; }
h2 {
margin-top: 2.5em;
}
h3 { font-weight: normal; }
.section p, .section label {
font-size: 0.86em;
}
.oauth-provider img {
max-height: 2.75em;
vertical-align: middle;
}
.modal {
position: fixed;
}
</style>
<div class="content-container snug">
<div id="overlay"></div>
{{if .Silenced}}
- {{template "user-silenced"}}
+ {{template "user-silenced" (dict "Tr" $.Tr)}}
{{end}}
- <h1>{{if .IsLogOut}}Before you go...{{else}}Account Settings{{end}}</h1>
+ <h1>{{if .IsLogOut}}{{call .Tr "Before you go..."}}{{else}}{{call .Tr "Account Settings"}}{{end}}</h1>
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
{{ if .IsLogOut }}
<div class="alert info">
- <p class="introduction">Please add an <strong>email address</strong> and/or <strong>passphrase</strong> so you can log in again later.</p>
+ <p class="introduction">{{call .Tr "Please add an **%s** and/or **%s** so you can log in again later." true (variables "email address" "passphrase")}}</p>
</div>
{{ else }}
<div>
- <p>Change your account settings here.</p>
+ <p>{{call .Tr "Change your account settings here."}}</p>
</div>
<form method="post" action="/api/me/self" autocomplete="false">
<div class="option">
- <h3>Username</h3>
+ <h3>{{call .Tr "Username"}}</h3>
<div class="section">
<input type="text" name="username" value="{{.Username}}" tabindex="1" />
- <input type="submit" value="Update" style="margin-left: 1em;" />
+ <input type="submit" value="{{call .Tr "Update"}}" style="margin-left: 1em;" />
</div>
</div>
</form>
{{ end }}
{{if not .DisablePasswordAuth}}
<form method="post" action="/api/me/self" autocomplete="false">
<input type="hidden" name="logout" value="{{.IsLogOut}}" />
<div class="option">
- <h3>Passphrase</h3>
+ <h3>{{call .Tr "Passphrase"}}</h3>
<div class="section">
- {{if and (not .HasPass) (not .IsLogOut)}}<div class="alert info"><p>Add a passphrase to easily log in to your account.</p></div>{{end}}
- {{if .HasPass}}<p>Current passphrase</p>
- <input type="password" name="current-pass" placeholder="Current passphrase" tabindex="1" /> <input class="show" type="checkbox" id="show-cur-pass" /><label for="show-cur-pass"> Show</label>
- <p>New passphrase</p>
+ {{if and (not .HasPass) (not .IsLogOut)}}<div class="alert info"><p>{{call .Tr "Add a passphrase to easily log in to your account."}}</p></div>{{end}}
+ {{if .HasPass}}<p>{{call .Tr "Current passphrase"}}</p>
+ <input type="password" name="current-pass" placeholder="{{call .Tr "Current passphrase"}}" tabindex="1" /> <input class="show" type="checkbox" id="show-cur-pass" /><label for="show-cur-pass"> {{call .Tr "Show"}}</label>
+ <p>{{call .Tr "New passphrase"}}</p>
{{end}}
{{if .IsLogOut}}<input type="text" value="{{.Username}}" style="display:none" />{{end}}
- <input type="password" name="new-pass" autocomplete="new-password" placeholder="New passphrase" tabindex="{{if .IsLogOut}}1{{else}}2{{end}}" /> <input class="show" type="checkbox" id="show-new-pass" /><label for="show-new-pass"> Show</label>
+ <input type="password" name="new-pass" autocomplete="new-password" placeholder="{{call .Tr "New passphrase"}}" tabindex="{{if .IsLogOut}}1{{else}}2{{end}}" /> <input class="show" type="checkbox" id="show-new-pass" /><label for="show-new-pass"> {{call .Tr "Show"}}</label>
</div>
</div>
<div class="option">
- <h3>Email</h3>
+ <h3>{{call .Tr "Email"}}</h3>
<div class="section">
- {{if and (not .Email) (not .IsLogOut)}}<div class="alert info"><p>Add your email to get:</p>
+ {{if and (not .Email) (not .IsLogOut)}}<div class="alert info"><p>{{call .Tr "Add your email to get:"}}</p>
<ul>
- <li>No-passphrase login</li>
- <li>Account recovery if you forget your passphrase</li>
+ <li>{{call .Tr "No-passphrase login"}}</li>
+ <li>{{call .Tr "Account recovery if you forget your passphrase"}}</li>
</ul></div>{{end}}
- <input type="email" name="email" style="letter-spacing: 1px" placeholder="Email address" value="{{.Email}}" size="40" tabindex="{{if .IsLogOut}}2{{else}}3{{end}}" />
+ <input type="email" name="email" style="letter-spacing: 1px" placeholder="{{call .Tr "Email address"}}" value="{{.Email}}" size="40" tabindex="{{if .IsLogOut}}2{{else}}3{{end}}" />
</div>
</div>
<div class="option" style="text-align: center;">
- <input type="submit" value="Save changes" tabindex="4" />
+ <input type="submit" value="{{call .Tr "Save changes"}}" tabindex="4" />
</div>
</form>
{{end}}
{{ if .OauthSection }}
{{ if .OauthAccounts }}
<div class="option">
- <h2>Linked Accounts</h2>
- <p>These are your linked external accounts.</p>
+ <h2>{{call .Tr "Linked Accounts"}}</h2>
+ <p>{{call .Tr "These are your linked external accounts."}}</p>
{{ range $oauth_account := .OauthAccounts }}
<form method="post" action="/api/me/oauth/remove" autocomplete="false">
<input type="hidden" name="provider" value="{{ $oauth_account.Provider }}" />
<input type="hidden" name="client_id" value="{{ $oauth_account.ClientID }}" />
<input type="hidden" name="remote_user_id" value="{{ $oauth_account.RemoteUserID }}" />
<div class="section oauth-provider">
{{ if $oauth_account.DisplayName}}
{{ if $oauth_account.AllowDisconnect}}
<input type="submit" value="Remove {{.DisplayName}}" />
{{else}}
<a class="btn cta"><strong>{{.DisplayName}}</strong></a>
{{end}}
{{else}}
<img src="/img/mark/{{$oauth_account.Provider}}.png" alt="{{ $oauth_account.Provider | title }}" />
<input type="submit" value="Remove {{ $oauth_account.Provider | title }}" />
{{end}}
</div>
</form>
{{ end }}
</div>
{{ end }}
{{ if or .OauthSlack .OauthWriteAs .OauthGitLab .OauthGeneric .OauthGitea }}
<div class="option">
- <h2>Link External Accounts</h2>
- <p>Connect additional accounts to enable logging in with those providers, instead of using your username and password.</p>
+ <h2>{{call .Tr "Link External Accounts"}}</h2>
+ <p>{{call .Tr "Connect additional accounts to enable logging in with those providers, instead of using your username and password."}}</p>
<div class="row signinbtns">
{{ if .OauthWriteAs }}
<div class="section oauth-provider">
<a class="btn cta loginbtn" id="writeas-login" href="/oauth/write.as?attach=t">
<img src="/img/mark/writeas-white.png" alt="Write.as" />
- Link <strong>Write.as</strong>
+ {{call .Tr "ToLink"}} <strong>Write.as</strong>
</a>
</div>
{{ end }}
{{ if .OauthSlack }}
<div class="section oauth-provider">
<a class="btn cta loginbtn" id="slack-login" href="/oauth/slack?attach=t">
<img src="/img/mark/slack.png" alt="Slack" />
- Link <strong>Slack</strong>
+ {{call .Tr "ToLink"}} <strong>Slack</strong>
</a>
</div>
{{ end }}
{{ if .OauthGitLab }}
<div class="section oauth-provider">
<a class="btn cta loginbtn" id="gitlab-login" href="/oauth/gitlab?attach=t">
<img src="/img/mark/gitlab.png" alt="GitLab" />
- Link <strong>{{.GitLabDisplayName}}</strong>
+ {{call .Tr "ToLink"}} <strong>{{.GitLabDisplayName}}</strong>
</a>
</div>
{{ end }}
{{ if .OauthGitea }}
<div class="section oauth-provider">
<a class="btn cta loginbtn" id="gitea-login" href="/oauth/gitea?attach=t">
<img src="/img/mark/gitea.png" alt="Gitea" />
- Link <strong>{{.GiteaDisplayName}}</strong>
+ {{call .Tr "ToLink"}} <strong>{{.GiteaDisplayName}}</strong>
</a>
</div>
{{ end }}
{{ if .OauthGeneric }}
<div class="section oauth-provider">
<a class="btn cta loginbtn" id="generic-oauth-login" href="/oauth/generic?attach=t">
- Link <strong>{{ .OauthGenericDisplayName }}</strong>
+ {{call .Tr "ToLink"}} <strong>{{ .OauthGenericDisplayName }}</strong>
</a>
</div>
{{ end }}
</div>
</div>
{{ end }}
{{ end }}
{{ if and .OpenDeletion (not .IsAdmin) }}
- <h2>Incinerator</h2>
+ <h2>{{call .Tr "Incinerator"}}</h2>
<div class="alert danger">
<div class="row">
<div>
- <h3>Delete your account</h3>
- <p>Permanently erase all your data, with no way to recover it.</p>
+ <h3>{{call .Tr "Delete your account"}}</h3>
+ <p>{{call .Tr "Permanently erase all your data, with no way to recover it."}}</p>
</div>
- <button class="cta danger" onclick="prepareDeleteUser()">Delete your account...</button>
+ <button class="cta danger" onclick="prepareDeleteUser()">{{call .Tr "Delete your account"}}...</button>
</div>
</div>
{{end}}
</div>
<div id="modal-delete-user" class="modal">
- <h2>Are you sure?</h2>
+ <h2>{{call .Tr "Are you sure?"}}</h2>
<div class="body">
- <p style="text-align:left">This action <strong>cannot</strong> be undone. It will immediately and permanently erase your account, including your blogs and posts. Before continuing, you might want to <a href="/me/export">export your data</a>.</p>
- <p>If you're sure, please type <strong>{{.Username}}</strong> to confirm.</p>
+ <p style="text-align:left">{{call .Tr "This action **cannot** be undone. It will immediately and permanently erase your account, including your blogs and posts. Before continuing, you might want to %s." true (variables "export your data;/me/export")}}</p>
+ <p>{{call .Tr "If you're sure, please type **%s** to confirm." true (variables .Username)}}</p>
<ul id="delete-errors" class="errors"></ul>
<form action="/me/delete" method="post" onsubmit="confirmDeletion()">
{{ .CSRFField }}
<input id="confirm-text" placeholder="{{.Username}}" type="text" class="confirm boxy" name="confirm-username" style="margin-top: 0.5em;" />
<div style="text-align:right; margin-top: 1em;">
- <a id="cancel-delete" style="margin-right:2em" href="#">Cancel</a>
- <input class="danger" type="submit" id="confirm-delete" value="Delete your account" disabled />
+ <a id="cancel-delete" style="margin-right:2em" href="#">{{call .Tr "Cancel"}}</a>
+ <input class="danger" type="submit" id="confirm-delete" value="{{call .Tr "Delete your account"}}" disabled />
</div>
</div>
</div>
<script src="/js/h.js"></script>
<script src="/js/modals.js"></script>
<script>
var showChecks = document.querySelectorAll('input.show');
for (var i=0; i<showChecks.length; i++) {
showChecks[i].addEventListener('click', function() {
var prevEl = this.previousElementSibling;
if (prevEl.type == "password") {
prevEl.type = "text";
} else {
prevEl.type = "password";
}
});
}
{{ if and .OpenDeletion (not .IsAdmin) }}
H.getEl('cancel-delete').on('click', closeModals);
let $confirmDelBtn = document.getElementById('confirm-delete');
let $confirmText = document.getElementById('confirm-text')
$confirmText.addEventListener('input', function() {
$confirmDelBtn.disabled = this.value !== '{{.Username}}'
});
function prepareDeleteUser() {
$confirmText.value = ''
showModal('delete-user')
$confirmText.focus()
}
function confirmDeletion() {
$confirmDelBtn.disabled = true
- $confirmDelBtn.value = 'Deleting...'
+ $confirmDelBtn.value = '{{call .Tr "Deleting..."}}'
}
{{ end }}
</script>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/stats.tmpl b/templates/user/stats.tmpl
index 0791f77..b59063b 100644
--- a/templates/user/stats.tmpl
+++ b/templates/user/stats.tmpl
@@ -1,63 +1,63 @@
{{define "stats"}}
{{template "header" .}}
<style>
table.classy th { text-align: left }
table.classy th.num { text-align: right }
td + td {
padding-left: 0.5em;
padding-right: 0.5em;
}
td.num {
text-align: right;
}
table.classy.export a { text-transform: inherit; }
td.none {
font-style: italic;
}
</style>
<div class="content-container snug">
{{if .Silenced}}
- {{template "user-silenced"}}
+ {{template "user-silenced" (dict "Tr" $.Tr)}}
{{end}}
{{template "collection-breadcrumbs" .}}
- <h1 id="posts-header">Stats</h1>
+ <h1 id="posts-header">{{call .Tr "Stats"}}</h1>
{{if .Collection}}
- {{template "collection-nav" (dict "Alias" .Collection.Alias "Path" .Path "SingleUser" .SingleUser)}}
+ {{template "collection-nav" (dict "Alias" .Collection.Alias "Path" .Path "SingleUser" .SingleUser "Tr" .Tr)}}
{{end}}
- <p>Stats for all time.</p>
+ <p>{{call .Tr "Stats for all time."}}</p>
{{if .Federation}}
- <h3>Fediverse stats</h3>
+ <h3>{{call .Tr "Fediverse stats"}}</h3>
<table id="fediverse" class="classy export">
<tr>
- <th>Followers</th>
+ <th>{{call .Tr "Followers"}}</th>
</tr>
<tr>
<td>{{.APFollowers}}</td>
</tr>
</table>
{{end}}
- <h3>Top {{len .TopPosts}} posts</h3>
+ <h3>{{call .Tr "Top %d post" (len .TopPosts) (variables (len .TopPosts))}}</h3>
<table class="classy export">
<tr>
- <th>Post</th>
- {{if not .Collection}}<th>Blog</th>{{end}}
- <th class="num">Total Views</th>
+ <th>{{call .Tr "Post"}}</th>
+ {{if not .Collection}}<th>{{call .Tr "Blog"}}</th>{{end}}
+ <th class="num">{{call .Tr "Total Views"}}</th>
</tr>
{{range .TopPosts}}<tr>
- <td style="word-break: break-all;"><a href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}/{{.ID}}{{end}}">{{if ne .DisplayTitle ""}}{{.DisplayTitle}}{{else}}<em>{{.ID}}</em>{{end}}</a></td>
- {{ if not $.Collection }}<td>{{if .Collection}}<a href="{{.Collection.CanonicalURL}}">{{.Collection.Title}}</a>{{else}}<em>Draft</em>{{end}}</td>{{ end }}
+ <td style="word-break: break-all;"><a href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}/{{.ID}}{{end}}">{{if ne .Title.String ""}}{{.Title.String}}{{else}}<em>{{.ID}}</em>{{end}}</a></td>
+ {{ if not $.Collection }}<td>{{if .Collection}}<a href="{{.Collection.CanonicalURL}}">{{.Collection.Title}}</a>{{else}}<em>{{call .Tr "Draft"}}</em>{{end}}</td>{{ end }}
<td class="num">{{.ViewCount}}</td>
</tr>{{end}}
</table>
</div>
{{template "footer" .}}
{{end}}

File Metadata

Mime Type
text/x-diff
Expires
Thu, Nov 20, 7:29 AM (1 d, 21 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3498450

Event Timeline