Introduce GiteaLocaleNumber custom element to handle number localization on pages. (#23861)
Follow #21429 & #22861 Use `<gitea-locale-number>` instead of backend `PrettyNumber`. All old `PrettyNumber` related functions are removed. A lot of code could be simplified. And some functions haven't been used for long time (dead code), so they are also removed by the way (eg: `SplitStringAtRuneN`, `Dedent`) This PR only tries to improve the `PrettyNumber` rendering problem, it doesn't touch the "plural" problem. Screenshot: ![image](https://user-images.githubusercontent.com/2114189/229290804-1f63db65-1e34-4a54-84ba-e00b44331b17.png) ![image](https://user-images.githubusercontent.com/2114189/229290911-c88dea00-b11d-48dd-accb-9f52edd73ce4.png)
This commit is contained in:
parent
01d9466bfd
commit
19de52e0f4
24 changed files with 94 additions and 227 deletions
|
@ -22,7 +22,6 @@ import (
|
||||||
"code.gitea.io/gitea/modules/git"
|
"code.gitea.io/gitea/modules/git"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/util"
|
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/minio/sha256-simd"
|
"github.com/minio/sha256-simd"
|
||||||
|
@ -142,12 +141,6 @@ func FileSize(s int64) string {
|
||||||
return humanize.IBytes(uint64(s))
|
return humanize.IBytes(uint64(s))
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrettyNumber produces a string form of the given number in base 10 with
|
|
||||||
// commas after every three orders of magnitude
|
|
||||||
func PrettyNumber(i interface{}) string {
|
|
||||||
return humanize.Comma(util.NumberIntoInt64(i))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subtract deals with subtraction of all types of number.
|
// Subtract deals with subtraction of all types of number.
|
||||||
func Subtract(left, right interface{}) interface{} {
|
func Subtract(left, right interface{}) interface{} {
|
||||||
var rleft, rright int64
|
var rleft, rright int64
|
||||||
|
|
|
@ -114,13 +114,6 @@ func TestFileSize(t *testing.T) {
|
||||||
assert.Equal(t, "2.0 EiB", FileSize(size))
|
assert.Equal(t, "2.0 EiB", FileSize(size))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPrettyNumber(t *testing.T) {
|
|
||||||
assert.Equal(t, "23,342,432", PrettyNumber(23342432))
|
|
||||||
assert.Equal(t, "23,342,432", PrettyNumber(int32(23342432)))
|
|
||||||
assert.Equal(t, "0", PrettyNumber(0))
|
|
||||||
assert.Equal(t, "-100,000", PrettyNumber(-100000))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubtract(t *testing.T) {
|
func TestSubtract(t *testing.T) {
|
||||||
toFloat64 := func(n interface{}) float64 {
|
toFloat64 := func(n interface{}) float64 {
|
||||||
switch v := n.(type) {
|
switch v := n.(type) {
|
||||||
|
|
|
@ -19,7 +19,6 @@ import (
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
texttmpl "text/template"
|
texttmpl "text/template"
|
||||||
"time"
|
"time"
|
||||||
|
@ -112,18 +111,17 @@ func NewFuncMap() []template.FuncMap {
|
||||||
"IsShowFullName": func() bool {
|
"IsShowFullName": func() bool {
|
||||||
return setting.UI.DefaultShowFullName
|
return setting.UI.DefaultShowFullName
|
||||||
},
|
},
|
||||||
"Safe": Safe,
|
"Safe": Safe,
|
||||||
"SafeJS": SafeJS,
|
"SafeJS": SafeJS,
|
||||||
"JSEscape": JSEscape,
|
"JSEscape": JSEscape,
|
||||||
"Str2html": Str2html,
|
"Str2html": Str2html,
|
||||||
"TimeSince": timeutil.TimeSince,
|
"TimeSince": timeutil.TimeSince,
|
||||||
"TimeSinceUnix": timeutil.TimeSinceUnix,
|
"TimeSinceUnix": timeutil.TimeSinceUnix,
|
||||||
"FileSize": base.FileSize,
|
"FileSize": base.FileSize,
|
||||||
"PrettyNumber": base.PrettyNumber,
|
"LocaleNumber": LocaleNumber,
|
||||||
"JsPrettyNumber": JsPrettyNumber,
|
"Subtract": base.Subtract,
|
||||||
"Subtract": base.Subtract,
|
"EntryIcon": base.EntryIcon,
|
||||||
"EntryIcon": base.EntryIcon,
|
"MigrationIcon": MigrationIcon,
|
||||||
"MigrationIcon": MigrationIcon,
|
|
||||||
"Add": func(a ...int) int {
|
"Add": func(a ...int) int {
|
||||||
sum := 0
|
sum := 0
|
||||||
for _, val := range a {
|
for _, val := range a {
|
||||||
|
@ -410,62 +408,9 @@ func NewFuncMap() []template.FuncMap {
|
||||||
"Join": strings.Join,
|
"Join": strings.Join,
|
||||||
"QueryEscape": url.QueryEscape,
|
"QueryEscape": url.QueryEscape,
|
||||||
"DotEscape": DotEscape,
|
"DotEscape": DotEscape,
|
||||||
"Iterate": func(arg interface{}) (items []uint64) {
|
"Iterate": func(arg interface{}) (items []int64) {
|
||||||
count := uint64(0)
|
count := util.ToInt64(arg)
|
||||||
switch val := arg.(type) {
|
for i := int64(0); i < count; i++ {
|
||||||
case uint64:
|
|
||||||
count = val
|
|
||||||
case *uint64:
|
|
||||||
count = *val
|
|
||||||
case int64:
|
|
||||||
if val < 0 {
|
|
||||||
val = 0
|
|
||||||
}
|
|
||||||
count = uint64(val)
|
|
||||||
case *int64:
|
|
||||||
if *val < 0 {
|
|
||||||
*val = 0
|
|
||||||
}
|
|
||||||
count = uint64(*val)
|
|
||||||
case int:
|
|
||||||
if val < 0 {
|
|
||||||
val = 0
|
|
||||||
}
|
|
||||||
count = uint64(val)
|
|
||||||
case *int:
|
|
||||||
if *val < 0 {
|
|
||||||
*val = 0
|
|
||||||
}
|
|
||||||
count = uint64(*val)
|
|
||||||
case uint:
|
|
||||||
count = uint64(val)
|
|
||||||
case *uint:
|
|
||||||
count = uint64(*val)
|
|
||||||
case int32:
|
|
||||||
if val < 0 {
|
|
||||||
val = 0
|
|
||||||
}
|
|
||||||
count = uint64(val)
|
|
||||||
case *int32:
|
|
||||||
if *val < 0 {
|
|
||||||
*val = 0
|
|
||||||
}
|
|
||||||
count = uint64(*val)
|
|
||||||
case uint32:
|
|
||||||
count = uint64(val)
|
|
||||||
case *uint32:
|
|
||||||
count = uint64(*val)
|
|
||||||
case string:
|
|
||||||
cnt, _ := strconv.ParseInt(val, 10, 64)
|
|
||||||
if cnt < 0 {
|
|
||||||
cnt = 0
|
|
||||||
}
|
|
||||||
count = uint64(cnt)
|
|
||||||
}
|
|
||||||
if count <= 0 {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
for i := uint64(0); i < count; i++ {
|
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
|
@ -1067,10 +1012,8 @@ func mirrorRemoteAddress(ctx context.Context, m *repo_model.Repository, remoteNa
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
// JsPrettyNumber renders a number using english decimal separators, e.g. 1,200 and subsequent
|
// LocaleNumber renders a number with a Custom Element, browser will render it with a locale number
|
||||||
// JS will replace the number with locale-specific separators, based on the user's selected language
|
func LocaleNumber(v interface{}) template.HTML {
|
||||||
func JsPrettyNumber(i interface{}) template.HTML {
|
num := util.ToInt64(v)
|
||||||
num := util.NumberIntoInt64(i)
|
return template.HTML(fmt.Sprintf(`<gitea-locale-number data-number="%d">%d</gitea-locale-number>`, num, num))
|
||||||
|
|
||||||
return template.HTML(`<span class="js-pretty-number" data-value="` + strconv.FormatInt(num, 10) + `">` + base.PrettyNumber(num) + `</span>`)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,27 +35,3 @@ func SplitStringAtByteN(input string, n int) (left, right string) {
|
||||||
|
|
||||||
return input[:end] + utf8Ellipsis, utf8Ellipsis + input[end:]
|
return input[:end] + utf8Ellipsis, utf8Ellipsis + input[end:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// SplitStringAtRuneN splits a string at rune n accounting for rune boundaries. (Combining characters are not accounted for.)
|
|
||||||
func SplitStringAtRuneN(input string, n int) (left, right string) {
|
|
||||||
if !utf8.ValidString(input) {
|
|
||||||
if len(input) <= n || n-3 < 0 {
|
|
||||||
return input, ""
|
|
||||||
}
|
|
||||||
return input[:n-3] + asciiEllipsis, asciiEllipsis + input[n-3:]
|
|
||||||
}
|
|
||||||
|
|
||||||
if utf8.RuneCountInString(input) <= n {
|
|
||||||
return input, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
count := 0
|
|
||||||
end := 0
|
|
||||||
for count < n-1 {
|
|
||||||
_, size := utf8.DecodeRuneInString(input[end:])
|
|
||||||
end += size
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
|
|
||||||
return input[:end] + utf8Ellipsis, utf8Ellipsis + input[end:]
|
|
||||||
}
|
|
||||||
|
|
|
@ -43,18 +43,4 @@ func TestSplitString(t *testing.T) {
|
||||||
{"\xef\x03", 1, "\xef\x03", ""},
|
{"\xef\x03", 1, "\xef\x03", ""},
|
||||||
}
|
}
|
||||||
test(tc, SplitStringAtByteN)
|
test(tc, SplitStringAtByteN)
|
||||||
|
|
||||||
tc = []*testCase{
|
|
||||||
{"abc123xyz", 0, "", utf8Ellipsis},
|
|
||||||
{"abc123xyz", 1, "", utf8Ellipsis},
|
|
||||||
{"abc123xyz", 4, "abc", utf8Ellipsis},
|
|
||||||
{"啊bc123xyz", 4, "啊bc", utf8Ellipsis},
|
|
||||||
{"啊bc123xyz", 6, "啊bc12", utf8Ellipsis},
|
|
||||||
{"啊bc", 3, "啊bc", ""},
|
|
||||||
{"啊bc", 4, "啊bc", ""},
|
|
||||||
{"abc\xef\x03\xfe", 3, "", asciiEllipsis},
|
|
||||||
{"abc\xef\x03\xfe", 4, "a", asciiEllipsis},
|
|
||||||
{"\xef\x03", 1, "\xef\x03", ""},
|
|
||||||
}
|
|
||||||
test(tc, SplitStringAtRuneN)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,8 +7,9 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"regexp"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
@ -200,40 +201,14 @@ func ToTitleCaseNoLower(s string) string {
|
||||||
return titleCaserNoLower.String(s)
|
return titleCaserNoLower.String(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
func logError(msg string, args ...any) {
|
||||||
whitespaceOnly = regexp.MustCompile("(?m)^[ \t]+$")
|
// TODO: the "util" package can not import the "modules/log" package, so we use the "fmt" package here temporarily.
|
||||||
leadingWhitespace = regexp.MustCompile("(?m)(^[ \t]*)(?:[^ \t\n])")
|
// In the future, we should decouple the dependency between them.
|
||||||
)
|
_, _ = fmt.Fprintf(os.Stderr, msg, args...)
|
||||||
|
|
||||||
// Dedent removes common indentation of a multi-line string along with whitespace around it
|
|
||||||
// Based on https://github.com/lithammer/dedent
|
|
||||||
func Dedent(s string) string {
|
|
||||||
var margin string
|
|
||||||
|
|
||||||
s = whitespaceOnly.ReplaceAllString(s, "")
|
|
||||||
indents := leadingWhitespace.FindAllStringSubmatch(s, -1)
|
|
||||||
|
|
||||||
for i, indent := range indents {
|
|
||||||
if i == 0 {
|
|
||||||
margin = indent[1]
|
|
||||||
} else if strings.HasPrefix(indent[1], margin) {
|
|
||||||
continue
|
|
||||||
} else if strings.HasPrefix(margin, indent[1]) {
|
|
||||||
margin = indent[1]
|
|
||||||
} else {
|
|
||||||
margin = ""
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if margin != "" {
|
|
||||||
s = regexp.MustCompile("(?m)^"+margin).ReplaceAllString(s, "")
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(s)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NumberIntoInt64 transform a given int into int64.
|
// ToInt64 transform a given int into int64.
|
||||||
func NumberIntoInt64(number interface{}) int64 {
|
func ToInt64(number interface{}) int64 {
|
||||||
var value int64
|
var value int64
|
||||||
switch v := number.(type) {
|
switch v := number.(type) {
|
||||||
case int:
|
case int:
|
||||||
|
@ -246,6 +221,23 @@ func NumberIntoInt64(number interface{}) int64 {
|
||||||
value = int64(v)
|
value = int64(v)
|
||||||
case int64:
|
case int64:
|
||||||
value = v
|
value = v
|
||||||
|
case uint:
|
||||||
|
value = int64(v)
|
||||||
|
case uint8:
|
||||||
|
value = int64(v)
|
||||||
|
case uint16:
|
||||||
|
value = int64(v)
|
||||||
|
case uint32:
|
||||||
|
value = int64(v)
|
||||||
|
case uint64:
|
||||||
|
value = int64(v)
|
||||||
|
case string:
|
||||||
|
var err error
|
||||||
|
if value, err = strconv.ParseInt(v, 10, 64); err != nil {
|
||||||
|
logError("strconv.ParseInt failed for %q: %v", v, err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logError("unable to convert %q to int64", v)
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
|
@ -224,10 +224,3 @@ func TestToTitleCase(t *testing.T) {
|
||||||
assert.Equal(t, ToTitleCase(`foo bar baz`), `Foo Bar Baz`)
|
assert.Equal(t, ToTitleCase(`foo bar baz`), `Foo Bar Baz`)
|
||||||
assert.Equal(t, ToTitleCase(`FOO BAR BAZ`), `Foo Bar Baz`)
|
assert.Equal(t, ToTitleCase(`FOO BAR BAZ`), `Foo Bar Baz`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDedent(t *testing.T) {
|
|
||||||
assert.Equal(t, Dedent(`
|
|
||||||
foo
|
|
||||||
bar
|
|
||||||
`), "foo\n\tbar")
|
|
||||||
}
|
|
||||||
|
|
|
@ -13,11 +13,11 @@
|
||||||
<div class="ui compact tiny menu">
|
<div class="ui compact tiny menu">
|
||||||
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{$.Link}}?state=open">
|
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{$.Link}}?state=open">
|
||||||
{{svg "octicon-project-symlink" 16 "gt-mr-3"}}
|
{{svg "octicon-project-symlink" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .OpenCount}} {{.locale.Tr "repo.issues.open_title"}}
|
{{LocaleNumber .OpenCount}} {{.locale.Tr "repo.issues.open_title"}}
|
||||||
</a>
|
</a>
|
||||||
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{$.Link}}?state=closed">
|
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{$.Link}}?state=closed">
|
||||||
{{svg "octicon-check" 16 "gt-mr-3"}}
|
{{svg "octicon-check" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .ClosedCount}} {{.locale.Tr "repo.issues.closed_title"}}
|
{{LocaleNumber .ClosedCount}} {{.locale.Tr "repo.issues.closed_title"}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -46,9 +46,9 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="issue-stats">
|
<span class="issue-stats">
|
||||||
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
|
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}}
|
{{LocaleNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}}
|
||||||
{{svg "octicon-check" 16 "gt-mr-3"}}
|
{{svg "octicon-check" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}}
|
{{LocaleNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{{if and $.CanWriteProjects (not $.Repository.IsArchived)}}
|
{{if and $.CanWriteProjects (not $.Repository.IsArchived)}}
|
||||||
|
|
|
@ -18,11 +18,11 @@
|
||||||
<div class="ui compact tiny menu">
|
<div class="ui compact tiny menu">
|
||||||
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=open&q={{$.Keyword}}">
|
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=open&q={{$.Keyword}}">
|
||||||
{{svg "octicon-milestone" 16 "gt-mr-3"}}
|
{{svg "octicon-milestone" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .OpenCount}} {{.locale.Tr "repo.issues.open_title"}}
|
{{LocaleNumber .OpenCount}} {{.locale.Tr "repo.issues.open_title"}}
|
||||||
</a>
|
</a>
|
||||||
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=closed&q={{$.Keyword}}">
|
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=closed&q={{$.Keyword}}">
|
||||||
{{svg "octicon-check" 16 "gt-mr-3"}}
|
{{svg "octicon-check" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .ClosedCount}} {{.locale.Tr "repo.issues.closed_title"}}
|
{{LocaleNumber .ClosedCount}} {{.locale.Tr "repo.issues.closed_title"}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -84,9 +84,9 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="issue-stats">
|
<span class="issue-stats">
|
||||||
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
|
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}}
|
{{LocaleNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}}
|
||||||
{{svg "octicon-check" 16 "gt-mr-3"}}
|
{{svg "octicon-check" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}}
|
{{LocaleNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}}
|
||||||
{{if .TotalTrackedTime}}{{svg "octicon-clock"}} {{.TotalTrackedTime|Sec2Time}}{{end}}
|
{{if .TotalTrackedTime}}{{svg "octicon-clock"}} {{.TotalTrackedTime|Sec2Time}}{{end}}
|
||||||
{{if .UpdatedUnix}}{{svg "octicon-clock"}} {{$.locale.Tr "repo.milestones.update_ago" (.TimeSinceUpdate|Sec2Time)}}{{end}}
|
{{if .UpdatedUnix}}{{svg "octicon-clock"}} {{$.locale.Tr "repo.milestones.update_ago" (.TimeSinceUpdate|Sec2Time)}}{{end}}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -5,10 +5,10 @@
|
||||||
{{else}}
|
{{else}}
|
||||||
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
|
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{JsPrettyNumber .IssueStats.OpenCount}} {{.locale.Tr "repo.issues.open_title"}}
|
{{LocaleNumber .IssueStats.OpenCount}} {{.locale.Tr "repo.issues.open_title"}}
|
||||||
</a>
|
</a>
|
||||||
<a class="{{if .IsShowClosed}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type={{.ViewType}}&sort={{$.SortType}}&state=closed&labels={{.SelectLabels}}&milestone={{.MilestoneID}}&project={{.ProjectID}}&assignee={{.AssigneeID}}&poster={{.PosterID}}">
|
<a class="{{if .IsShowClosed}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type={{.ViewType}}&sort={{$.SortType}}&state=closed&labels={{.SelectLabels}}&milestone={{.MilestoneID}}&project={{.ProjectID}}&assignee={{.AssigneeID}}&poster={{.PosterID}}">
|
||||||
{{svg "octicon-check" 16 "gt-mr-3"}}
|
{{svg "octicon-check" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .IssueStats.ClosedCount}} {{.locale.Tr "repo.issues.closed_title"}}
|
{{LocaleNumber .IssueStats.ClosedCount}} {{.locale.Tr "repo.issues.closed_title"}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -15,11 +15,11 @@
|
||||||
<div class="ui compact tiny menu">
|
<div class="ui compact tiny menu">
|
||||||
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/projects?state=open">
|
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/projects?state=open">
|
||||||
{{svg "octicon-project" 16 "gt-mr-3"}}
|
{{svg "octicon-project" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .OpenCount}} {{.locale.Tr "repo.issues.open_title"}}
|
{{LocaleNumber .OpenCount}} {{.locale.Tr "repo.issues.open_title"}}
|
||||||
</a>
|
</a>
|
||||||
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/projects?state=closed">
|
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/projects?state=closed">
|
||||||
{{svg "octicon-check" 16 "gt-mr-3"}}
|
{{svg "octicon-check" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .ClosedCount}} {{.locale.Tr "repo.issues.closed_title"}}
|
{{LocaleNumber .ClosedCount}} {{.locale.Tr "repo.issues.closed_title"}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -48,9 +48,9 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="issue-stats">
|
<span class="issue-stats">
|
||||||
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
|
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}}
|
{{LocaleNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}}
|
||||||
{{svg "octicon-check" 16 "gt-mr-3"}}
|
{{svg "octicon-check" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}}
|
{{LocaleNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{{if and $.CanWriteProjects (not $.Repository.IsArchived)}}
|
{{if and $.CanWriteProjects (not $.Repository.IsArchived)}}
|
||||||
|
|
|
@ -161,9 +161,9 @@
|
||||||
<li>
|
<li>
|
||||||
<span class="ui text middle aligned right">
|
<span class="ui text middle aligned right">
|
||||||
<span class="ui text grey">{{.Size | FileSize}}</span>
|
<span class="ui text grey">{{.Size | FileSize}}</span>
|
||||||
<span data-tooltip-content="{{$.locale.Tr "repo.release.download_count" (.DownloadCount | PrettyNumber)}}">
|
<gitea-locale-number data-number-in-tooltip="{{dict "message" ($.locale.Tr "repo.release.download_count") "number" .DownloadCount | Json}}">
|
||||||
{{svg "octicon-info"}}
|
{{svg "octicon-info"}}
|
||||||
</span>
|
</gitea-locale-number>
|
||||||
</span>
|
</span>
|
||||||
<a target="_blank" rel="noopener noreferrer" href="{{.DownloadURL}}">
|
<a target="_blank" rel="noopener noreferrer" href="{{.DownloadURL}}">
|
||||||
<strong>{{svg "octicon-package" 16 "gt-mr-2"}}{{.Name}}</strong>
|
<strong>{{svg "octicon-package" 16 "gt-mr-2"}}{{.Name}}</strong>
|
||||||
|
|
|
@ -72,9 +72,9 @@
|
||||||
<input name="attachment-edit-{{.UUID}}" class="gt-mr-3 attachment_edit" required value="{{.Name}}">
|
<input name="attachment-edit-{{.UUID}}" class="gt-mr-3 attachment_edit" required value="{{.Name}}">
|
||||||
<input name="attachment-del-{{.UUID}}" type="hidden" value="false">
|
<input name="attachment-del-{{.UUID}}" type="hidden" value="false">
|
||||||
<span class="ui text grey gt-mr-3">{{.Size | FileSize}}</span>
|
<span class="ui text grey gt-mr-3">{{.Size | FileSize}}</span>
|
||||||
<span data-tooltip-content="{{$.locale.Tr "repo.release.download_count" (.DownloadCount | PrettyNumber)}}">
|
<gitea-locale-number data-number-in-tooltip="{{dict "message" ($.locale.Tr "repo.release.download_count") "number" .DownloadCount | Json}}">
|
||||||
{{svg "octicon-info"}}
|
{{svg "octicon-info"}}
|
||||||
</span>
|
</gitea-locale-number>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<div class="ui two horizontal center link list">
|
<div class="ui two horizontal center link list">
|
||||||
{{if and (.Permission.CanRead $.UnitTypeCode) (not .IsEmptyRepo)}}
|
{{if and (.Permission.CanRead $.UnitTypeCode) (not .IsEmptyRepo)}}
|
||||||
<div class="item{{if .PageIsCommits}} active{{end}}">
|
<div class="item{{if .PageIsCommits}} active{{end}}">
|
||||||
<a href="{{.RepoLink}}/commits/{{.BranchNameSubURL}}">{{svg "octicon-history"}} <b>{{JsPrettyNumber .CommitsCount}}</b> {{.locale.TrN .CommitsCount "repo.commit" "repo.commits"}}</a>
|
<a href="{{.RepoLink}}/commits/{{.BranchNameSubURL}}">{{svg "octicon-history"}} <b>{{LocaleNumber .CommitsCount}}</b> {{.locale.TrN .CommitsCount "repo.commit" "repo.commits"}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="item{{if .PageIsBranches}} active{{end}}">
|
<div class="item{{if .PageIsBranches}} active{{end}}">
|
||||||
<a href="{{.RepoLink}}/branches">{{svg "octicon-git-branch"}} <b>{{.BranchesCount}}</b> {{.locale.TrN .BranchesCount "repo.branch" "repo.branches"}}</a>
|
<a href="{{.RepoLink}}/branches">{{svg "octicon-git-branch"}} <b>{{.BranchesCount}}</b> {{.locale.TrN .BranchesCount "repo.branch" "repo.branches"}}</a>
|
||||||
|
|
|
@ -65,11 +65,11 @@
|
||||||
<div class="ui compact tiny menu">
|
<div class="ui compact tiny menu">
|
||||||
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open&q={{$.Keyword}}">
|
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open&q={{$.Keyword}}">
|
||||||
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
|
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .IssueStats.OpenCount}} {{.locale.Tr "repo.issues.open_title"}}
|
{{LocaleNumber .IssueStats.OpenCount}} {{.locale.Tr "repo.issues.open_title"}}
|
||||||
</a>
|
</a>
|
||||||
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed&q={{$.Keyword}}">
|
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed&q={{$.Keyword}}">
|
||||||
{{svg "octicon-issue-closed" 16 "gt-mr-3"}}
|
{{svg "octicon-issue-closed" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .IssueStats.ClosedCount}} {{.locale.Tr "repo.issues.closed_title"}}
|
{{LocaleNumber .IssueStats.ClosedCount}} {{.locale.Tr "repo.issues.closed_title"}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -39,11 +39,11 @@
|
||||||
<div class="ui compact tiny menu">
|
<div class="ui compact tiny menu">
|
||||||
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open&q={{$.Keyword}}">
|
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open&q={{$.Keyword}}">
|
||||||
{{svg "octicon-milestone" 16 "gt-mr-3"}}
|
{{svg "octicon-milestone" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .MilestoneStats.OpenCount}} {{.locale.Tr "repo.issues.open_title"}}
|
{{LocaleNumber .MilestoneStats.OpenCount}} {{.locale.Tr "repo.issues.open_title"}}
|
||||||
</a>
|
</a>
|
||||||
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed&q={{$.Keyword}}">
|
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed&q={{$.Keyword}}">
|
||||||
{{svg "octicon-check" 16 "gt-mr-3"}}
|
{{svg "octicon-check" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .MilestoneStats.ClosedCount}} {{.locale.Tr "repo.issues.closed_title"}}
|
{{LocaleNumber .MilestoneStats.ClosedCount}} {{.locale.Tr "repo.issues.closed_title"}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -104,9 +104,9 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="issue-stats">
|
<span class="issue-stats">
|
||||||
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
|
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}}
|
{{LocaleNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}}
|
||||||
{{svg "octicon-check" 16 "gt-mr-3"}}
|
{{svg "octicon-check" 16 "gt-mr-3"}}
|
||||||
{{JsPrettyNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}}
|
{{LocaleNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}}
|
||||||
{{if .TotalTrackedTime}}
|
{{if .TotalTrackedTime}}
|
||||||
{{svg "octicon-clock"}} {{.TotalTrackedTime|Sec2Time}}
|
{{svg "octicon-clock"}} {{.TotalTrackedTime|Sec2Time}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
@ -1,20 +1,9 @@
|
||||||
import {prettyNumber} from '../utils.js';
|
|
||||||
|
|
||||||
const {lang} = document.documentElement;
|
const {lang} = document.documentElement;
|
||||||
const dateFormatter = new Intl.DateTimeFormat(lang, {year: 'numeric', month: 'long', day: 'numeric'});
|
const dateFormatter = new Intl.DateTimeFormat(lang, {year: 'numeric', month: 'long', day: 'numeric'});
|
||||||
const shortDateFormatter = new Intl.DateTimeFormat(lang, {year: 'numeric', month: 'short', day: 'numeric'});
|
const shortDateFormatter = new Intl.DateTimeFormat(lang, {year: 'numeric', month: 'short', day: 'numeric'});
|
||||||
const dateTimeFormatter = new Intl.DateTimeFormat(lang, {year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric'});
|
const dateTimeFormatter = new Intl.DateTimeFormat(lang, {year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric'});
|
||||||
|
|
||||||
export function initFormattingReplacements() {
|
export function initFormattingReplacements() {
|
||||||
// replace english formatted numbers with locale-specific separators
|
|
||||||
for (const el of document.getElementsByClassName('js-pretty-number')) {
|
|
||||||
const num = Number(el.getAttribute('data-value'));
|
|
||||||
const formatted = prettyNumber(num, lang);
|
|
||||||
if (formatted && formatted !== el.textContent) {
|
|
||||||
el.textContent = formatted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// for each <time></time> tag, if it has the data-format attribute, format
|
// for each <time></time> tag, if it has the data-format attribute, format
|
||||||
// the text according to the user's chosen locale and formatter.
|
// the text according to the user's chosen locale and formatter.
|
||||||
formatAllTimeElements();
|
formatAllTimeElements();
|
||||||
|
|
|
@ -54,13 +54,6 @@ export function parseIssueHref(href) {
|
||||||
return {owner, repo, type, index};
|
return {owner, repo, type, index};
|
||||||
}
|
}
|
||||||
|
|
||||||
// pretty-print a number using locale-specific separators, e.g. 1200 -> 1,200
|
|
||||||
export function prettyNumber(num, locale = 'en-US') {
|
|
||||||
if (typeof num !== 'number') return '';
|
|
||||||
const {format} = new Intl.NumberFormat(locale);
|
|
||||||
return format(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse a URL, either relative '/path' or absolute 'https://localhost/path'
|
// parse a URL, either relative '/path' or absolute 'https://localhost/path'
|
||||||
export function parseUrl(str) {
|
export function parseUrl(str) {
|
||||||
return new URL(str, str.startsWith('http') ? undefined : window.location.origin);
|
return new URL(str, str.startsWith('http') ? undefined : window.location.origin);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import {expect, test} from 'vitest';
|
import {expect, test} from 'vitest';
|
||||||
import {
|
import {
|
||||||
basename, extname, isObject, stripTags, joinPaths, parseIssueHref,
|
basename, extname, isObject, stripTags, joinPaths, parseIssueHref,
|
||||||
prettyNumber, parseUrl, translateMonth, translateDay, blobToDataURI,
|
parseUrl, translateMonth, translateDay, blobToDataURI,
|
||||||
toAbsoluteUrl,
|
toAbsoluteUrl,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
|
||||||
|
@ -84,17 +84,6 @@ test('parseIssueHref', () => {
|
||||||
expect(parseIssueHref('')).toEqual({owner: undefined, repo: undefined, type: undefined, index: undefined});
|
expect(parseIssueHref('')).toEqual({owner: undefined, repo: undefined, type: undefined, index: undefined});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prettyNumber', () => {
|
|
||||||
expect(prettyNumber()).toEqual('');
|
|
||||||
expect(prettyNumber(null)).toEqual('');
|
|
||||||
expect(prettyNumber(undefined)).toEqual('');
|
|
||||||
expect(prettyNumber('1200')).toEqual('');
|
|
||||||
expect(prettyNumber(12345678, 'en-US')).toEqual('12,345,678');
|
|
||||||
expect(prettyNumber(12345678, 'de-DE')).toEqual('12.345.678');
|
|
||||||
expect(prettyNumber(12345678, 'be-BE')).toEqual('12 345 678');
|
|
||||||
expect(prettyNumber(12345678, 'hi-IN')).toEqual('1,23,45,678');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseUrl', () => {
|
test('parseUrl', () => {
|
||||||
expect(parseUrl('').pathname).toEqual('/');
|
expect(parseUrl('').pathname).toEqual('/');
|
||||||
expect(parseUrl('/path').pathname).toEqual('/path');
|
expect(parseUrl('/path').pathname).toEqual('/path');
|
||||||
|
|
20
web_src/js/webcomponents/GiteaLocaleNumber.js
Normal file
20
web_src/js/webcomponents/GiteaLocaleNumber.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// Convert a number to a locale string by data-number attribute.
|
||||||
|
// Or add a tooltip by data-number-in-tooltip attribute. JSON: {message: "count: %s", number: 123}
|
||||||
|
window.customElements.define('gitea-locale-number', class extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
// ideally, the number locale formatting and plural processing should be done by backend with translation strings.
|
||||||
|
// if we have complete backend locale support (eg: Golang "x/text" package), we can drop this component.
|
||||||
|
const number = this.getAttribute('data-number');
|
||||||
|
if (number) {
|
||||||
|
this.attachShadow({mode: 'open'});
|
||||||
|
this.shadowRoot.textContent = new Intl.NumberFormat().format(Number(number));
|
||||||
|
}
|
||||||
|
const numberInTooltip = this.getAttribute('data-number-in-tooltip');
|
||||||
|
if (numberInTooltip) {
|
||||||
|
// TODO: only 2 usages of this, we can replace it with Golang's "x/text/number" package in the future
|
||||||
|
const {message, number} = JSON.parse(numberInTooltip);
|
||||||
|
const tooltipContent = message.replace(/%[ds]/, new Intl.NumberFormat().format(Number(number)));
|
||||||
|
this.setAttribute('data-tooltip-content', tooltipContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
|
@ -1,6 +1,4 @@
|
||||||
import '@webcomponents/custom-elements'; // automatically adds custom elements for older browsers that don't support it
|
// Convert an absolute or relative URL to an absolute URL with the current origin
|
||||||
|
|
||||||
// this is a Gitea's private HTML component, it converts an absolute or relative URL to an absolute URL with the current origin
|
|
||||||
window.customElements.define('gitea-origin-url', class extends HTMLElement {
|
window.customElements.define('gitea-origin-url', class extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
const urlStr = this.getAttribute('data-url');
|
const urlStr = this.getAttribute('data-url');
|
||||||
|
|
|
@ -15,5 +15,4 @@ https://developer.mozilla.org/en-US/docs/Web/Web_Components
|
||||||
|
|
||||||
There are still some components that are not migrated to web components yet:
|
There are still some components that are not migrated to web components yet:
|
||||||
|
|
||||||
* `<span class="js-pretty-number">`
|
|
||||||
* `<time data-format>`
|
* `<time data-format>`
|
||||||
|
|
3
web_src/js/webcomponents/webcomponents.js
Normal file
3
web_src/js/webcomponents/webcomponents.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import '@webcomponents/custom-elements'; // polyfill for some browsers like Pale Moon
|
||||||
|
import './GiteaLocaleNumber.js';
|
||||||
|
import './GiteaOriginUrl.js';
|
|
@ -60,7 +60,7 @@ export default {
|
||||||
fileURLToPath(new URL('web_src/css/index.css', import.meta.url)),
|
fileURLToPath(new URL('web_src/css/index.css', import.meta.url)),
|
||||||
],
|
],
|
||||||
webcomponents: [
|
webcomponents: [
|
||||||
fileURLToPath(new URL('web_src/js/webcomponents/GiteaOriginUrl.js', import.meta.url)),
|
fileURLToPath(new URL('web_src/js/webcomponents/webcomponents.js', import.meta.url)),
|
||||||
],
|
],
|
||||||
swagger: [
|
swagger: [
|
||||||
fileURLToPath(new URL('web_src/js/standalone/swagger.js', import.meta.url)),
|
fileURLToPath(new URL('web_src/js/standalone/swagger.js', import.meta.url)),
|
||||||
|
|
Loading…
Add table
Reference in a new issue