Page MenuHomeMusing Studio

No OneTemporary

diff --git a/account.go b/account.go
index 236dfb3..ba013c2 100644
--- a/account.go
+++ b/account.go
@@ -1,1179 +1,1182 @@
/*
* Copyright © 2018-2020 A Bunch Tell 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"
"strings"
"sync"
"time"
"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/writeas/writefreely/author"
"github.com/writeas/writefreely/config"
"github.com/writeas/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
// TODO: remove this var
createdWithPass := true
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: createdWithPass,
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); 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,
}
if !createdWithPass {
resUser.Password = signup.Pass
}
title := signup.Alias
if signup.Normalize {
title = desiredUsername
}
resUser.Collections = &[]Collection{
{
Alias: signup.Alias,
Title: title,
},
}
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)
}
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))
}
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
}
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)
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 {
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 {
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
}
silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil {
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
}
}
topPosts, err := app.db.GetTopPosts(u, alias)
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 {
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 {
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
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(),
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 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/less/admin.less b/less/admin.less
index d9d659e..86dc9ff 100644
--- a/less/admin.less
+++ b/less/admin.less
@@ -1,86 +1,99 @@
.edit-page {
font-size: 1em;
min-height: 12em;
}
header.admin {
margin: 0;
h1 + a {
margin-left: 1em;
}
}
nav#admin {
display: block;
margin: 0.5em 0;
a {
margin-left: 0;
.rounded(.25em);
border: 0;
&.selected {
background: #dedede;
font-weight: bold;
.blip {
color: black;
}
}
}
.blip {
font-weight: bold;
}
}
.pager {
display: flex;
justify-content: center;
+ &:not(.pages) {
+ display: block;
+ margin: 0.5em 0;
+ a {
+ margin-left: 0;
+ .rounded(.25em);
+
+ &+a {
+ margin-left: 0.5em;
+ }
+ }
+ }
+
a {
color: #333;
font-family: @sansFont;
font-size: 0.86em;
padding: 0.5em 1em;
border: 1px solid #ccc;
&:hover {
text-decoration: none;
background: #efefef;
}
&.selected {
cursor: default;
background: #ccc;
}
}
}
.admin-actions {
.btn {
font-family: @sansFont;
font-size: 0.86em;
}
}
.features {
margin: 1em 0;
div {
&:first-child {
font-weight: bold;
}
&+div {
padding-left: 1em;
}
p {
font-weight: normal;
margin: 0.5rem 0;
font-size: 0.86em;
color: #666;
}
}
}
@media (max-width: 600px) {
div.row.features {
align-items: start;
}
.features div + div {
padding-left: 0;
}
}
\ No newline at end of file
diff --git a/less/core.less b/less/core.less
index b502c5d..b085241 100644
--- a/less/core.less
+++ b/less/core.less
@@ -1,1549 +1,1595 @@
@primary: rgb(114, 120, 191);
@secondary: rgb(114, 191, 133);
@subheaders: #444;
@headerTextColor: black;
@sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif;
@serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif;
@monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace;
@dangerCol: #e21d27;
@errUrgentCol: #ecc63c;
@proSelectedCol: #71D571;
@textLinkColor: rgb(0, 0, 238);
+@accent: #767676;
+
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;
.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;
}
}
}
}
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, 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, 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;
}
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;
+ }
+}
+
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;
}
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;
- margin-left: 0.5em;
+ 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 0.25em;
+ 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;
}
}
@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/less/new-core.less b/less/new-core.less
index 87d8158..c9e7a17 100644
--- a/less/new-core.less
+++ b/less/new-core.less
@@ -1,258 +1,257 @@
@actionNavColor: #767676;
body {
margin: 0;
padding: 0;
font-size: 100%;
footer {
text-align: center;
padding: 0 2em;
nav {
margin: 3em 0 4em;
color: #444;
a {
text-decoration: none;
+ a {
margin-left: 0.8em;
}
&:link, &:visited {
color: #999;
}
&:hover {
color: #666;
}
&.home {
color: #333;
font-weight: bold;
&:hover {
color: #000;
}
}
}
}
}
}
header {
margin: 1em;
h1, h2 {
display: inline;
}
nav {
display: inline;
margin: 0 1em;
line-height: 2.4em;
span, a {
margin: 0 0 0 1em;
}
a {
color: @actionNavColor;
&:hover {
text-decoration: underline;
}
}
}
p {
&.description {
color: #444;
font-size: 1.1em;
margin-top: 0.5em;
line-height: 1.5;
}
&.meta-note {
color: #333;
font-style: italic;
margin-top: 2em;
span {
text-transform: uppercase;
font-variant: small-caps;
font-size: 0.9em;
color: #666;
font-style: normal;
}
}
}
}
hr {
border: 0;
height: 1px;
background: #ccc;
max-width: 40em;
margin: 4em auto;
text-align: center;
}
textarea, textarea:focus {
border: 0;
}
textarea, textarea:focus, input {
outline: 0;
}
textarea {
width: 100%;
resize: none;
&#editor {
position: fixed;
top: 3em;
right: 0;
bottom: 2em;
left: 0;
padding: 2em 2em 0 2em;
font-size: 2em;
box-sizing: border-box;
}
}
#official-writing, #wrapper {
margin: 1em 2em;
ul {
margin: 0;
padding: 0 0 0 1em;
line-height: 1.5;
&.collections, &.posts, &.integrations {
list-style: none;
margin-left: 1em;
li + li {
margin-top: 0.4em;
}
}
&.collections li {
&.collection {
a.title {
font-size: 1.3em;
- font-weight: bold;
}
}
}
}
}
.clearfix {
overflow: auto;
}
.half-col, .half, .third {
float: left;
+ .half-col {
margin-left: 4em;
}
}
.half {
width: 50%;
}
.third {
width: 33%;
}
code, textarea#embed {
font-family: monospace, monospace;
font-size: 1em;
}
#wrapper {
max-width: 50em;
}
#official-writing, #wrapper {
h2 {
&.minor {
font-size: 1.3em;
}
&.bugfix {
font-size: 1.15em;
}
+.android-version {
margin-top: 0;
font-size: 1.1em;
a {
&:hover {
text-decoration: underline;
}
}
}
}
}
#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;
}
}
}
#collection-options {
#title, #description {
width: 100%;
box-sizing: border-box;
}
.option {
h2 {
margin-top: 2em;
margin-bottom: 0.5em;
}
label {
&.option-text.disabled {
color: #999;
#domain-alias {
border-color: #ccc;
}
&+p {
color: #555;
}
}
}
label+p, p.describe {
font-size: 0.8em;
margin-top: 0.4em;
margin-left: 1.8em;
}
input.low-profile {
padding: 0.25rem 0.5rem;
margin-left: 0.25rem;
font-size: 0.8em;
}
.fedi-handle {
margin-left: 0.5em;
.transition-duration(0.25s);
}
}
}
diff --git a/templates.go b/templates.go
index be1412c..846c5d8 100644
--- a/templates.go
+++ b/templates.go
@@ -1,208 +1,229 @@
/*
* Copyright © 2018 A Bunch Tell 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/writeas/writefreely/config"
)
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,
}
)
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" {
// 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 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
+}
diff --git a/templates/user/admin/users.tmpl b/templates/user/admin/users.tmpl
index 714fa24..4b2404e 100644
--- a/templates/user/admin/users.tmpl
+++ b/templates/user/admin/users.tmpl
@@ -1,36 +1,36 @@
{{define "users"}}
{{template "header" .}}
<div class="snug content-container">
{{template "admin-header" .}}
<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>
</div>
<table class="classy export" style="width:100%">
<tr>
<th>User</th>
<th>Joined</th>
<th>Type</th>
<th>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>
</tr>
{{end}}
</table>
- <nav class="pager">
+ <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>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/collection.tmpl b/templates/user/collection.tmpl
index 14114e6..52d1934 100644
--- a/templates/user/collection.tmpl
+++ b/templates/user/collection.tmpl
@@ -1,258 +1,263 @@
{{define "upgrade"}}
<p><a href="/me/plan?to=/me/c/{{.Alias}}">Upgrade</a> for <span>$40 / year</span> to edit.</p>
{{end}}
{{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;
}
</style>
<div class="content-container snug">
<div id="overlay"></div>
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
- <h2>Customize {{.DisplayTitle}} <a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">view blog</a></h2>
+
+ {{template "collection-breadcrumbs" .}}
+
+ <h1>Customize</h1>
+
+ {{template "collection-nav" (dict "Alias" .Alias "Path" .Path "SingleUser" .SingleUser)}}
{{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" /></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}}
<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>
<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
</label>
<p>This blog is visible to {{if .Private}}any registered user on this instance{{else}}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
</label>
<p>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}}" />
</label>
<p>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
</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}}
</li>
{{end}}
</ul>
</div>
</div>
<div class="option">
<h2>Display Format</h2>
<div class="section">
<p class="explain">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>
</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>
</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>
</li>
</ul>
</div>
</div>
<div class="option">
<h2>Text Rendering</h2>
<div class="section">
<p class="explain">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>
<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>
</div>
</div>
<div class="option">
<h2>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>
<textarea id="signature" class="section norm" name="signature">{{.Signature}}</textarea>
</div>
</div>
<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}}
</div>
</div>
</form>
</div>
<div id="modal-delete" class="modal">
<h2>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>
<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>
</div>
</div>
</div>
<script src="/js/h.js"></script>
<script src="/js/ace.js" type="text/javascript" charset="utf-8"></script>
<script>
// Begin shared modal code
function showModal(id) {
document.getElementById('overlay').style.display = 'block';
document.getElementById('modal-'+id).style.display = 'block';
}
var closeModals = function(e) {
e.preventDefault();
document.getElementById('overlay').style.display = 'none';
var modals = document.querySelectorAll('.modal');
for (var i=0; i<modals.length; i++) {
modals[i].style.display = 'none';
}
};
H.getEl('overlay').on('click', closeModals);
H.getEl('cancel-delete').on('click', closeModals);
// end
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>';
return;
}
// Clear errors
document.getElementById('delete-errors').innerHTML = '';
document.getElementById('btn-delete').innerHTML = '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';
}
}
};
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.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');
var opt = {
showLineNumbers: false,
showPrintMargin: 0,
};
var theme = "ace/theme/chrome";
var cssEditor = ace.edit("css-editor");
cssEditor.setTheme(theme);
cssEditor.session.setMode("ace/mode/css");
cssEditor.setOptions(opt);
</script>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/collections.tmpl b/templates/user/collections.tmpl
index b2ce51a..f371ce0 100644
--- a/templates/user/collections.tmpl
+++ b/templates/user/collections.tmpl
@@ -1,114 +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"}}
{{end}}
<h1>Blogs</h1>
<ul class="atoms collections">
- {{range $i, $el := .Collections}}<li class="collection"><h3>
- <a class="title" href="/{{.Alias}}/">{{if .Title}}{{.Title}}{{else}}{{.Alias}}{{end}}</a>
- </h3>
- <h4>
- <a class="action new-post" href="{{if $.Chorus}}/new{{else}}/{{end}}#{{.Alias}}">new post</a>
- <a class="action" href="/me/c/{{.Alias}}">customize</a>
- <a class="action" href="/me/c/{{.Alias}}/stats">stats</a>
- </h4>
- {{if .Description}}<p class="description">{{.Description}}</p>{{end}}
-</li>{{end}}
+ {{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 )}}
+ {{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.";
$createColl.appendChild(url);
} else {
$err.innerText = "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.";
}
}
}
});
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/include/header.tmpl b/templates/user/include/header.tmpl
index 21a81ff..9c4effa 100644
--- a/templates/user/include/header.tmpl
+++ b/templates/user/include/header.tmpl
@@ -1,113 +1,115 @@
{{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="/me/c/{{.Username}}">Customize</a></li>
- <li><a href="/me/c/{{.Username}}/stats">Stats</a></li>
- <li class="separator"><hr /></li>
- {{if .IsAdmin}}<li><a href="/admin">Admin</a></li>{{end}}
- <li><a href="/me/settings">Settings</a></li>
- <li><a href="/me/import">Import posts</a></li>
- <li><a href="/me/export">Export</a></li>
- <li class="separator"><hr /></li>
- <li><a href="/me/logout">Log out</a></li>
- </ul></li>
- </ul>
- </nav>
- <nav class="tabs">
- <a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>
- <a href="/me/new">New Post</a>
+ <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>
+ {{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>
+ <li class="separator"><hr /></li>
+ <li><a href="/me/logout">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>
+ </nav>
</nav>
- </nav>
+ <div class="right-side">
+ <a class="simple-btn" href="/me/new">New Post</a>
+ </div>
{{else}}
- <nav id="full-nav">
<div class="left-side">
<h1><a href="/" title="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}}
- <li class="separator"><hr /></li>
- <li><a href="/me/logout">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}}
- {{ end }}
- <a href="/about">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}}
- {{ 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}}
- {{ 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}}
+ <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}}
+ <li class="separator"><hr /></li>
+ <li><a href="/me/logout">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}}
+ {{ end }}
+ <a href="/about">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}}
+ {{ 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}}
+ {{ 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}}
+ {{end}}
+ </nav>
</nav>
- </nav>
- {{if .Chorus}}{{if .Username}}<div class="right-side">
- <a class="simple-btn" href="/new">New Post</a>
- </div>{{end}}
- </nav>
- {{end}}
+ {{if .Username}}
+ <div class="right-side">
+ <a class="simple-btn" href="/{{if .CollAlias}}#{{.CollAlias}}{{end}}">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" />
<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>
<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>
{{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}}
{{end}}
{{if not .Forest}}
<a href="/admin/monitor" {{if eq .Path "/admin/monitor"}}class="selected"{{end}}>Monitor</a>
{{end}}
</nav>
</header>
{{end}}
diff --git a/templates/user/include/nav.tmpl b/templates/user/include/nav.tmpl
new file mode 100644
index 0000000..057fc3c
--- /dev/null
+++ b/templates/user/include/nav.tmpl
@@ -0,0 +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}}
+{{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>
+ </nav>
+ </header>
+ {{end}}
+{{end}}
diff --git a/templates/user/settings.tmpl b/templates/user/settings.tmpl
index 22de3d8..04fb2d9 100644
--- a/templates/user/settings.tmpl
+++ b/templates/user/settings.tmpl
@@ -1,171 +1,171 @@
{{define "settings"}}
{{template "header" .}}
<style type="text/css">
.option { margin: 2em 0em; }
h3 { font-weight: normal; }
.section p, .section label {
font-size: 0.86em;
}
.oauth-provider img {
max-height: 2.75em;
vertical-align: middle;
}
</style>
<div class="content-container snug">
{{if .Silenced}}
{{template "user-silenced"}}
{{end}}
- <h1>{{if .IsLogOut}}Before you go...{{else}}Account Settings {{if .IsAdmin}}<a href="/admin">admin settings</a>{{end}}{{end}}</h1>
+ <h1>{{if .IsLogOut}}Before you go...{{else}}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>
</div>
{{ else }}
<div>
<p>Change your account settings here.</p>
</div>
<form method="post" action="/api/me/self" autocomplete="false">
<div class="option">
<h3>Username</h3>
<div class="section">
<input type="text" name="username" value="{{.Username}}" tabindex="1" />
<input type="submit" value="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>
<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>
{{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>
</div>
</div>
<div class="option">
<h3>Email</h3>
<div class="section">
{{if and (not .Email) (not .IsLogOut)}}<div class="alert info"><p>Add your email to get:</p>
<ul>
<li>No-passphrase login</li>
<li>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}}" />
</div>
</div>
<div class="option" style="text-align: center;">
<input type="submit" value="Save changes" tabindex="4" />
</div>
</form>
{{end}}
{{ if .OauthSection }}
<hr />
{{ if .OauthAccounts }}
<div class="option">
<h2>Linked Accounts</h2>
<p>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>
<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>
</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>
</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>
</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>
</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>
</a>
</div>
{{ end }}
</div>
</div>
{{ end }}
{{ end }}
</div>
<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";
}
});
}
</script>
{{template "footer" .}}
{{end}}
diff --git a/templates/user/stats.tmpl b/templates/user/stats.tmpl
index 19cc33b..f3e3dce 100644
--- a/templates/user/stats.tmpl
+++ b/templates/user/stats.tmpl
@@ -1,56 +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"}}
{{end}}
- <h2 id="posts-header">{{if .Collection}}{{.Collection.DisplayTitle}} {{end}}Stats</h2>
+
+ {{template "collection-breadcrumbs" .}}
+
+ <h1 id="posts-header">Stats</h1>
+
+ {{if .Collection}}
+ {{template "collection-nav" (dict "Alias" .Collection.Alias "Path" .Path "SingleUser" .SingleUser)}}
+ {{end}}
<p>Stats for all time.</p>
{{if .Federation}}
<h3>Fediverse stats</h3>
<table id="fediverse" class="classy export">
<tr>
<th>Followers</th>
</tr>
<tr>
<td>{{.APFollowers}}</td>
</tr>
</table>
{{end}}
<h3>Top {{len .TopPosts}} posts</h3>
<table class="classy export">
<tr>
<th>Post</th>
{{if not .Collection}}<th>Blog</th>{{end}}
<th class="num">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 .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>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
Fri, Apr 25, 3:13 AM (1 d, 6 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3214842

Event Timeline