[feature] User-selectable preset themes

This commit is contained in:
tobi 2024-03-21 19:25:42 +01:00
parent 7f4a0a1aeb
commit 13b4e191a6
24 changed files with 736 additions and 25 deletions

View file

@ -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:

View file

@ -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)
}

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,89 @@
// 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"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
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.
gtsThemes := m.processor.Account().ThemesGet()
// Convert themes to apimodel.
themes := make([]apimodel.Theme, len(gtsThemes.SortedByTitle))
for i, gtsTheme := range gtsThemes.SortedByTitle {
themes[i] = apimodel.Theme{
Title: gtsTheme.Title,
Description: gtsTheme.Description,
FileName: gtsTheme.FileName,
}
}
apiutil.JSON(c, http.StatusOK, themes)
}

View file

@ -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"`

View file

@ -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"`
}

View file

@ -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)
}
}

View file

@ -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.

View file

@ -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(),
}
}

View file

@ -0,0 +1,163 @@
// 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"
"github.com/superseriousbusiness/gotosocial/internal/config"
"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() *Themes {
return p.themes
}
// 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
}
// Themes represents an in-memory
// storage structure for themes.
type Themes struct {
// Themes sorted alphabetically
// by title (case insensitive).
SortedByTitle []*Theme
// ByFileName contains themes retrievable
// by their filename eg., `light-blurple.css`.
ByFileName map[string]*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]*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 := &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 *Theme) int {
return cmp.Compare(strings.ToLower(a.Title), strings.ToLower(b.Title))
})
return themes
}

View file

@ -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("Light blurple theme", theme.Description)
suite.Equal("blurple-light.css", theme.FileName)
}
func TestThemesTestSuite(t *testing.T) {
suite.Run(t, new(ThemesTestSuite))
}

View file

@ -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 {

View file

@ -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,

View file

@ -140,16 +140,31 @@ func (m *Module) profileGETHandler(c *gin.Context) {
return
}
// Basic profile stylesheets.
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,

View file

@ -138,16 +138,31 @@ func (m *Module) threadGETHandler(c *gin.Context) {
return
}
// Basic thread stylesheets.
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,

View file

@ -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"

View file

@ -0,0 +1,71 @@
/*
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: linear-gradient(var(--blurple12), black);
--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;
}
/* 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 polls */
.status .text .poll {
background-color: var(--blurple12);
}
.status .text .poll .poll-info {
background-color: var(--blurple11);
}

View file

@ -0,0 +1,72 @@
/*
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;
/* Restyle basic colors to use blurple */
--white1: var(--blurple1);
--white2: var(--blurple2);
--blue1: var(--blurple4);
--blue2: var(--blurple6);
--blue3: var(--blurple8);
/* Basic page styling (background + foreground) */
--bg: var(--white1);
--bg-accent: var(--white2);
--fg: var(--gray2);
--fg-reduced: var(--gray1);
/* 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);
}
/* 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 polls */
.status .text .poll {
background-color: var(--white2);
}
.status .text .poll .poll-info {
background-color: var(--white1);
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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 {

View file

@ -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,13 @@ function UserProfileForm({ data: profile }) {
/>
</div>
</div>
<label className="theme">
<b>Profile Theme</b>
<RadioGroup
field={form.theme}
/>
</label>
</div>
<div className="form-section-docs">