[feature] User-selectable preset CSS themes for accounts (#2777)
* [feature] User-selectable preset themes * docs, more theme stuff * lint, tests * fix css name * correct some little issues * add another theme * fix poll background * okay last theme i swear * make retrieval of apimodel themes more conventional * preallocate stylesheet slices
This commit is contained in:
parent
b7b42e832a
commit
8953f57d88
|
@ -0,0 +1,30 @@
|
|||
# Themes
|
||||
|
||||
Users on your instance can select a theme for their profile from any css files present in the `web/assets/themes` directory.
|
||||
|
||||
GoToSocial comes with some theme files already, but you can add more yourself by doing the following:
|
||||
|
||||
1. Create a file in `web/assets/themes` called (for example) `new-theme.css`.
|
||||
2. (Optional) Include the following comment at the top of your theme file to title and describe your theme:
|
||||
```css
|
||||
/*
|
||||
theme-title: My New Theme
|
||||
theme-description: This is an example theme
|
||||
*/
|
||||
```
|
||||
You can use any text you like for these fields, but bear in mind whatever you write here will appear in the settings panel to help users when selecting a theme, so keep it short and sweet.
|
||||
3. Fill out your custom CSS in the rest of the file. You can use one of the existing CSS files to guide you. Also see [this page](../user_guide/custom_css.md) for some rough guidelines about how to write accessible CSS.
|
||||
4. Restart your instance so that the new CSS file is picked up.
|
||||
|
||||
!!! info
|
||||
If you're using Docker for your deployment, you can mount theme files from the host machine into your GoToSocial `web/assets/themes` directory instead, by including entries for them in the `volumes` section of your Docker configuration.
|
||||
|
||||
For example, say you've created a theme on your host machine at `~/gotosocial/my-themes/new-theme.css`, you could mount that theme into the GoToSocial Docker container in the following way:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
[.... some other volume entries ...]
|
||||
- "~/gotosocial/my-themes/new-theme.css:/gotosocial/web/assets/themes/new-theme.css"
|
||||
```
|
||||
|
||||
Bear in mind if you mount an entire directory to `/gotosocial/web/assets/themes` instead of mounting individual theme files, you'll override the default themes.
|
|
@ -294,6 +294,10 @@ definitions:
|
|||
description: Account has been suspended by our instance.
|
||||
type: boolean
|
||||
x-go-name: Suspended
|
||||
theme:
|
||||
description: Filename of user-selected CSS theme to include when rendering this account's profile or statuses. Eg., `blurple-light.css`.
|
||||
type: string
|
||||
x-go-name: Theme
|
||||
url:
|
||||
description: Web location of the account's profile page.
|
||||
example: https://example.org/@some_user
|
||||
|
@ -2463,6 +2467,24 @@ definitions:
|
|||
type: object
|
||||
x-go-name: Tag
|
||||
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
|
||||
theme:
|
||||
properties:
|
||||
description:
|
||||
description: User-facing description of this theme.
|
||||
type: string
|
||||
x-go-name: Description
|
||||
file_name:
|
||||
description: FileName of this theme in the themes directory.
|
||||
type: string
|
||||
x-go-name: FileName
|
||||
title:
|
||||
description: User-facing title of this theme.
|
||||
type: string
|
||||
x-go-name: Title
|
||||
title: Theme represents one user-selectable preset CSS theme.
|
||||
type: object
|
||||
x-go-name: Theme
|
||||
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
|
||||
wellKnownResponse:
|
||||
description: See https://webfinger.net/
|
||||
properties:
|
||||
|
@ -3448,6 +3470,34 @@ paths:
|
|||
summary: Search for accounts by username and/or display name.
|
||||
tags:
|
||||
- accounts
|
||||
/api/v1/accounts/themes:
|
||||
get:
|
||||
operationId: accountThemes
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Array of themes.
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/theme'
|
||||
type: array
|
||||
"400":
|
||||
description: bad request
|
||||
"401":
|
||||
description: unauthorized
|
||||
"404":
|
||||
description: not found
|
||||
"406":
|
||||
description: not acceptable
|
||||
"500":
|
||||
description: internal server error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- read:accounts
|
||||
summary: See preset CSS themes available to accounts on this instance.
|
||||
tags:
|
||||
- accounts
|
||||
/api/v1/accounts/update_credentials:
|
||||
patch:
|
||||
consumes:
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 102 KiB |
|
@ -22,6 +22,15 @@ A preview of the image as it will appear on your profile will be shown. If you'r
|
|||
|
||||
If you navigate to your profile and refresh the page, your new avatar / header will be shown. It might take a bit longer for the update to federate out to remote instances.
|
||||
|
||||
### Select Theme
|
||||
|
||||
GoToSocial provides themes for you to choose from for the web view of your profile, to change your profile's appearance and vibe.
|
||||
|
||||
To choose a theme, just select it from the profile settings page, and click/tap "Save profile info" at the bottom of the page. When you look at your profile in the web view (you may need to refresh the page), you'll see the new theme applied, and so will anyone else visiting your profile.
|
||||
|
||||
!!! tip "Adding more themes"
|
||||
Instance admins can add more themes by dropping css files into the `web/assets/themes` folder. See the [themes](../admin/themes.md) part of the admin docs for more information.
|
||||
|
||||
### Basic Information
|
||||
|
||||
#### Display Name
|
||||
|
@ -109,9 +118,14 @@ Turning on the discoverable flag may take a week or more to propagate; your acco
|
|||
|
||||
#### Custom CSS
|
||||
|
||||
If enabled on your instance by the instance administrator, [Custom CSS](./custom_css.md) allows you to theme the way your profile looks when visited through a browser.
|
||||
If enabled on your instance by the instance administrator, custom CSS allows you to further customize the way your profile looks when visited through a browser.
|
||||
|
||||
When this setting is not enabled by the instance administrator, the text input box is read-only.
|
||||
When this setting is not enabled by the instance administrator, the text input box is read-only and custom CSS will not be applied.
|
||||
|
||||
See the [Custom CSS](./custom_css.md) page for some tips on writing custom CSS for your profile.
|
||||
|
||||
!!! tip
|
||||
Any custom CSS you add in this box will be applied *after* your selected theme, so you can pick a preset theme that you like and then make your own tweaks!
|
||||
|
||||
## Post Settings
|
||||
|
||||
|
|
|
@ -55,6 +55,7 @@ const (
|
|||
VerifyPath = BasePath + "/verify_credentials"
|
||||
MovePath = BasePath + "/move"
|
||||
AliasPath = BasePath + "/alias"
|
||||
ThemesPath = BasePath + "/themes"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
|
@ -114,4 +115,7 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
|
|||
// migration handlers
|
||||
attachHandler(http.MethodPost, AliasPath, m.AccountAliasPOSTHandler)
|
||||
attachHandler(http.MethodPost, MovePath, m.AccountMovePOSTHandler)
|
||||
|
||||
// account themes
|
||||
attachHandler(http.MethodGet, ThemesPath, m.AccountThemesGETHandler)
|
||||
}
|
||||
|
|
|
@ -309,6 +309,7 @@ func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateCredentialsRequest,
|
|||
form.Source.Language == nil &&
|
||||
form.Source.StatusContentType == nil &&
|
||||
form.FieldsAttributes == nil &&
|
||||
form.Theme == nil &&
|
||||
form.CustomCSS == nil &&
|
||||
form.EnableRSS == nil) {
|
||||
return nil, errors.New("empty form submitted")
|
||||
|
|
|
@ -69,7 +69,7 @@ import (
|
|||
// '500':
|
||||
// description: internal server error
|
||||
func (m *Module) AccountListsGETHandler(c *gin.Context) {
|
||||
authed, err := oauth.Authed(c, false, false, false, false)
|
||||
authed, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
|
@ -134,7 +134,7 @@ import (
|
|||
// '500':
|
||||
// description: internal server error
|
||||
func (m *Module) AccountStatusesGETHandler(c *gin.Context) {
|
||||
authed, err := oauth.Authed(c, false, false, false, false)
|
||||
authed, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// AccountThemesGETHandler swagger:operation GET /api/v1/accounts/themes accountThemes
|
||||
//
|
||||
// See preset CSS themes available to accounts on this instance.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - accounts
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - read:accounts
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// name: statuses
|
||||
// description: Array of themes.
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/theme"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '404':
|
||||
// description: not found
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '500':
|
||||
// description: internal server error
|
||||
func (m *Module) AccountThemesGETHandler(c *gin.Context) {
|
||||
_, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve available themes.
|
||||
themes := m.processor.Account().ThemesGet()
|
||||
apiutil.JSON(c, http.StatusOK, themes)
|
||||
}
|
|
@ -89,6 +89,8 @@ type Account struct {
|
|||
MuteExpiresAt string `json:"mute_expires_at,omitempty"`
|
||||
// Extra profile information. Shown only if the requester owns the account being requested.
|
||||
Source *Source `json:"source,omitempty"`
|
||||
// Filename of user-selected CSS theme to include when rendering this account's profile or statuses. Eg., `blurple-light.css`.
|
||||
Theme string `json:"theme,omitempty"`
|
||||
// CustomCSS to include when rendering this account's profile or statuses.
|
||||
CustomCSS string `json:"custom_css,omitempty"`
|
||||
// Account has enabled RSS feed.
|
||||
|
@ -162,7 +164,11 @@ type UpdateCredentialsRequest struct {
|
|||
FieldsAttributes *[]UpdateField `form:"fields_attributes" json:"-"`
|
||||
// Profile metadata names and values, parsed from JSON.
|
||||
JSONFieldsAttributes *map[string]UpdateField `form:"-" json:"fields_attributes"`
|
||||
// Theme file name to be used when rendering this account's profile or statuses.
|
||||
// Use empty string to unset.
|
||||
Theme *string `form:"theme" json:"theme"`
|
||||
// Custom CSS to be included when rendering this account's profile or statuses.
|
||||
// Use empty string to unset.
|
||||
CustomCSS *string `form:"custom_css" json:"custom_css"`
|
||||
// Enable RSS feed of public toots for this account at /@[username]/feed.rss
|
||||
EnableRSS *bool `form:"enable_rss" json:"enable_rss"`
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package model
|
||||
|
||||
// Theme represents one user-selectable preset CSS theme.
|
||||
//
|
||||
// swagger:model theme
|
||||
type Theme struct {
|
||||
// User-facing title of this theme.
|
||||
Title string `json:"title"`
|
||||
|
||||
// User-facing description of this theme.
|
||||
Description string `json:"description"`
|
||||
|
||||
// FileName of this theme in the themes directory.
|
||||
FileName string `json:"file_name"`
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func init() {
|
||||
up := func(ctx context.Context, db *bun.DB) error {
|
||||
// Add theme to account settings table.
|
||||
_, err := db.ExecContext(ctx,
|
||||
"ALTER TABLE ? ADD COLUMN ? TEXT",
|
||||
bun.Ident("account_settings"), bun.Ident("theme"),
|
||||
)
|
||||
if err != nil {
|
||||
e := err.Error()
|
||||
if !(strings.Contains(e, "already exists") ||
|
||||
strings.Contains(e, "duplicate column name") ||
|
||||
strings.Contains(e, "SQLSTATE 42701")) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
down := func(ctx context.Context, db *bun.DB) error {
|
||||
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := Migrations.Register(up, down); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
|
@ -220,3 +220,17 @@ type Relationship struct {
|
|||
Endorsed bool // Are you featuring this user on your profile?
|
||||
Note string // Your note on this account.
|
||||
}
|
||||
|
||||
// Theme represents a user-selected
|
||||
// CSS theme for an account.
|
||||
type Theme struct {
|
||||
// User-facing title of this theme.
|
||||
Title string
|
||||
|
||||
// User-facing description of this theme.
|
||||
Description string
|
||||
|
||||
// FileName of this theme in the themes
|
||||
// directory (eg., `light-blurple.css`).
|
||||
FileName string
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ type AccountSettings struct {
|
|||
Sensitive *bool `bun:",nullzero,notnull,default:false"` // Set posts from this account to sensitive by default?
|
||||
Language string `bun:",nullzero,notnull,default:'en'"` // What language does this account post in?
|
||||
StatusContentType string `bun:",nullzero"` // What is the default format for statuses posted by this account (only for local accounts).
|
||||
Theme string `bun:",nullzero"` // Preset CSS theme filename selected by this Account (empty string if nothing set).
|
||||
CustomCSS string `bun:",nullzero"` // Custom CSS that should be displayed for this Account's profile and statuses.
|
||||
EnableRSS *bool `bun:",nullzero,notnull,default:false"` // enable RSS feed subscription for this account's public posts at [URL]/feed
|
||||
HideCollections *bool `bun:",nullzero,notnull,default:false"` // Hide this account's followers/following collections.
|
||||
|
|
|
@ -44,6 +44,7 @@ type Processor struct {
|
|||
formatter *text.Formatter
|
||||
federator *federation.Federator
|
||||
parseMention gtsmodel.ParseMentionFunc
|
||||
themes *Themes
|
||||
}
|
||||
|
||||
// New returns a new account processor.
|
||||
|
@ -67,5 +68,6 @@ func New(
|
|||
formatter: text.NewFormatter(state.DB),
|
||||
federator: federator,
|
||||
parseMention: parseMention,
|
||||
themes: PopulateThemes(),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,151 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package account
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/gruf/go-bytesize"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
)
|
||||
|
||||
var (
|
||||
themeTitleRegex = regexp.MustCompile(`(?m)^\ *theme-title:(.*)$`)
|
||||
themeDescriptionRegex = regexp.MustCompile(`(?m)^\ *theme-description:(.*)$`)
|
||||
)
|
||||
|
||||
// GetThemes returns available account css themes.
|
||||
func (p *Processor) ThemesGet() []apimodel.Theme {
|
||||
return p.converter.ThemesToAPIThemes(p.themes.SortedByTitle)
|
||||
}
|
||||
|
||||
// Themes represents an in-memory
|
||||
// storage structure for themes.
|
||||
type Themes struct {
|
||||
// Themes sorted alphabetically
|
||||
// by title (case insensitive).
|
||||
SortedByTitle []*gtsmodel.Theme
|
||||
|
||||
// ByFileName contains themes retrievable
|
||||
// by their filename eg., `light-blurple.css`.
|
||||
ByFileName map[string]*gtsmodel.Theme
|
||||
}
|
||||
|
||||
// PopulateThemes parses available account CSS
|
||||
// themes from the web assets themes directory.
|
||||
func PopulateThemes() *Themes {
|
||||
webAssetsAbsFilePath, err := filepath.Abs(config.GetWebAssetBaseDir())
|
||||
if err != nil {
|
||||
log.Panicf(nil, "error getting abs path for web assets: %v", err)
|
||||
}
|
||||
|
||||
themesAbsFilePath := filepath.Join(webAssetsAbsFilePath, "themes")
|
||||
themesFiles, err := os.ReadDir(themesAbsFilePath)
|
||||
if err != nil {
|
||||
log.Warnf(nil, "error reading themes at %s: %v", themesAbsFilePath, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
themes := &Themes{
|
||||
ByFileName: make(map[string]*gtsmodel.Theme),
|
||||
}
|
||||
|
||||
for _, f := range themesFiles {
|
||||
// Ignore nested directories.
|
||||
if f.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ignore weird files.
|
||||
info, err := f.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ignore really big files.
|
||||
if info.Size() > int64(bytesize.MiB) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get just the name of the
|
||||
// file, eg `blurple-light.css`.
|
||||
fileName := f.Name()
|
||||
|
||||
// Get just the `.css` part.
|
||||
extensionWithDot := filepath.Ext(fileName)
|
||||
|
||||
// Remove any leading `.`
|
||||
extension := strings.TrimPrefix(extensionWithDot, ".")
|
||||
|
||||
// Ignore non-css files.
|
||||
if extension != "css" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Load the file contents.
|
||||
path := filepath.Join(themesAbsFilePath, fileName)
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Warnf(nil, "error reading css theme at %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to parse a title and description
|
||||
// for this theme from the file itself.
|
||||
var themeTitle string
|
||||
titleMatches := themeTitleRegex.FindSubmatch(contents)
|
||||
if len(titleMatches) == 2 {
|
||||
themeTitle = strings.TrimSpace(string(titleMatches[1]))
|
||||
} else {
|
||||
// Fall back to file name
|
||||
// without `.css` suffix.
|
||||
themeTitle = strings.TrimSuffix(fileName, ".css")
|
||||
}
|
||||
|
||||
var themeDescription string
|
||||
descMatches := themeDescriptionRegex.FindSubmatch(contents)
|
||||
if len(descMatches) == 2 {
|
||||
themeDescription = strings.TrimSpace(string(descMatches[1]))
|
||||
}
|
||||
|
||||
theme := >smodel.Theme{
|
||||
Title: themeTitle,
|
||||
Description: themeDescription,
|
||||
FileName: fileName,
|
||||
}
|
||||
|
||||
themes.SortedByTitle = append(themes.SortedByTitle, theme)
|
||||
themes.ByFileName[fileName] = theme
|
||||
}
|
||||
|
||||
// Sort themes alphabetically
|
||||
// by title (case insensitive).
|
||||
slices.SortFunc(themes.SortedByTitle, func(a, b *gtsmodel.Theme) int {
|
||||
return cmp.Compare(strings.ToLower(a.Title), strings.ToLower(b.Title))
|
||||
})
|
||||
|
||||
return themes
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package account_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/account"
|
||||
)
|
||||
|
||||
type ThemesTestSuite struct {
|
||||
AccountStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *ThemesTestSuite) TestPopulateThemes() {
|
||||
config.SetWebAssetBaseDir("../../../web/assets")
|
||||
|
||||
themes := account.PopulateThemes()
|
||||
if themes == nil {
|
||||
suite.FailNow("themes was nil")
|
||||
}
|
||||
|
||||
suite.NotEmpty(themes.SortedByTitle)
|
||||
theme := themes.ByFileName["blurple-light.css"]
|
||||
if theme == nil {
|
||||
suite.FailNow("theme was nil")
|
||||
}
|
||||
suite.Equal("Blurple (light)", theme.Title)
|
||||
suite.Equal("Official light blurple theme", theme.Description)
|
||||
suite.Equal("blurple-light.css", theme.FileName)
|
||||
}
|
||||
|
||||
func TestThemesTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(ThemesTestSuite))
|
||||
}
|
|
@ -256,6 +256,22 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form
|
|||
}
|
||||
}
|
||||
|
||||
if form.Theme != nil {
|
||||
theme := *form.Theme
|
||||
if theme == "" {
|
||||
// Empty is easy, just clear this.
|
||||
account.Settings.Theme = ""
|
||||
} else {
|
||||
// Theme was provided, check
|
||||
// against known available themes.
|
||||
if _, ok := p.themes.ByFileName[theme]; !ok {
|
||||
err := fmt.Errorf("theme %s not available on this instance, see /api/v1/accounts/themes for available themes", theme)
|
||||
return nil, gtserror.NewErrorBadRequest(err, err.Error())
|
||||
}
|
||||
account.Settings.Theme = theme
|
||||
}
|
||||
}
|
||||
|
||||
if form.CustomCSS != nil {
|
||||
customCSS := *form.CustomCSS
|
||||
if err := validate.CustomCSS(customCSS); err != nil {
|
||||
|
|
|
@ -170,12 +170,13 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
|
|||
// Bits that vary between remote + local accounts:
|
||||
// - Account (acct) string.
|
||||
// - Role.
|
||||
// - Settings things (enableRSS, customCSS).
|
||||
// - Settings things (enableRSS, theme, customCSS).
|
||||
|
||||
var (
|
||||
acct string
|
||||
role *apimodel.AccountRole
|
||||
enableRSS bool
|
||||
theme string
|
||||
customCSS string
|
||||
)
|
||||
|
||||
|
@ -208,6 +209,7 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
|
|||
}
|
||||
|
||||
enableRSS = *a.Settings.EnableRSS
|
||||
theme = a.Settings.Theme
|
||||
customCSS = a.Settings.CustomCSS
|
||||
}
|
||||
|
||||
|
@ -272,6 +274,7 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
|
|||
Emojis: apiEmojis,
|
||||
Fields: fields,
|
||||
Suspended: !a.SuspendedAt.IsZero(),
|
||||
Theme: theme,
|
||||
CustomCSS: customCSS,
|
||||
EnableRSS: enableRSS,
|
||||
Role: role,
|
||||
|
@ -1771,3 +1774,16 @@ func (c *Converter) convertTagsToAPITags(ctx context.Context, tags []*gtsmodel.T
|
|||
|
||||
return apiTags, errs.Combine()
|
||||
}
|
||||
|
||||
// ThemesToAPIThemes converts a slice of gtsmodel Themes into apimodel Themes.
|
||||
func (c *Converter) ThemesToAPIThemes(themes []*gtsmodel.Theme) []apimodel.Theme {
|
||||
apiThemes := make([]apimodel.Theme, len(themes))
|
||||
for i, theme := range themes {
|
||||
apiThemes[i] = apimodel.Theme{
|
||||
Title: theme.Title,
|
||||
Description: theme.Description,
|
||||
FileName: theme.FileName,
|
||||
}
|
||||
}
|
||||
return apiThemes
|
||||
}
|
||||
|
|
|
@ -140,16 +140,40 @@ func (m *Module) profileGETHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Prepare stylesheets for profile.
|
||||
stylesheets := make([]string, 0, 6)
|
||||
|
||||
// Basic profile stylesheets.
|
||||
stylesheets = append(
|
||||
stylesheets,
|
||||
[]string{
|
||||
cssFA,
|
||||
cssStatus,
|
||||
cssThread,
|
||||
cssProfile,
|
||||
}...,
|
||||
)
|
||||
|
||||
// User-selected theme if set.
|
||||
if theme := targetAccount.Theme; theme != "" {
|
||||
stylesheets = append(
|
||||
stylesheets,
|
||||
themesPathPrefix+"/"+theme,
|
||||
)
|
||||
}
|
||||
|
||||
// Custom CSS for this user last in cascade.
|
||||
stylesheets = append(
|
||||
stylesheets,
|
||||
"/@"+targetAccount.Username+"/custom.css",
|
||||
)
|
||||
|
||||
page := apiutil.WebPage{
|
||||
Template: "profile.tmpl",
|
||||
Instance: instance,
|
||||
OGMeta: apiutil.OGBase(instance).WithAccount(targetAccount),
|
||||
Stylesheets: []string{
|
||||
cssFA, cssStatus, cssThread, cssProfile,
|
||||
// Custom CSS for this user last in cascade.
|
||||
"/@" + targetAccount.Username + "/custom.css",
|
||||
},
|
||||
Javascript: []string{jsFrontend},
|
||||
Template: "profile.tmpl",
|
||||
Instance: instance,
|
||||
OGMeta: apiutil.OGBase(instance).WithAccount(targetAccount),
|
||||
Stylesheets: stylesheets,
|
||||
Javascript: []string{jsFrontend},
|
||||
Extra: map[string]any{
|
||||
"account": targetAccount,
|
||||
"rssFeed": rssFeed,
|
||||
|
|
|
@ -138,16 +138,39 @@ func (m *Module) threadGETHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Prepare stylesheets for thread.
|
||||
stylesheets := make([]string, 0, 5)
|
||||
|
||||
// Basic thread stylesheets.
|
||||
stylesheets = append(
|
||||
stylesheets,
|
||||
[]string{
|
||||
cssFA,
|
||||
cssStatus,
|
||||
cssThread,
|
||||
}...,
|
||||
)
|
||||
|
||||
// User-selected theme if set.
|
||||
if theme := targetAccount.Theme; theme != "" {
|
||||
stylesheets = append(
|
||||
stylesheets,
|
||||
themesPathPrefix+"/"+theme,
|
||||
)
|
||||
}
|
||||
|
||||
// Custom CSS for this user last in cascade.
|
||||
stylesheets = append(
|
||||
stylesheets,
|
||||
"/@"+targetAccount.Username+"/custom.css",
|
||||
)
|
||||
|
||||
page := apiutil.WebPage{
|
||||
Template: "thread.tmpl",
|
||||
Instance: instance,
|
||||
OGMeta: apiutil.OGBase(instance).WithStatus(status),
|
||||
Stylesheets: []string{
|
||||
cssFA, cssStatus, cssThread,
|
||||
// Custom CSS for this user last in cascade.
|
||||
"/@" + targetUsername + "/custom.css",
|
||||
},
|
||||
Javascript: []string{jsFrontend},
|
||||
Template: "thread.tmpl",
|
||||
Instance: instance,
|
||||
OGMeta: apiutil.OGBase(instance).WithStatus(status),
|
||||
Stylesheets: stylesheets,
|
||||
Javascript: []string{jsFrontend},
|
||||
Extra: map[string]any{
|
||||
"status": status,
|
||||
"context": context,
|
||||
|
|
|
@ -44,6 +44,7 @@ const (
|
|||
rssFeedPath = profileGroupPath + "/feed.rss"
|
||||
assetsPathPrefix = "/assets"
|
||||
distPathPrefix = assetsPathPrefix + "/dist"
|
||||
themesPathPrefix = assetsPathPrefix + "/themes"
|
||||
settingsPathPrefix = "/settings"
|
||||
settingsPanelGlob = settingsPathPrefix + "/*panel"
|
||||
userPanelPath = settingsPathPrefix + "/user"
|
||||
|
|
|
@ -121,6 +121,7 @@ nav:
|
|||
- "admin/media_caching.md"
|
||||
- "admin/spam.md"
|
||||
- "admin/database_maintenance.md"
|
||||
- "admin/themes.md"
|
||||
- "Federation":
|
||||
- "federation/index.md"
|
||||
- "federation/glossary.md"
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
theme-title: Blurple (dark)
|
||||
theme-description: Official dark blurple theme
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Define our nice blurple palette */
|
||||
--blurple1: #ffffff;
|
||||
--blurple2: #ebe6f8;
|
||||
--blurple3: #d6cceb;
|
||||
--blurple4: #c2b3e1;
|
||||
--blurple5: #ad99d7;
|
||||
--blurple6: #9980cd;
|
||||
--blurple7: #8566c2;
|
||||
--blurple8: #704db8;
|
||||
--blurple9: #5c33ae;
|
||||
--blurple10: #471aa4;
|
||||
--blurple11: #33009a;
|
||||
--blurple12: #170044;
|
||||
|
||||
/* Restyle basic colors to use blurple */
|
||||
--blue1: var(--blurple1);
|
||||
--blue2: var(--blurple2);
|
||||
--blue3: var(--blurple3);
|
||||
|
||||
/* Basic page styling (background + foreground) */
|
||||
--bg: var(--blurple12);
|
||||
--bg-accent: var(--blurple11);
|
||||
--fg: var(--blurple1);
|
||||
--fg-reduced: var(--blurple3);
|
||||
|
||||
/* Profile page styling (light) */
|
||||
--profile-bg: var(--blurple11);
|
||||
|
||||
/* Blurpleize buttons */
|
||||
--button-bg: var(--blurple2);
|
||||
--button-fg: var(--blurple11);
|
||||
|
||||
/* Blurpleize statuses */
|
||||
--status-bg: var(--blurple11);
|
||||
--status-focus-bg: var(--blurple11);
|
||||
--status-info-bg: var(--blurple9);
|
||||
--status-focus-info-bg: var(--blurple9);
|
||||
|
||||
/* Used around statuses + other items */
|
||||
--boxshadow-border: 0.08rem solid black;
|
||||
}
|
||||
|
||||
/* Scroll bar */
|
||||
html, body {
|
||||
scrollbar-color: var(--blurple8) var(--blurple12);
|
||||
}
|
||||
|
||||
/* Profile fields */
|
||||
.profile .about-user .fields .field {
|
||||
border-bottom: 0.1rem solid var(--blurple8);
|
||||
}
|
||||
.profile .about-user .fields .field:first-child {
|
||||
border-top: 0.1rem solid var(--blurple8);
|
||||
}
|
||||
|
||||
/* Status media */
|
||||
.status .media .media-wrapper {
|
||||
border: 0.08rem solid var(--blurple9);
|
||||
}
|
||||
.status .media .media-wrapper details .unknown-attachment .placeholder {
|
||||
color: var(--blue2);
|
||||
}
|
||||
.status .media .media-wrapper details video.plyr-video {
|
||||
background: var(--blurple11);
|
||||
}
|
||||
|
||||
/* Status polls */
|
||||
.status .text .poll {
|
||||
background-color: var(--bg);
|
||||
}
|
||||
.status .text .poll .poll-info {
|
||||
background-color: var(--blurple11);
|
||||
}
|
||||
|
||||
/* Code snippets */
|
||||
pre, pre[class*="language-"],
|
||||
code, code[class*="language-"] {
|
||||
background-color: var(--blurple12);
|
||||
color: var(--fg-reduced);
|
||||
}
|
||||
|
||||
/* Block quotes */
|
||||
blockquote {
|
||||
background-color: var(--blurple12);
|
||||
color: var(--fg-reduced);
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
theme-title: Blurple (light)
|
||||
theme-description: Official light blurple theme
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Define our nice blurple palette */
|
||||
--blurple1: #ffffff;
|
||||
--blurple2: #ebe6f8;
|
||||
--blurple3: #d6cceb;
|
||||
--blurple4: #c2b3e1;
|
||||
--blurple5: #ad99d7;
|
||||
--blurple6: #9980cd;
|
||||
--blurple7: #8566c2;
|
||||
--blurple8: #704db8;
|
||||
--blurple9: #5c33ae;
|
||||
--blurple10: #471aa4;
|
||||
--blurple11: #33009a;
|
||||
--blurple12: #170044;
|
||||
|
||||
/* Restyle basic colors to use blurple */
|
||||
--white1: var(--blurple2);
|
||||
--white2: var(--blurple3);
|
||||
--blue1: var(--blurple6);
|
||||
--blue2: var(--blurple8);
|
||||
--blue3: var(--blurple10);
|
||||
|
||||
/* Basic page styling (background + foreground) */
|
||||
--bg: linear-gradient(var(--blurple2), var(--blurple1));
|
||||
--bg-accent: var(--white2);
|
||||
--fg: var(--gray1);
|
||||
--fg-reduced: var(--gray2);
|
||||
|
||||
/* Profile page styling (light) */
|
||||
--profile-bg: var(--white2);
|
||||
|
||||
/* Blurpleize buttons */
|
||||
--button-bg: var(--blue2);
|
||||
--button-fg: var(--white1);
|
||||
|
||||
/* Blurpleize statuses */
|
||||
--status-bg: var(--white1);
|
||||
--status-focus-bg: var(--white1);
|
||||
--status-info-bg: var(--white2);
|
||||
--status-focus-info-bg: var(--white2);
|
||||
|
||||
/* Used around statuses + other items */
|
||||
--boxshadow-border: 0.08rem solid var(--blurple10);
|
||||
}
|
||||
|
||||
/* Scroll bar */
|
||||
html, body {
|
||||
scrollbar-color: var(--blurple8) var(--blurple2);
|
||||
}
|
||||
|
||||
/* Profile fields */
|
||||
.profile .about-user .fields .field {
|
||||
border-bottom: 0.1rem solid var(--blurple10);
|
||||
}
|
||||
.profile .about-user .fields .field:first-child {
|
||||
border-top: 0.1rem solid var(--blurple10);
|
||||
}
|
||||
|
||||
/* Status media */
|
||||
.status .media .media-wrapper {
|
||||
border: 0.08rem solid var(--blurple10);
|
||||
}
|
||||
.status .media .media-wrapper details .unknown-attachment .placeholder {
|
||||
color: var(--blue2);
|
||||
}
|
||||
.status .media .media-wrapper details video.plyr-video {
|
||||
background: var(--blurple2);
|
||||
}
|
||||
|
||||
/* Status polls */
|
||||
.status .text .poll {
|
||||
background-color: var(--white2);
|
||||
}
|
||||
.status .text .poll .poll-info {
|
||||
background-color: var(--white1);
|
||||
}
|
||||
|
||||
/* Code snippets */
|
||||
pre, pre[class*="language-"],
|
||||
code, code[class*="language-"] {
|
||||
background-color: var(--blurple12);
|
||||
color: var(--blurple2);
|
||||
}
|
||||
|
||||
/* Block quotes */
|
||||
blockquote {
|
||||
background-color: var(--blurple1);
|
||||
color: var(--blurple12);
|
||||
}
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
theme-title: Midnight Trip
|
||||
theme-description: Woah
|
||||
*/
|
||||
|
||||
/* Theme colors */
|
||||
:root {
|
||||
--acid-green: rgb(63, 255, 0);
|
||||
--acid-green-light: #79FF4D;
|
||||
--acid-green-dark: #269900;
|
||||
--magenta: rgb(153, 50, 204);
|
||||
--darkred: rgb(58, 0, 15);
|
||||
--darkblue: rgb(0, 0, 58);
|
||||
--darkmagenta: rgb(47, 1, 65);
|
||||
|
||||
/* Override */
|
||||
--orange2: var(--acid-green);
|
||||
--gray1: rgb(20, 21, 23);
|
||||
--blue1: var(--acid-green-dark);
|
||||
--blue2: var(--acid-green-light);
|
||||
--blue3: var(--acid-green);
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(-45deg, black, var(--darkmagenta), var(--darkblue), var(--darkred));
|
||||
background-size: 400% 400%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media not (prefers-reduced-motion) {
|
||||
body {
|
||||
animation: gradient 30s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
/* Funky scroll bar */
|
||||
scrollbar-color: var(--acid-green) var(--gray1);
|
||||
}
|
||||
|
||||
/* Instance display name */
|
||||
.page-header {
|
||||
grid-column: 2;
|
||||
align-self: start;
|
||||
margin: 1rem 0 1rem 0;
|
||||
background-color: var(--gray1);
|
||||
border: 0.25rem solid var(--magenta);
|
||||
border-radius: var(--br);
|
||||
}
|
||||
|
||||
/* Header card */
|
||||
.profile .profile-header {
|
||||
background-color: var(--gray1);
|
||||
border: 0.25rem solid var(--magenta);
|
||||
}
|
||||
|
||||
/* About + Pinned posts headers */
|
||||
.profile .col-header {
|
||||
background: var(--gray1);
|
||||
border: 0.25rem solid var(--magenta);
|
||||
}
|
||||
|
||||
.profile .about-user .col-header {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Make about sections transparent */
|
||||
.profile .about-user .fields, .profile .about-user .bio, .profile .about-user .accountstats {
|
||||
background: var(--gray1);
|
||||
border-left: 0.25rem solid var(--magenta);
|
||||
border-right: 0.25rem solid var(--magenta);
|
||||
}
|
||||
|
||||
/* Fiddle around with borders on about sections */
|
||||
.profile .about-user .fields .field:first-child {
|
||||
border-top: 0.25rem dashed var(--magenta);
|
||||
}
|
||||
.profile .about-user .fields .field {
|
||||
border-bottom: 0.25rem dashed var(--magenta);
|
||||
}
|
||||
.profile .about-user .accountstats {
|
||||
border-top: 0.25rem dashed var(--magenta);
|
||||
border-bottom: 0.25rem solid var(--magenta);
|
||||
}
|
||||
|
||||
/* Statuses + threads */
|
||||
|
||||
/* Thread column header */
|
||||
.thread .col-header {
|
||||
background: var(--gray1);
|
||||
border: 0.25rem solid var(--magenta);
|
||||
}
|
||||
|
||||
/* Main status body */
|
||||
.status, .status.expanded {
|
||||
background: var(--gray1);
|
||||
border: 0.25rem solid var(--magenta);
|
||||
}
|
||||
|
||||
/* Code snippets */
|
||||
.status .text .content pre, .status .text .content code {
|
||||
background: black;
|
||||
color: var(--white2);
|
||||
}
|
||||
|
||||
/* Block quotes */
|
||||
.status .text .content blockquote {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
/* Media wrapper for attachments */
|
||||
.status .media .media-wrapper {
|
||||
background: var(--bg-nearly-opaque);
|
||||
}
|
||||
.status .media .media-wrapper details .unknown-attachment .placeholder {
|
||||
border: 0.2rem dashed var(--magenta);
|
||||
}
|
||||
|
||||
/* Polls */
|
||||
.status .text .poll {
|
||||
background-color: black;
|
||||
border: 0.25rem solid var(--magenta);
|
||||
}
|
||||
|
||||
.status .text .poll .poll-info {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
/* Status info bars */
|
||||
.status .status-info, .status.expanded .status-info {
|
||||
background: black;
|
||||
}
|
||||
|
||||
/* Back + next links */
|
||||
.backnextlinks {
|
||||
background: var(--gray1);
|
||||
padding: 0.5rem;
|
||||
border: 0.25rem solid var(--magenta);
|
||||
border-radius: var(--br);
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
margin-top: 2rem;
|
||||
background-color: var(--gray1);
|
||||
border-top: 0.25rem solid var(--magenta);
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
theme-title: Soft
|
||||
theme-description: Pastel pink and blue with dark magenta trim
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Define our palette */
|
||||
--soft-pink: rgb(255, 199, 234);
|
||||
--soft-pink-translucent: rgb(255, 199, 234, 30%);
|
||||
--soft-lilac: #D8B4F8;
|
||||
--soft-lilac-translucent: rgb(216, 180, 248, 30%);
|
||||
--soft-blue: #d6f1ff;
|
||||
|
||||
/* Override */
|
||||
--blue1: #7f16de;
|
||||
--blue2: #7514cc;
|
||||
--blue3: #6b12ba;
|
||||
--orange2: var(--blue1);
|
||||
--br: 0.8rem;
|
||||
--br-inner: 0.4rem;
|
||||
|
||||
/* Basic page styling (background + foreground) */
|
||||
--bg: linear-gradient(-90deg, var(--soft-blue), var(--soft-pink), white, var(--soft-pink), var(--soft-blue));
|
||||
--bg-accent: var(--soft-pink-translucent);
|
||||
--fg: var(--gray1);
|
||||
--fg-reduced: var(--gray3);
|
||||
|
||||
/* Profile page styling (light) */
|
||||
--profile-bg: var(--soft-pink-translucent);
|
||||
|
||||
/* Statuses */
|
||||
--status-bg: var(--soft-pink-translucent);
|
||||
--status-focus-bg: var(--soft-pink-translucent);
|
||||
--status-info-bg: var(--soft-lilac-translucent);
|
||||
--status-focus-info-bg: var(--soft-lilac-translucent);
|
||||
|
||||
/* Boot-on */
|
||||
--button-fg: var(--white1);
|
||||
|
||||
/* Used around statuses + other items */
|
||||
--boxshadow-border: 0.08rem solid var(--gray8);
|
||||
}
|
||||
|
||||
/* Scroll bar */
|
||||
html, body {
|
||||
scrollbar-color: var(--orange2) var(--soft-pink);
|
||||
}
|
||||
|
||||
/* Header card */
|
||||
.profile .profile-header {
|
||||
border: var(--boxshadow-border);
|
||||
}
|
||||
|
||||
.profile .profile-header .basic-info .namerole .role {
|
||||
border: var(--boxshadow-border);
|
||||
}
|
||||
|
||||
/* About + Pinned posts headers */
|
||||
.profile .col-header {
|
||||
border: var(--boxshadow-border);
|
||||
}
|
||||
|
||||
.profile .about-user .col-header {
|
||||
margin-bottom: initial;
|
||||
border-bottom: none;
|
||||
border-top: var(--boxshadow-border);
|
||||
border-left: var(--boxshadow-border);
|
||||
border-right: var(--boxshadow-border);
|
||||
}
|
||||
|
||||
/* Profile fields + bio */
|
||||
.profile .about-user .fields {
|
||||
border-left: var(--boxshadow-border);
|
||||
border-right: var(--boxshadow-border);
|
||||
}
|
||||
.profile .about-user .fields .field {
|
||||
border-bottom: 0.1rem dashed var(--blue3);
|
||||
}
|
||||
.profile .about-user .fields .field:first-child {
|
||||
border-top: 0.1rem dashed var(--blue3);
|
||||
}
|
||||
.profile .about-user .bio {
|
||||
border-left: var(--boxshadow-border);
|
||||
border-right: var(--boxshadow-border);
|
||||
}
|
||||
.profile .about-user .accountstats {
|
||||
background: var(--soft-lilac-translucent);
|
||||
border-bottom: var(--boxshadow-border);
|
||||
border-left: var(--boxshadow-border);
|
||||
border-right: var(--boxshadow-border);
|
||||
border-bottom-left-radius: var(--br);
|
||||
border-bottom-right-radius: var(--br);
|
||||
}
|
||||
|
||||
/* Status media */
|
||||
.status .media .media-wrapper {
|
||||
border: 0.08rem solid var(--blue3);
|
||||
}
|
||||
.status .media .media-wrapper details .unknown-attachment .placeholder {
|
||||
color: var(--blue2);
|
||||
}
|
||||
.status .media .media-wrapper details video.plyr-video {
|
||||
background: var(--soft-pink-translucent);
|
||||
}
|
||||
|
||||
/* Status polls */
|
||||
.status .text .poll {
|
||||
background-color: var(--soft-lilac-translucent);
|
||||
}
|
||||
.status .text .poll .poll-info {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* Code snippets */
|
||||
pre, pre[class*="language-"],
|
||||
code, code[class*="language-"] {
|
||||
background-color: var(--gray1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Block quotes */
|
||||
blockquote {
|
||||
background-color: var(--soft-lilac-translucent);
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
theme-title: Sunset (light)
|
||||
theme-description: Official light orange/yellow theme.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Define our palette */
|
||||
--eggshell: #fff6eb;
|
||||
--yellow: #FFAF45;
|
||||
--orange: #FB6D48;
|
||||
--pink: #D74B76;
|
||||
--eggplant1: #5c385e;
|
||||
--eggplant2: #523254;
|
||||
--eggplant3: #482c49;
|
||||
--eggplant4: #29192a;
|
||||
|
||||
/* Restyle basic colors */
|
||||
--white1: var(--eggshell);
|
||||
--white2: var(--yellow);
|
||||
--blue1: var(--eggplant1);
|
||||
--blue2: var(--eggplant2);
|
||||
--blue3: var(--eggplant3);
|
||||
--orange2: var(--pink);
|
||||
|
||||
/* Basic page styling (background + foreground) */
|
||||
--bg: linear-gradient(var(--eggplant1), var(--pink), var(--orange), var(--yellow), var(--eggshell));
|
||||
--bg-accent: var(--white2);
|
||||
--fg: var(--eggplant4);
|
||||
--fg-reduced: var(--eggplant3);
|
||||
|
||||
/* Profile page styling (light) */
|
||||
--profile-bg: var(--white2);
|
||||
|
||||
/* Buttons */
|
||||
--button-bg: var(--blue2);
|
||||
--button-fg: var(--white1);
|
||||
|
||||
/* Statuses */
|
||||
--status-bg: var(--white1);
|
||||
--status-focus-bg: var(--white1);
|
||||
--status-info-bg: var(--white2);
|
||||
--status-focus-info-bg: var(--white2);
|
||||
|
||||
/* Used around statuses + other items */
|
||||
--boxshadow-border: 0.08rem solid var(--orange);
|
||||
}
|
||||
|
||||
/* Scroll bar */
|
||||
html, body {
|
||||
scrollbar-color: var(--pink) var(--eggshell);
|
||||
}
|
||||
|
||||
.page-header a h1 {
|
||||
color: var(--eggshell);
|
||||
}
|
||||
|
||||
/* Profile fields */
|
||||
.profile .about-user .fields .field {
|
||||
border-bottom: 0.1rem solid var(--orange);
|
||||
}
|
||||
.profile .about-user .fields .field:first-child {
|
||||
border-top: 0.1rem solid var(--orange);
|
||||
}
|
||||
|
||||
/* Status media */
|
||||
.status .media .media-wrapper {
|
||||
border: 0.08rem solid var(--orange);
|
||||
}
|
||||
.status .media .media-wrapper details .unknown-attachment .placeholder {
|
||||
color: var(--blue2);
|
||||
}
|
||||
.status .media .media-wrapper details video.plyr-video {
|
||||
background: var(--eggshell);
|
||||
}
|
||||
|
||||
/* Status polls */
|
||||
.status .text .poll {
|
||||
background-color: var(--white2);
|
||||
}
|
||||
.status .text .poll .poll-info {
|
||||
background-color: var(--white1);
|
||||
}
|
||||
|
||||
/* Code snippets */
|
||||
pre, pre[class*="language-"],
|
||||
code, code[class*="language-"] {
|
||||
background-color: var(--eggplant4);
|
||||
color: var(--white1);
|
||||
}
|
||||
|
||||
/* Block quotes */
|
||||
blockquote {
|
||||
background-color: var(--yellow);
|
||||
color: var(--eggplant4);
|
||||
}
|
|
@ -23,6 +23,7 @@ import type {
|
|||
MoveAccountFormData,
|
||||
UpdateAliasesFormData
|
||||
} from "../../types/migration";
|
||||
import type { Theme } from "../../types/theme";
|
||||
|
||||
const extended = gtsApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
|
@ -66,6 +67,11 @@ const extended = gtsApi.injectEndpoints({
|
|||
url: `/api/v1/accounts/move`,
|
||||
body: data
|
||||
})
|
||||
}),
|
||||
accountThemes: build.query<Theme[], void>({
|
||||
query: () => ({
|
||||
url: `/api/v1/accounts/themes`
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
|
@ -75,4 +81,5 @@ export const {
|
|||
usePasswordChangeMutation,
|
||||
useAliasAccountMutation,
|
||||
useMoveAccountMutation,
|
||||
useAccountThemesQuery,
|
||||
} = extended;
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface Theme {
|
||||
title: string;
|
||||
description: string;
|
||||
file_name: string;
|
||||
}
|
|
@ -439,7 +439,7 @@ section.with-sidebar > div, section.with-sidebar > form {
|
|||
display: grid;
|
||||
max-width: 60rem;
|
||||
grid-template-columns: 70% 30%;
|
||||
grid-template-rows: 100%;
|
||||
grid-template-rows: auto;
|
||||
gap: 1rem;
|
||||
|
||||
.files {
|
||||
|
@ -465,6 +465,12 @@ section.with-sidebar > div, section.with-sidebar > form {
|
|||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.theme, .form-field.radio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.migration-details {
|
||||
|
|
|
@ -23,7 +23,8 @@ import {
|
|||
useTextInput,
|
||||
useFileInput,
|
||||
useBoolInput,
|
||||
useFieldArrayInput
|
||||
useFieldArrayInput,
|
||||
useRadioInput
|
||||
} from "../lib/form";
|
||||
|
||||
import useFormSubmit from "../lib/form/submit";
|
||||
|
@ -33,14 +34,15 @@ import {
|
|||
TextInput,
|
||||
TextArea,
|
||||
FileInput,
|
||||
Checkbox
|
||||
Checkbox,
|
||||
RadioGroup
|
||||
} from "../components/form/inputs";
|
||||
|
||||
import FormWithData from "../lib/form/form-with-data";
|
||||
import FakeProfile from "../components/fake-profile";
|
||||
import MutationButton from "../components/form/mutation-button";
|
||||
|
||||
import { useInstanceV1Query } from "../lib/query";
|
||||
import { useAccountThemesQuery, useInstanceV1Query } from "../lib/query";
|
||||
import { useUpdateCredentialsMutation } from "../lib/query/user";
|
||||
import { useVerifyCredentialsQuery } from "../lib/query/oauth";
|
||||
|
||||
|
@ -64,6 +66,7 @@ function UserProfileForm({ data: profile }) {
|
|||
- file header
|
||||
- bool enable_rss
|
||||
- string custom_css (if enabled)
|
||||
- string theme
|
||||
*/
|
||||
|
||||
const { data: instance } = useInstanceV1Query();
|
||||
|
@ -73,13 +76,24 @@ function UserProfileForm({ data: profile }) {
|
|||
maxPinnedFields: instance?.configuration?.accounts?.max_profile_fields ?? 6
|
||||
};
|
||||
}, [instance]);
|
||||
|
||||
// Parse out available theme options into nice format.
|
||||
const { data: themes } = useAccountThemesQuery();
|
||||
let themeOptions = { "": "Default" };
|
||||
themes?.forEach((theme) => {
|
||||
let key = theme.file_name;
|
||||
let value = theme.title;
|
||||
if (theme.description) {
|
||||
value += " - " + theme.description;
|
||||
}
|
||||
themeOptions[key] = value;
|
||||
});
|
||||
|
||||
const form = {
|
||||
avatar: useFileInput("avatar", { withPreview: true }),
|
||||
header: useFileInput("header", { withPreview: true }),
|
||||
displayName: useTextInput("display_name", { source: profile }),
|
||||
note: useTextInput("note", { source: profile, valueSelector: (p) => p.source?.note }),
|
||||
customCSS: useTextInput("custom_css", { source: profile, nosubmit: !instanceConfig.allowCustomCSS }),
|
||||
bot: useBoolInput("bot", { source: profile }),
|
||||
locked: useBoolInput("locked", { source: profile }),
|
||||
discoverable: useBoolInput("discoverable", { source: profile}),
|
||||
|
@ -88,6 +102,11 @@ function UserProfileForm({ data: profile }) {
|
|||
defaultValue: profile?.source?.fields,
|
||||
length: instanceConfig.maxPinnedFields
|
||||
}),
|
||||
customCSS: useTextInput("custom_css", { source: profile, nosubmit: !instanceConfig.allowCustomCSS }),
|
||||
theme: useRadioInput("theme", {
|
||||
source: profile,
|
||||
options: themeOptions,
|
||||
}),
|
||||
};
|
||||
|
||||
const [submitForm, result] = useFormSubmit(form, useUpdateCredentialsMutation(), {
|
||||
|
@ -125,6 +144,18 @@ function UserProfileForm({ data: profile }) {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme">
|
||||
<div>
|
||||
<b id="theme-label">Theme</b>
|
||||
<br/>
|
||||
<span>After choosing theme and saving, <a href={profile.url} target="_blank">open your profile</a> and refresh to see changes.</span>
|
||||
</div>
|
||||
<RadioGroup
|
||||
aria-labelledby="theme-label"
|
||||
field={form.theme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section-docs">
|
||||
|
|
Loading…
Reference in New Issue