Compare commits

...

8 Commits

Author SHA1 Message Date
Tyrone Yeh 7f94005fb9
Merge 64cffdd62e into 2f6b1c46a1 2024-04-23 21:18:44 +02:00
sillyguodong 2f6b1c46a1
Interpolate runs-on with variables when scheduling tasks (#30640)
Follow #29468
1. Interpolate runs-on with variables when scheduling tasks.
2. The `GetVariablesOfRun` function will check if the `Repo` of the run
is nil.

---------

Co-authored-by: Giteabot <teabot@gitea.io>
2024-04-23 20:55:25 +02:00
wxiaoguang b79e3db264
Initial support for colorblindness-friendly themes (#30625)
Initial support for #25680

This PR only adds some simple styles from GitHub, it is big enough and
it focuses on adding the necessary framework-level supports. More styles
could be fine-tuned later.
2024-04-24 00:18:41 +08:00
Tyrone Yeh 64cffdd62e
Change extName to use utils.js extname 2024-04-18 09:49:32 +08:00
Tyrone Yeh b7828ad174
Update web_src/js/features/comp/Paste.js
Co-authored-by: silverwind <me@silverwind.io>
2024-04-18 09:36:36 +08:00
Tyrone Yeh 5af777b5c9
Fix cannot to paste text file 2024-04-16 15:43:32 +08:00
Tyrone Yeh 48cf2a5127
Fix js error in edit issue and improve image type check 2024-04-16 12:05:13 +08:00
Tyrone Yeh ce5745233c
Improve attachment upload methods 2024-04-16 11:28:30 +08:00
32 changed files with 259 additions and 151 deletions

View File

@ -1231,7 +1231,8 @@ LEVEL = Info
;DEFAULT_THEME = gitea-auto
;;
;; All available themes. Allow users select personalized themes regardless of the value of `DEFAULT_THEME`.
;THEMES = gitea-auto,gitea-light,gitea-dark
;; Leave it empty to allow users to select any theme from "{CustomPath}/public/assets/css/theme-*.css"
;THEMES =
;;
;; All available reactions users can choose on issues/prs and comments.
;; Values can be emoji alias (:smile:) or a unicode emoji.

View File

@ -214,10 +214,9 @@ The following configuration set `Content-Type: application/vnd.android.package-a
- `SITEMAP_PAGING_NUM`: **20**: Number of items that are displayed in a single subsitemap.
- `GRAPH_MAX_COMMIT_NUM`: **100**: Number of maximum commits shown in the commit graph.
- `CODE_COMMENT_LINES`: **4**: Number of line of codes shown for a code comment.
- `DEFAULT_THEME`: **gitea-auto**: \[gitea-auto, gitea-light, gitea-dark\]: Set the default theme for the Gitea installation.
- `DEFAULT_THEME`: **gitea-auto**: Set the default theme for the Gitea installation, custom themes could be provided by "{CustomPath}/public/assets/css/theme-*.css".
- `SHOW_USER_EMAIL`: **true**: Whether the email of the user should be shown in the Explore Users page.
- `THEMES`: **gitea-auto,gitea-light,gitea-dark**: All available themes. Allow users select personalized themes.
regardless of the value of `DEFAULT_THEME`.
- `THEMES`: **_empty_**: All available themes by "{CustomPath}/public/assets/css/theme-*.css". Allow users select personalized themes.
- `MAX_DISPLAY_FILE_SIZE`: **8388608**: Max size of files to be displayed (default is 8MiB)
- `AMBIGUOUS_UNICODE_DETECTION`: **true**: Detect ambiguous unicode characters in file contents and show warnings on the UI
- `REACTIONS`: All available reactions users can choose on issues/prs and comments

View File

@ -212,10 +212,9 @@ menu:
- `SITEMAP_PAGING_NUM`: **20**: 在单个子SiteMap中显示的项数。
- `GRAPH_MAX_COMMIT_NUM`: **100**: 提交图中显示的最大commit数量。
- `CODE_COMMENT_LINES`: **4**: 在代码评论中能够显示的最大代码行数。
- `DEFAULT_THEME`: **gitea-auto**: \[gitea-auto, gitea-light, gitea-dark\]: 在Gitea安装时候设置的默认主题。
- `DEFAULT_THEME`: **gitea-auto**: 在Gitea安装时候设置的默认主题,自定义的主题可以通过 "{CustomPath}/public/assets/css/theme-*.css" 提供
- `SHOW_USER_EMAIL`: **true**: 用户的电子邮件是否应该显示在`Explore Users`页面中。
- `THEMES`: **gitea-auto,gitea-light,gitea-dark**: 所有可用的主题。允许用户选择个性化的主题,
而不受DEFAULT_THEME 值的影响。
- `THEMES`: **_empty_**: 所有可用的主题(由 "{CustomPath}/public/assets/css/theme-*.css" 提供)。允许用户选择个性化的主题,
- `MAX_DISPLAY_FILE_SIZE`: **8388608**: 能够显示文件的最大大小默认为8MiB
- `REACTIONS`: 用户可以在问题Issue、Pull RequestPR以及评论中选择的所有可选的反应。
这些值可以是表情符号别名(例如::smile:或Unicode表情符号。

View File

@ -381,7 +381,7 @@ To make a custom theme available to all users:
1. Add a CSS file to `$GITEA_CUSTOM/public/assets/css/theme-<theme-name>.css`.
The value of `$GITEA_CUSTOM` of your instance can be queried by calling `gitea help` and looking up the value of "CustomPath".
2. Add `<theme-name>` to the comma-separated list of setting `THEMES` in `app.ini`
2. Add `<theme-name>` to the comma-separated list of setting `THEMES` in `app.ini`, or leave `THEMES` empty to allow all themes.
Community themes are listed in [gitea/awesome-gitea#themes](https://gitea.com/gitea/awesome-gitea#themes).

View File

@ -178,17 +178,6 @@ At some point, a customer or third party needs access to a specific repo and onl
Use [Fail2Ban](administration/fail2ban-setup.md) to monitor and stop automated login attempts or other malicious behavior based on log patterns
## How to add/use custom themes
Gitea supports three official themes right now, `gitea-light`, `gitea-dark`, and `gitea-auto` (automatically switches between the previous two depending on operating system settings).
To add your own theme, currently the only way is to provide a complete theme (not just color overrides)
As an example, let's say our theme is `arc-blue` (this is a real theme, and can be found [in this issue](https://github.com/go-gitea/gitea/issues/6011))
Name the `.css` file `theme-arc-blue.css` and add it to your custom folder in `custom/public/assets/css`
Allow users to use it by adding `arc-blue` to the list of `THEMES` in your `app.ini`
## SSHD vs built-in SSH
SSHD is the built-in SSH server on most Unix systems.

View File

@ -182,17 +182,6 @@ Gitea不提供内置的Pages服务器。您需要一个专用的域名来提供
使用 [Fail2Ban](administration/fail2ban-setup.md) 监视并阻止基于日志模式的自动登录尝试或其他恶意行为。
## 如何添加/使用自定义主题
Gitea 目前支持三个官方主题,分别是 `gitea-light`、`gitea-dark` 和 `gitea-auto`(根据操作系统设置自动切换前两个主题)。
要添加自己的主题,目前唯一的方法是提供一个完整的主题(不仅仅是颜色覆盖)。
假设我们的主题是 `arc-blue`(这是一个真实的主题,可以在[此问题](https://github.com/go-gitea/gitea/issues/6011)中找到)
将`.css`文件命名为`theme-arc-blue.css`并将其添加到`custom/public/assets/css`文件夹中
通过将`arc-blue`添加到`app.ini`中的`THEMES`列表中,允许用户使用该主题
## SSHD vs 内建SSH
SSHD是大多数Unix系统上内建的SSH服务器。

View File

@ -98,13 +98,10 @@ func (run *ActionRun) LoadAttributes(ctx context.Context) error {
return nil
}
if run.Repo == nil {
repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID)
if err != nil {
return err
}
run.Repo = repo
if err := run.LoadRepo(ctx); err != nil {
return err
}
if err := run.Repo.LoadAttributes(ctx); err != nil {
return err
}
@ -120,6 +117,19 @@ func (run *ActionRun) LoadAttributes(ctx context.Context) error {
return nil
}
func (run *ActionRun) LoadRepo(ctx context.Context) error {
if run == nil || run.Repo != nil {
return nil
}
repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID)
if err != nil {
return err
}
run.Repo = repo
return nil
}
func (run *ActionRun) Duration() time.Duration {
return calculateDuration(run.Started, run.Stopped, run.Status) + run.PreviousDuration
}

View File

@ -92,6 +92,11 @@ func DeleteVariable(ctx context.Context, id int64) error {
func GetVariablesOfRun(ctx context.Context, run *ActionRun) (map[string]string, error) {
variables := map[string]string{}
if err := run.LoadRepo(ctx); err != nil {
log.Error("LoadRepo: %v", err)
return nil, err
}
// Global
globalVariables, err := db.Find[ActionVariable](ctx, FindVariablesOpts{})
if err != nil {

View File

@ -318,7 +318,7 @@ func mustMapSetting(rootCfg ConfigProvider, sectionName string, setting any) {
// StartupProblems contains the messages for various startup problems, including: setting option, file/folder, etc
var StartupProblems []string
func logStartupProblem(skip int, level log.Level, format string, args ...any) {
func LogStartupProblem(skip int, level log.Level, format string, args ...any) {
msg := fmt.Sprintf(format, args...)
log.Log(skip+1, level, "%s", msg)
StartupProblems = append(StartupProblems, msg)
@ -326,14 +326,14 @@ func logStartupProblem(skip int, level log.Level, format string, args ...any) {
func deprecatedSetting(rootCfg ConfigProvider, oldSection, oldKey, newSection, newKey, version string) {
if rootCfg.Section(oldSection).HasKey(oldKey) {
logStartupProblem(1, log.ERROR, "Deprecation: config option `[%s].%s` presents, please use `[%s].%s` instead because this fallback will be/has been removed in %s", oldSection, oldKey, newSection, newKey, version)
LogStartupProblem(1, log.ERROR, "Deprecation: config option `[%s].%s` presents, please use `[%s].%s` instead because this fallback will be/has been removed in %s", oldSection, oldKey, newSection, newKey, version)
}
}
// deprecatedSettingDB add a hint that the configuration has been moved to database but still kept in app.ini
func deprecatedSettingDB(rootCfg ConfigProvider, oldSection, oldKey string) {
if rootCfg.Section(oldSection).HasKey(oldKey) {
logStartupProblem(1, log.ERROR, "Deprecation: config option `[%s].%s` presents but it won't take effect because it has been moved to admin panel -> config setting", oldSection, oldKey)
LogStartupProblem(1, log.ERROR, "Deprecation: config option `[%s].%s` presents but it won't take effect because it has been moved to admin panel -> config setting", oldSection, oldKey)
}
}

View File

@ -174,7 +174,7 @@ func GetGeneralTokenSigningSecret() []byte {
}
if generalSigningSecret.CompareAndSwap(old, &jwtSecret) {
// FIXME: in main branch, the signing token should be refactored (eg: one unique for LFS/OAuth2/etc ...)
logStartupProblem(1, log.WARN, "OAuth2 is not enabled, unable to use a persistent signing secret, a new one is generated, which is not persistent between restarts and cluster nodes")
LogStartupProblem(1, log.WARN, "OAuth2 is not enabled, unable to use a persistent signing secret, a new one is generated, which is not persistent between restarts and cluster nodes")
return jwtSecret
}
return *generalSigningSecret.Load()

View File

@ -235,7 +235,7 @@ var configuredPaths = make(map[string]string)
func checkOverlappedPath(name, path string) {
// TODO: some paths shouldn't overlap (storage.xxx.path), while some could (data path is the base path for storage path)
if targetName, ok := configuredPaths[path]; ok && targetName != name {
logStartupProblem(1, log.ERROR, "Configured path %q is used by %q and %q at the same time. The paths must be unique to prevent data loss.", path, targetName, name)
LogStartupProblem(1, log.ERROR, "Configured path %q is used by %q and %q at the same time. The paths must be unique to prevent data loss.", path, targetName, name)
}
configuredPaths[path] = name
}

View File

@ -82,7 +82,6 @@ var UI = struct {
ReactionMaxUserNum: 10,
MaxDisplayFileSize: 8388608,
DefaultTheme: `gitea-auto`,
Themes: []string{`gitea-auto`, `gitea-light`, `gitea-dark`},
Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`},
CustomEmojis: []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`},
CustomEmojisMap: map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:"},

View File

@ -22,6 +22,7 @@ import (
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/gitdiff"
"code.gitea.io/gitea/services/webtheme"
)
// NewFuncMap returns functions for injecting to templates
@ -137,12 +138,7 @@ func NewFuncMap() template.FuncMap {
"DisableImportLocal": func() bool {
return !setting.ImportLocalPaths
},
"ThemeName": func(user *user_model.User) string {
if user == nil || user.Theme == "" {
return setting.UI.DefaultTheme
}
return user.Theme
},
"UserThemeName": UserThemeName,
"NotificationSettings": func() map[string]any {
return map[string]any{
"MinTimeout": int(setting.UI.Notification.MinTimeout / time.Millisecond),
@ -261,3 +257,13 @@ func Eval(tokens ...any) (any, error) {
n, err := eval.Expr(tokens...)
return n.Value, err
}
func UserThemeName(user *user_model.User) string {
if user == nil || user.Theme == "" {
return setting.UI.DefaultTheme
}
if webtheme.IsThemeAvailable(user.Theme) {
return user.Theme
}
return setting.UI.DefaultTheme
}

View File

@ -763,6 +763,8 @@ manage_themes = Select default theme
manage_openid = Manage OpenID Addresses
email_desc = Your primary email address will be used for notifications, password recovery and, provided that it is not hidden, web-based Git operations.
theme_desc = This will be your default theme across the site.
theme_colorblindness_help = Colorblindness Theme Support
theme_colorblindness_prompt = Gitea just gets some themes with basic colorblindness support, which only have a few colors defined. The work is still in progress. More improvements could be done by defining more colors in the theme CSS files.
primary = Primary
activated = Activated
requires_activation = Requires activation

View File

@ -31,6 +31,7 @@ import (
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
user_service "code.gitea.io/gitea/services/user"
"code.gitea.io/gitea/services/webtheme"
)
const (
@ -319,6 +320,13 @@ func Appearance(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.appearance")
ctx.Data["PageIsSettingsAppearance"] = true
allThemes := webtheme.GetAvailableThemes()
if webtheme.IsThemeAvailable(setting.UI.DefaultTheme) {
allThemes = util.SliceRemoveAll(allThemes, setting.UI.DefaultTheme)
allThemes = append([]string{setting.UI.DefaultTheme}, allThemes...) // move the default theme to the top
}
ctx.Data["AllThemes"] = allThemes
var hiddenCommentTypes *big.Int
val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes)
if err != nil {
@ -341,11 +349,12 @@ func UpdateUIThemePost(ctx *context.Context) {
ctx.Data["PageIsSettingsAppearance"] = true
if ctx.HasError() {
ctx.Flash.Error(ctx.GetErrMsg())
ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
return
}
if !form.IsThemeExists() {
if !webtheme.IsThemeAvailable(form.Theme) {
ctx.Flash.Error(ctx.Tr("settings.theme_update_error"))
ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
return

View File

@ -652,7 +652,7 @@ func registerRoutes(m *web.Route) {
m.Get("", user_setting.BlockedUsers)
m.Post("", web.Bind(forms.BlockUserForm{}), user_setting.BlockedUsersPost)
})
}, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled))
}, reqSignIn, ctxDataSet("PageIsUserSettings", true, "EnablePackages", setting.Packages.Enabled))
m.Group("/user", func() {
m.Get("/activate", auth.Activate)

View File

@ -132,8 +132,14 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule)
Status: actions_model.StatusWaiting,
}
vars, err := actions_model.GetVariablesOfRun(ctx, run)
if err != nil {
log.Error("GetVariablesOfRun: %v", err)
return err
}
// Parse the workflow specification from the cron schedule
workflows, err := jobparser.Parse(cron.Content)
workflows, err := jobparser.Parse(cron.Content, jobparser.WithVars(vars))
if err != nil {
return err
}

View File

@ -230,6 +230,7 @@ func Contexter() func(next http.Handler) http.Handler {
// HasError returns true if error occurs in form validation.
// Attention: this function changes ctx.Data and ctx.Flash
// If HasError is called, then before Redirect, the error message should be stored by ctx.Flash.Error(ctx.GetErrMsg()) again.
func (ctx *Context) HasError() bool {
hasErr, ok := ctx.Data["HasError"]
if !ok {

View File

@ -11,7 +11,6 @@ import (
auth_model "code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/context"
@ -273,7 +272,7 @@ func (f *AddEmailForm) Validate(req *http.Request, errs binding.Errors) binding.
// UpdateThemeForm form for updating a users' theme
type UpdateThemeForm struct {
Theme string `binding:"Required;MaxSize(30)"`
Theme string `binding:"Required;MaxSize(255)"`
}
// Validate validates the field
@ -282,20 +281,6 @@ func (f *UpdateThemeForm) Validate(req *http.Request, errs binding.Errors) bindi
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// IsThemeExists checks if the theme is a theme available in the config.
func (f UpdateThemeForm) IsThemeExists() bool {
var exists bool
for _, v := range setting.UI.Themes {
if strings.EqualFold(v, f.Theme) {
exists = true
break
}
}
return exists
}
// ChangePasswordForm form for changing password
type ChangePasswordForm struct {
OldPassword string `form:"old_password" binding:"MaxSize(255)"`

View File

@ -0,0 +1,74 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package webtheme
import (
"sort"
"strings"
"sync"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting"
)
var (
availableThemes []string
availableThemesSet container.Set[string]
themeOnce sync.Once
)
func initThemes() {
availableThemes = nil
defer func() {
availableThemesSet = container.SetOf(availableThemes...)
if !availableThemesSet.Contains(setting.UI.DefaultTheme) {
setting.LogStartupProblem(1, log.ERROR, "Default theme %q is not available, please correct the '[ui].DEFAULT_THEME' setting in the config file", setting.UI.DefaultTheme)
}
}()
cssFiles, err := public.AssetFS().ListFiles("/assets/css")
if err != nil {
log.Error("Failed to list themes: %v", err)
availableThemes = []string{setting.UI.DefaultTheme}
return
}
var foundThemes []string
for _, name := range cssFiles {
name, ok := strings.CutPrefix(name, "theme-")
if !ok {
continue
}
name, ok = strings.CutSuffix(name, ".css")
if !ok {
continue
}
foundThemes = append(foundThemes, name)
}
if len(setting.UI.Themes) > 0 {
allowedThemes := container.SetOf(setting.UI.Themes...)
for _, theme := range foundThemes {
if allowedThemes.Contains(theme) {
availableThemes = append(availableThemes, theme)
}
}
} else {
availableThemes = foundThemes
}
sort.Strings(availableThemes)
if len(availableThemes) == 0 {
setting.LogStartupProblem(1, log.ERROR, "No theme candidate in asset files, but Gitea requires there should be at least one usable theme")
availableThemes = []string{setting.UI.DefaultTheme}
}
}
func GetAvailableThemes() []string {
themeOnce.Do(initThemes)
return availableThemes
}
func IsThemeAvailable(name string) bool {
themeOnce.Do(initThemes)
return availableThemesSet.Contains(name)
}

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ctx.Locale.Lang}}" data-theme="{{ThemeName .SignedUser}}">
<html lang="{{ctx.Locale.Lang}}" data-theme="{{UserThemeName .SignedUser}}">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} - {{end}}{{if .Repository.Name}}{{.Repository.Name}} - {{end}}{{AppName}}</title>

View File

@ -1,2 +1,2 @@
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/index.css?v={{AssetVersion}}">
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/theme-{{ThemeName .SignedUser | PathEscape}}.css?v={{AssetVersion}}">
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/theme-{{UserThemeName .SignedUser | PathEscape}}.css?v={{AssetVersion}}">

View File

@ -1,12 +1,12 @@
{{/* This page should only depend the minimal template functions/variables, to avoid triggering new panics.
* base template functions: AppName, AssetUrlPrefix, AssetVersion, AppSubUrl, ThemeName
* base template functions: AppName, AssetUrlPrefix, AssetVersion, AppSubUrl, UserThemeName
* ctx.Locale
* .Flash
* .ErrorMsg
* .SignedUser (optional)
*/}}
<!DOCTYPE html>
<html lang="{{ctx.Locale.Lang}}" data-theme="{{ThemeName .SignedUser}}">
<html lang="{{ctx.Locale.Lang}}" data-theme="{{UserThemeName .SignedUser}}">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Internal Server Error - {{AppName}}</title>

View File

@ -1,4 +1,4 @@
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings sshkeys")}}
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings")}}
<div class="user-setting-content">
<!-- Theme -->
@ -6,39 +6,26 @@
{{ctx.Locale.Tr "settings.manage_themes"}}
</h4>
<div class="ui attached segment">
<div class="ui email list">
<div class="item">
{{ctx.Locale.Tr "settings.theme_desc"}}
</div>
<form class="ui form" action="{{.Link}}/theme" method="post">
{{.CsrfTokenHtml}}
<div class="field">
<label for="ui">{{ctx.Locale.Tr "settings.ui"}}</label>
<div class="ui selection dropdown" id="ui">
<input name="theme" type="hidden" value="{{.SignedUser.Theme}}">
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="text">
{{range $i,$a := .AllThemes}}
{{if eq $.SignedUser.Theme $a}}{{$a}}{{end}}
{{end}}
</div>
<div class="menu">
{{range $i,$a := .AllThemes}}
<div class="item{{if eq $.SignedUser.Theme $a}} active selected{{end}}" data-value="{{$a}}">
{{$a}}
</div>
{{end}}
</div>
</div>
</div>
<div class="field">
{{ctx.Locale.Tr "settings.theme_desc"}}
<a class="muted" target="_blank" href="https://github.com/go-gitea/gitea/blob/main/web_src/css/themes/" data-tooltip-content="{{ctx.Locale.Tr "settings.theme_colorblindness_prompt"}}">
{{svg "octicon-question"}} {{ctx.Locale.Tr "settings.theme_colorblindness_help"}}
</a>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "settings.ui"}}</label>
<select name="theme" class="ui dropdown">
{{range $theme := .AllThemes}}
<option value="{{$theme}}" {{Iif (eq $.SignedUser.Theme $theme) "selected"}}>{{$theme}}</option>
{{end}}
</select>
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "settings.update_theme"}}</button>
</div>
</form>
</div>
</div>
<!-- Language -->

View File

@ -0,0 +1,11 @@
@import "./theme-gitea-dark.css";
/* red/green colorblind-friendly colors */
/* from GitHub: --diffBlob-addition-*, --diffBlob-deletion-*, etc */
:root {
--color-diff-added-word-bg: #388bfd66;
--color-diff-added-row-bg: #388bfd26;
--color-diff-removed-word-bg: #db6d2866;
--color-diff-removed-row-bg: #db6d2826;
}

View File

@ -0,0 +1,11 @@
@import "./theme-gitea-light.css";
/* red/green colorblind-friendly colors */
/* from GitHub: --diffBlob-addition-*, --diffBlob-deletion-*, etc */
:root {
--color-diff-added-word-bg: #54aeff66;
--color-diff-added-row-bg: #ddf4ff80;
--color-diff-removed-word-bg: #ffb77c80;
--color-diff-removed-row-bg: #fff1e580;
}

View File

@ -5,12 +5,13 @@ import {createDropzone} from './dropzone.js';
import {showGlobalErrorMessage} from '../bootstrap.js';
import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.js';
import {svg} from '../svg.js';
import {hideElem, showElem, toggleElem, initSubmitEventPolyfill, submitEventSubmitter} from '../utils/dom.js';
import {hideElem, showElem, toggleElem, initSubmitEventPolyfill, submitEventSubmitter, getComboMarkdownEditor} from '../utils/dom.js';
import {htmlEscape} from 'escape-goat';
import {showTemporaryTooltip} from '../modules/tippy.js';
import {confirmModal} from './comp/ConfirmModal.js';
import {showErrorToast} from '../modules/toast.js';
import {request, POST, GET} from '../modules/fetch.js';
import {removeLinksInTextarea} from './comp/ComboMarkdownEditor.js';
import '../htmx.js';
const {appUrl, appSubUrl, csrfToken, i18n} = window.config;
@ -250,12 +251,13 @@ export function initDropzone(el) {
});
file.previewTemplate.append(copyLinkElement);
});
this.on('removedfile', (file) => {
$(`#${file.uuid}`).remove();
this.on('removedfile', async (file) => {
document.getElementById(file.uuid)?.remove();
if ($dropzone.data('remove-url')) {
POST($dropzone.data('remove-url'), {
await POST($dropzone.data('remove-url'), {
data: new URLSearchParams({file: file.uuid}),
});
removeLinksInTextarea(getComboMarkdownEditor(el.closest('form').querySelector('.combo-markdown-editor')), file);
}
});
this.on('error', function (file, message) {

View File

@ -296,11 +296,6 @@ class ComboMarkdownEditor {
}
}
export function getComboMarkdownEditor(el) {
if (el instanceof $) el = el[0];
return el?._giteaComboMarkdownEditor;
}
export async function initComboMarkdownEditor(container, options = {}) {
if (container instanceof $) {
if (container.length !== 1) {
@ -315,3 +310,9 @@ export async function initComboMarkdownEditor(container, options = {}) {
await editor.init();
return editor;
}
export function removeLinksInTextarea(editor, file) {
const fileName = file.name.slice(0, file.name.lastIndexOf('.'));
const fileText = `\\[${fileName}\\]\\(/attachments/${file.uuid}\\)`;
editor.value(editor.value().replace(new RegExp(`<img [\\s\\w"=]+ alt="${fileName}" src="/attachments/${file.uuid}">`, 'g'), '').replace(new RegExp(`\\!${fileText}`, 'g'), '').replace(new RegExp(fileText, 'g'), ''));
}

View File

@ -82,35 +82,48 @@ class CodeMirrorEditor {
}
}
async function handleClipboardImages(editor, dropzone, images, e) {
async function handleClipboardFiles(editor, dropzone, files, e) {
const uploadUrl = dropzone.getAttribute('data-upload-url');
const filesContainer = dropzone.querySelector('.files');
if (!dropzone || !uploadUrl || !filesContainer || !images.length) return;
if (!dropzone || !uploadUrl || !filesContainer || !files.length) return;
e.preventDefault();
e.stopPropagation();
for (const img of images) {
const name = img.name.slice(0, img.name.lastIndexOf('.'));
for (const file of files) {
if (!file) continue;
const name = file.name.slice(0, file.name.lastIndexOf('.'));
const placeholder = `![${name}](uploading ...)`;
editor.insertPlaceholder(placeholder);
const {uuid} = await uploadFile(img, uploadUrl);
const {width, dppx} = await imageInfo(img);
const {uuid} = await uploadFile(file, uploadUrl);
const {width, dppx} = await imageInfo(file);
const url = `/attachments/${uuid}`;
let text;
if (width > 0 && dppx > 1) {
// Scale down images from HiDPI monitors. This uses the <img> tag because it's the only
// method to change image size in Markdown that is supported by all implementations.
text = `<img width="${Math.round(width / dppx)}" alt="${htmlEscape(name)}" src="${htmlEscape(url)}">`;
if (file.type?.startsWith('image/')) {
if (width > 0 && dppx > 1) {
// Scale down images from HiDPI monitors. This uses the <img> tag because it's the only
// method to change image size in Markdown that is supported by all implementations.
text = `<img width="${Math.round(width / dppx)}" alt="${htmlEscape(name)}" src="${htmlEscape(url)}">`;
} else {
text = `![${name}](${url})`;
}
} else {
text = `![${name}](${url})`;
text = `[${name}](${url})`;
}
editor.replacePlaceholder(placeholder, text);
file.uuid = uuid;
dropzone.dropzone.emit('addedfile', file);
if (file.type?.startsWith('image/')) {
const imgSrc = `/attachments/${file.uuid}`;
dropzone.dropzone.emit('thumbnail', file, imgSrc);
dropzone.querySelector(`img[src='${CSS.escape(imgSrc)}']`).style.maxWidth = '100%';
}
dropzone.dropzone.emit('complete', file);
const input = document.createElement('input');
input.setAttribute('name', 'files');
input.setAttribute('type', 'hidden');
@ -134,21 +147,25 @@ function handleClipboardText(textarea, text, e) {
}
export function initEasyMDEPaste(easyMDE, dropzone) {
easyMDE.codemirror.on('paste', (_, e) => {
const {images} = getPastedContent(e);
if (images.length) {
handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e);
const pasteFunc = (e) => {
const {files} = getPastedContent(e);
if (files.length) {
handleClipboardFiles(new CodeMirrorEditor(easyMDE.codemirror), dropzone, files, e);
}
});
};
easyMDE.codemirror.on('paste', (_, e) => pasteFunc(e));
easyMDE.codemirror.on('drop', (_, e) => pasteFunc(e));
}
export function initTextareaPaste(textarea, dropzone) {
textarea.addEventListener('paste', (e) => {
const {images, text} = getPastedContent(e);
if (images.length) {
handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e);
const pasteFunc = (e) => {
const {files, text} = getPastedContent(e);
if (files.length) {
handleClipboardFiles(new TextareaEditor(textarea), dropzone, files, e);
} else if (text) {
handleClipboardText(textarea, text, e);
}
});
};
textarea.addEventListener('paste', (e) => pasteFunc(e));
textarea.addEventListener('drop', (e) => pasteFunc(e));
}

View File

@ -1,9 +1,9 @@
import $ from 'jquery';
import {handleReply} from './repo-issue.js';
import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
import {initComboMarkdownEditor, removeLinksInTextarea} from './comp/ComboMarkdownEditor.js';
import {createDropzone} from './dropzone.js';
import {GET, POST} from '../modules/fetch.js';
import {hideElem, showElem} from '../utils/dom.js';
import {hideElem, showElem, getComboMarkdownEditor} from '../utils/dom.js';
import {attachRefIssueContextPopup} from './contextpopup.js';
import {initCommentContent, initMarkupContent} from '../markup/content.js';
@ -26,7 +26,6 @@ async function onEditContent(event) {
if (!dropzone) return null;
let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event
let fileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone
const dz = await createDropzone(dropzone, {
url: dropzone.getAttribute('data-upload-url'),
headers: {'X-Csrf-Token': csrfToken},
@ -45,7 +44,6 @@ async function onEditContent(event) {
init() {
this.on('success', (file, data) => {
file.uuid = data.uuid;
fileUuidDict[file.uuid] = {submitted: false};
const input = document.createElement('input');
input.id = data.uuid;
input.name = 'files';
@ -56,19 +54,15 @@ async function onEditContent(event) {
this.on('removedfile', async (file) => {
document.getElementById(file.uuid)?.remove();
if (disableRemovedfileEvent) return;
if (dropzone.getAttribute('data-remove-url') && !fileUuidDict[file.uuid].submitted) {
if (dropzone.getAttribute('data-remove-url')) {
try {
await POST(dropzone.getAttribute('data-remove-url'), {data: new URLSearchParams({file: file.uuid})});
removeLinksInTextarea(getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')), file);
} catch (error) {
console.error(error);
}
}
});
this.on('submit', () => {
for (const fileUuid of Object.keys(fileUuidDict)) {
fileUuidDict[fileUuid].submitted = true;
}
});
this.on('reload', async () => {
try {
const response = await GET(editContentZone.getAttribute('data-attachment-url'));
@ -78,16 +72,16 @@ async function onEditContent(event) {
dz.removeAllFiles(true);
dropzone.querySelector('.files').innerHTML = '';
for (const el of dropzone.querySelectorAll('.dz-preview')) el.remove();
fileUuidDict = {};
disableRemovedfileEvent = false;
for (const attachment of data) {
const imgSrc = `${dropzone.getAttribute('data-link-url')}/${attachment.uuid}`;
dz.emit('addedfile', attachment);
dz.emit('thumbnail', attachment, imgSrc);
if (/\.(jpg|jpeg|png|gif|bmp|svg)$/i.test(attachment.name)) {
const imgSrc = `${dropzone.getAttribute('data-link-url')}/${attachment.uuid}`;
dz.emit('thumbnail', attachment, imgSrc);
dropzone.querySelector(`img[src='${imgSrc}']`).style.maxWidth = '100%';
}
dz.emit('complete', attachment);
fileUuidDict[attachment.uuid] = {submitted: true};
dropzone.querySelector(`img[src='${imgSrc}']`).style.maxWidth = '100%';
const input = document.createElement('input');
input.id = attachment.uuid;
input.name = 'files';

View File

@ -1,9 +1,9 @@
import $ from 'jquery';
import {htmlEscape} from 'escape-goat';
import {showTemporaryTooltip, createTippy} from '../modules/tippy.js';
import {hideElem, showElem, toggleElem} from '../utils/dom.js';
import {hideElem, showElem, toggleElem, getComboMarkdownEditor} from '../utils/dom.js';
import {setFileFolding} from './file-fold.js';
import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
import {toAbsoluteUrl} from '../utils.js';
import {initDropzone} from './common-global.js';
import {POST, GET} from '../modules/fetch.js';

View File

@ -1,4 +1,5 @@
import {debounce} from 'throttle-debounce';
import {extname} from '../utils.js';
function elementsCall(el, func, ...args) {
if (typeof el === 'string' || el instanceof String) {
@ -262,16 +263,26 @@ export function isElemVisible(element) {
return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
}
export function getComboMarkdownEditor(el) {
if (el?.jquery) el = el[0];
return el?._giteaComboMarkdownEditor;
}
// extract text and images from "paste" event
export function getPastedContent(e) {
const images = [];
for (const item of e.clipboardData?.items ?? []) {
if (item.type?.startsWith('image/')) {
images.push(item.getAsFile());
const acceptedFiles = getComboMarkdownEditor(e.currentTarget).dropzone.getAttribute('data-accepts');
const files = [];
const data = e.clipboardData?.items || e.dataTransfer?.items;
for (const item of data ?? []) {
if (item?.kind === 'file') {
const file = item.getAsFile();
if (acceptedFiles.includes(extname(file.name))) {
files.push(file);
}
}
}
const text = e.clipboardData?.getData?.('text') ?? '';
return {text, images};
return {text, files};
}
// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this