mirror of
https://github.com/gogs/gogs.git
synced 2026-05-28 21:30:36 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0114e18bd3 | |||
| c4821cfe31 | |||
| 9327909209 | |||
| 8f9a1cf2c1 | |||
| bc6c1ddf07 | |||
| 65cce4eafe | |||
| 7102695655 | |||
| 32573b844c | |||
| 98cb5a3db4 | |||
| a5d2c0ee36 | |||
| 2d78fd22dc | |||
| c81f418856 | |||
| d259b87cfd | |||
| f7449a8c85 | |||
| 9e1525bdf8 | |||
| c604d23be5 | |||
| 22f84f7ee9 | |||
| c6b90ea5ab | |||
| 552fde5e8c | |||
| 87a7e5a80b | |||
| 6188e01752 | |||
| 8c17948ddf | |||
| a91b0551bd | |||
| 91e8a36578 | |||
| 5e191aa5c8 | |||
| c2a424a678 | |||
| 10c829f798 | |||
| c535b64bb9 | |||
| c3577dc6fa | |||
| d7c3f16f7a | |||
| eb24142b83 | |||
| dcad796c73 | |||
| 9b7d8ebd9d |
@@ -17,6 +17,7 @@ This applies to all texts, including but not limited to UI, documentation, code
|
||||
|
||||
- Use `github.com/cockroachdb/errors` for error handling.
|
||||
- Use `github.com/stretchr/testify` for assertions in tests. Be mindful about the choice of `require` and `assert`, the former should be used when the test cannot proceed meaningfully after a failed assertion.
|
||||
- Every 5xx response must log the error directly inside the handler, do not log errors in a shared helper.
|
||||
|
||||
## Localization
|
||||
|
||||
@@ -37,6 +38,7 @@ This applies to all texts, including but not limited to UI, documentation, code
|
||||
## Tool-use guidance
|
||||
|
||||
- Use `gh` CLI to access information on github.com that is not publicly available.
|
||||
- Run the Chrome DevTools MCP in headless mode so it does not steal focus from the user's foreground browser session. After finishing any task that used the Chrome DevTools MCP, kill all `chrome-devtools-mcp` processes with `pkill -f chrome-devtools-mcp`.
|
||||
|
||||
## Source code control
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/flamego/flamego"
|
||||
log "unknwon.dev/clog/v2"
|
||||
|
||||
"gogs.io/gogs/internal/database"
|
||||
"gogs.io/gogs/internal/ptrx"
|
||||
)
|
||||
|
||||
// repoContext is the request-scoped viewer of the repository. Viewer can be an
|
||||
// authenticated or anonymous user.
|
||||
type repoContext struct {
|
||||
Owner *database.User
|
||||
Repo *database.Repository
|
||||
|
||||
ViewerID int64
|
||||
viewerAccess database.AccessMode
|
||||
}
|
||||
|
||||
func (c *repoContext) ViewerCanRead() bool {
|
||||
return c.viewerAccess >= database.AccessModeRead
|
||||
}
|
||||
|
||||
func (c *repoContext) ViewerCanWrite() bool {
|
||||
return c.viewerAccess >= database.AccessModeWrite
|
||||
}
|
||||
|
||||
func (c *repoContext) ViewerCanAdminister() bool {
|
||||
return c.viewerAccess >= database.AccessModeAdmin
|
||||
}
|
||||
|
||||
// withRepoContext injects the repoContext of the repository derived from the
|
||||
// route.
|
||||
func withRepoContext(c flamego.Context, user *database.User) {
|
||||
ctx := c.Request().Context()
|
||||
w := c.ResponseWriter()
|
||||
ownerName := c.Param("owner")
|
||||
repoName := c.Param("repo")
|
||||
|
||||
owner, err := database.Handle.Users().GetByUsername(ctx, ownerName)
|
||||
if err != nil {
|
||||
if database.IsErrUserNotExist(err) {
|
||||
writeErrorResponse(w, http.StatusNotFound, errors.New("repository does not exist"))
|
||||
return
|
||||
}
|
||||
log.Error("repoContext: get user by username %q: %v", ownerName, err)
|
||||
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "get owner"))
|
||||
return
|
||||
}
|
||||
|
||||
repo, err := database.Handle.Repositories().GetByName(ctx, owner.ID, repoName)
|
||||
if err != nil {
|
||||
if database.IsErrRepoNotExist(err) {
|
||||
writeErrorResponse(w, http.StatusNotFound, errors.New("repository does not exist"))
|
||||
return
|
||||
}
|
||||
log.Error("repoContext: get repo by name %q/%q: %v", ownerName, repoName, err)
|
||||
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "get repo"))
|
||||
return
|
||||
}
|
||||
|
||||
viewer := ptrx.Deref(user, database.User{})
|
||||
var viewerAccess database.AccessMode
|
||||
if viewer.IsAdmin {
|
||||
viewerAccess = database.AccessModeOwner
|
||||
} else {
|
||||
viewerAccess = database.Handle.Permissions().AccessMode(
|
||||
ctx,
|
||||
viewer.ID,
|
||||
repo.ID,
|
||||
database.AccessModeOptions{
|
||||
OwnerID: owner.ID,
|
||||
Private: repo.IsPrivate,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
c.Map(&repoContext{
|
||||
Owner: owner,
|
||||
Repo: repo,
|
||||
ViewerID: viewer.ID,
|
||||
viewerAccess: viewerAccess,
|
||||
})
|
||||
}
|
||||
@@ -412,10 +412,9 @@ func Run(configPath string, portOverride int) error {
|
||||
c.Data["PageIsViewFiles"] = true
|
||||
})
|
||||
|
||||
// FIXME: Should use c.Repo.PullRequest to unify template, currently we have inconsistent URL
|
||||
// for PR in same repository. After select branch on the page, the URL contains redundant head user name.
|
||||
// e.g. /org1/test-repo/compare/master...org1:develop
|
||||
// which should be /org1/test-repo/compare/master...develop
|
||||
// FIXME: Should use c.Repo.PullRequest to unify the template. Same-repo PR URLs include a
|
||||
// redundant head user, e.g. /org1/test-repo/compare/master...org1:develop should be
|
||||
// /org1/test-repo/compare/master...develop.
|
||||
m.Combo("/compare/*", repo.MustAllowPulls).Get(repo.CompareAndPullRequest).
|
||||
Post(bindIgnErr(form.NewIssue{}), repo.CompareAndPullRequestPost)
|
||||
|
||||
@@ -484,12 +483,14 @@ func Run(configPath string, portOverride int) error {
|
||||
|
||||
m.Group("", func() {
|
||||
m.Get("/src/*", repo.Home)
|
||||
m.Get("/raw/*", repo.SingleDownload)
|
||||
m.Get("/commits/*", repo.RefCommits)
|
||||
m.Get("/commit/:sha([a-f0-9]{7,40})$", repo.Diff)
|
||||
m.Get("/forks", repo.Forks)
|
||||
}, repo.MustBeNotBare, context.RepoRef())
|
||||
m.Get("/commit/:sha([a-f0-9]{7,40})\\.:ext(patch|diff)", repo.MustBeNotBare, repo.RawDiff)
|
||||
// Bridged to Flamego to skip the legacy `RepoRef` middleware, which double-resolves the ref.
|
||||
m.Get("/raw/*", flamegoBridger(webHandler))
|
||||
m.Get("/commit/:sha([a-f0-9]{7,40})\\.:ext(patch|diff)", flamegoBridger(webHandler))
|
||||
// Constrain SHA shape so non-matching `/commit/...` paths 404 instead of loading the SPA with a bad param.
|
||||
m.Get("/commit/:sha([a-f0-9]{7,40})$", repo.MustBeNotBare, func(c *context.Context) { c.ServeWeb() })
|
||||
|
||||
m.Get("/compare/:before([a-z0-9]{40})\\.\\.\\.:after([a-z0-9]{40})", repo.MustBeNotBare, context.RepoRef(), repo.CompareDiff)
|
||||
}, ignSignIn, context.RepoAssignment())
|
||||
@@ -683,13 +684,35 @@ func newRoutingHandler() (http.Handler, error) {
|
||||
}
|
||||
f.Use(cache.Cacher(cacherOpts))
|
||||
|
||||
f.ReturnHandler(func(c flamego.Context, statusCode int, resp any, err error) {
|
||||
w := c.ResponseWriter()
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
if statusCode >= http.StatusInternalServerError && conf.IsProdMode() {
|
||||
msg = "Internal server error"
|
||||
}
|
||||
resp = map[string]any{"error": msg}
|
||||
}
|
||||
if resp == nil {
|
||||
w.WriteHeader(statusCode)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
f.Get("/redirect", getRedirect)
|
||||
|
||||
// The captcha middleware writes the image response itself when the request path
|
||||
// matches its URLPrefix. This route just needs to exist so the request reaches
|
||||
// the middleware chain.
|
||||
// The captcha middleware writes the response. This route exists so the request reaches it.
|
||||
f.Get("/captcha/image.jpeg", func() {})
|
||||
|
||||
f.Group("/{owner}/{repo}", func() {
|
||||
f.Get("/commit/{sha: /[0-9a-f]{7,40}/}.{format: /(diff|patch)/}", getRepoCommitRaw)
|
||||
f.Get("/raw/{ref}/{filepath: **}", getRepoRawFile)
|
||||
}, withRepoContext)
|
||||
|
||||
mountWebAPIRoutes(f)
|
||||
err = mountWebAppRoutes(f)
|
||||
if err != nil {
|
||||
@@ -801,9 +824,7 @@ func newMacaron() (*macaron.Macaron, error) {
|
||||
// renderIndex returns the index.html shell with per-request substitutions
|
||||
// applied for the given WebContext.
|
||||
func renderIndex(index []byte, wc context.WebContext) ([]byte, error) {
|
||||
// json.Marshal escapes <, >, and & to their \uXXXX forms by default, so
|
||||
// the payload cannot break out of the surrounding <script> with "</script>"
|
||||
// even if a field carries attacker-influenced text.
|
||||
// json.Marshal escapes <, >, and &, so the payload cannot break out of the surrounding <script>.
|
||||
payload, err := json.Marshal(struct {
|
||||
Lang string `json:"lang"`
|
||||
SubURL string `json:"subURL"`
|
||||
@@ -821,8 +842,7 @@ func renderIndex(index []byte, wc context.WebContext) ([]byte, error) {
|
||||
"{{.WebContext}}", script,
|
||||
}
|
||||
if wc.SubURL != "" {
|
||||
// Vite bakes absolute root paths into the bundle output. Prefix them
|
||||
// with the subpath so they resolve correctly under non-root mounts.
|
||||
// Prefix Vite's absolute root paths with the subpath for non-root mounts.
|
||||
pairs = append(pairs,
|
||||
`src="/assets/`, `src="`+wc.SubURL+`/assets/`,
|
||||
`href="/assets/`, `href="`+wc.SubURL+`/assets/`,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/flamego/flamego"
|
||||
"github.com/gogs/git-module"
|
||||
"gogs.io/gogs/internal/conf"
|
||||
"gogs.io/gogs/internal/gitx"
|
||||
"gogs.io/gogs/internal/repox"
|
||||
"gogs.io/gogs/internal/tool"
|
||||
log "unknwon.dev/clog/v2"
|
||||
)
|
||||
|
||||
// whitespaceFlag maps the `whitespace` query value to its `git diff` flag.
|
||||
// `ignore-change` (`-b`) still surfaces added/removed blank lines, unlike
|
||||
// `ignore-all` (`-w`). Empty or unknown values disable whitespace handling.
|
||||
func whitespaceFlag(v string) string {
|
||||
switch v {
|
||||
case "ignore-all":
|
||||
return "-w"
|
||||
case "ignore-change":
|
||||
return "-b"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func writeErrorResponse(w http.ResponseWriter, code int, err error) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
message := err.Error()
|
||||
// Match the JSON-API ReturnHandler: in prod, never leak 5xx detail.
|
||||
if code >= http.StatusInternalServerError && conf.IsProdMode() {
|
||||
message = "Internal server error"
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func getRepoCommitRaw(c flamego.Context, repoCtx *repoContext) {
|
||||
w := c.ResponseWriter()
|
||||
if !repoCtx.ViewerCanRead() {
|
||||
writeErrorResponse(w, http.StatusNotFound, errors.New("repository does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
owner := repoCtx.Owner
|
||||
repo := repoCtx.Repo
|
||||
sha := c.Param("sha")
|
||||
format := c.Param("format")
|
||||
|
||||
gitRepo, err := git.Open(repox.RepositoryPath(owner.Name, repo.Name))
|
||||
if err != nil {
|
||||
log.Error("getRepoCommitRaw: open repository %q/%q: %v", owner.Name, repo.Name, err)
|
||||
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "open repository"))
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = gitRepo.CatFileCommit(sha); err != nil {
|
||||
if gitx.IsErrRevisionNotExist(err) {
|
||||
writeErrorResponse(w, http.StatusNotFound, errors.New("commit does not exist"))
|
||||
return
|
||||
}
|
||||
log.Error("getRepoCommitRaw: cat-file commit %q in %q/%q: %v", sha, owner.Name, repo.Name, err)
|
||||
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "cat-file commit"))
|
||||
return
|
||||
}
|
||||
|
||||
var rawOpts []git.RawDiffOptions
|
||||
if flag := whitespaceFlag(c.Request().URL.Query().Get("whitespace")); flag != "" {
|
||||
rawOpts = append(rawOpts, git.RawDiffOptions{
|
||||
CommandOptions: git.CommandOptions{Args: []string{flag}},
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err = gitRepo.RawDiff(sha, git.RawDiffFormat(format), w, rawOpts...); err != nil {
|
||||
log.Error("getRepoCommitRaw: get raw diff %s: %v", sha, err)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveRef resolves ref as a commit SHA, then a branch name, then a tag
|
||||
// name, in that order. A branch and tag of the same name resolve to the branch.
|
||||
func resolveRef(gitRepo *git.Repository, ref string) (*git.Commit, error) {
|
||||
commit, err := gitRepo.CatFileCommit(ref)
|
||||
if err == nil {
|
||||
return commit, nil
|
||||
}
|
||||
if !gitx.IsErrRevisionNotExist(err) {
|
||||
return nil, errors.Wrap(err, "cat-file commit")
|
||||
}
|
||||
commit, err = gitRepo.BranchCommit(ref)
|
||||
if err == nil {
|
||||
return commit, nil
|
||||
}
|
||||
if !gitx.IsErrRevisionNotExist(err) {
|
||||
return nil, errors.Wrap(err, "get branch commit")
|
||||
}
|
||||
commit, err = gitRepo.TagCommit(ref)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "get tag commit")
|
||||
}
|
||||
return commit, nil
|
||||
}
|
||||
|
||||
func getRepoRawFile(c flamego.Context, repoCtx *repoContext) {
|
||||
w := c.ResponseWriter()
|
||||
if !repoCtx.ViewerCanRead() {
|
||||
writeErrorResponse(w, http.StatusNotFound, errors.New("repository does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
owner := repoCtx.Owner
|
||||
repo := repoCtx.Repo
|
||||
rawRef := c.Param("ref")
|
||||
filepath := c.Param("filepath")
|
||||
|
||||
ref, err := url.PathUnescape(rawRef)
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusNotFound, errors.New("ref does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
gitRepo, err := git.Open(repox.RepositoryPath(owner.Name, repo.Name))
|
||||
if err != nil {
|
||||
log.Error("getRepoRawFile: open repository %q/%q: %v", owner.Name, repo.Name, err)
|
||||
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "open repository"))
|
||||
return
|
||||
}
|
||||
|
||||
commit, err := resolveRef(gitRepo, ref)
|
||||
if err != nil {
|
||||
if gitx.IsErrRevisionNotExist(err) {
|
||||
writeErrorResponse(w, http.StatusNotFound, errors.New("ref does not exist"))
|
||||
return
|
||||
}
|
||||
log.Error("getRepoRawFile: resolve ref %q in %q/%q: %v", ref, owner.Name, repo.Name, err)
|
||||
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "resolve ref"))
|
||||
return
|
||||
}
|
||||
|
||||
blob, err := commit.Blob(filepath)
|
||||
if err != nil {
|
||||
if gitx.IsErrRevisionNotExist(err) || errors.Is(err, git.ErrNotBlob) {
|
||||
writeErrorResponse(w, http.StatusNotFound, errors.New("file does not exist"))
|
||||
return
|
||||
}
|
||||
log.Error("getRepoRawFile: blob %s:%s in %q/%q: %v", commit.ID, filepath, owner.Name, repo.Name, err)
|
||||
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "get blob"))
|
||||
return
|
||||
}
|
||||
|
||||
data, err := blob.Bytes()
|
||||
if err != nil {
|
||||
log.Error("getRepoRawFile: read blob %s:%s: %v", commit.ID, filepath, err)
|
||||
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "read blob"))
|
||||
return
|
||||
}
|
||||
|
||||
if pathCommit, err := commit.CommitByPath(git.CommitByRevisionOptions{Path: filepath}); err == nil && pathCommit != nil {
|
||||
w.Header().Set("Last-Modified", pathCommit.Committer.When.Format(http.TimeFormat))
|
||||
}
|
||||
|
||||
render, _ := strconv.ParseBool(c.Request().URL.Query().Get("render"))
|
||||
switch {
|
||||
case !tool.IsTextFile(data) && !tool.IsImageFile(data):
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+path.Base(filepath)+`"`)
|
||||
w.Header().Set("Content-Transfer-Encoding", "binary")
|
||||
case tool.IsTextFile(data) && (!conf.Repository.EnableRawFileRenderMode || !render):
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
}
|
||||
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -2,35 +2,23 @@ package web
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/flamego/binding"
|
||||
"github.com/flamego/cache"
|
||||
"github.com/flamego/captcha"
|
||||
"github.com/flamego/flamego"
|
||||
"github.com/flamego/session"
|
||||
"github.com/flamego/validator"
|
||||
"github.com/go-macaron/i18n"
|
||||
macaronsession "github.com/go-macaron/session"
|
||||
"gopkg.in/macaron.v1"
|
||||
log "unknwon.dev/clog/v2"
|
||||
|
||||
"gogs.io/gogs/internal/auth"
|
||||
"gogs.io/gogs/internal/conf"
|
||||
"gogs.io/gogs/internal/context"
|
||||
"gogs.io/gogs/internal/database"
|
||||
"gogs.io/gogs/internal/email"
|
||||
"gogs.io/gogs/internal/tool"
|
||||
"gogs.io/gogs/internal/userx"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -77,38 +65,11 @@ func (s flamegoSessionAdapter) Delete(key interface{}) { _ = s.sess.Del
|
||||
func (s flamegoSessionAdapter) Flush() { _ = s.sess.Flush() }
|
||||
func (s flamegoSessionAdapter) Encode() ([]byte, error) { return nil, nil }
|
||||
|
||||
func webAPIBodyLimiter(c flamego.Context) {
|
||||
func enforceWebAPIMaxBodySize(c flamego.Context) {
|
||||
r := c.Request().Request
|
||||
r.Body = http.MaxBytesReader(c.ResponseWriter(), r.Body, 4*1024) // 4 KiB
|
||||
}
|
||||
|
||||
func parseUserFromCode(ctx stdctx.Context, code string) (user *database.User) {
|
||||
if len(code) <= tool.TimeLimitCodeLength {
|
||||
return nil
|
||||
}
|
||||
|
||||
hexStr := code[tool.TimeLimitCodeLength:]
|
||||
if b, err := hex.DecodeString(hexStr); err == nil {
|
||||
if user, err = database.Handle.Users().GetByUsername(ctx, string(b)); user != nil {
|
||||
return user
|
||||
} else if !database.IsErrUserNotExist(err) {
|
||||
log.Error("parseUserFromCode: get user by name %q: %v", string(b), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyUserActiveCode(ctx stdctx.Context, code string) (user *database.User) {
|
||||
if user = parseUserFromCode(ctx, code); user != nil {
|
||||
prefix := code[:tool.TimeLimitCodeLength]
|
||||
data := strconv.FormatInt(user.ID, 10) + user.Email + user.LowerName + user.Password + user.Rands
|
||||
if tool.VerifyTimeLimitCode(data, conf.Auth.ActivateCodeLives, prefix) {
|
||||
return user
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// webAPIValidator is the shared validator instance used by every webapi
|
||||
// binding. Registering the json-tag name function makes validation errors
|
||||
// carry the wire field name (e.g. "recoveryCode") via ve.Field(), so the
|
||||
@@ -148,25 +109,6 @@ func bindJSON(model any) flamego.Handler {
|
||||
}
|
||||
|
||||
func mountWebAPIRoutes(f *flamego.Flame) {
|
||||
f.ReturnHandler(func(c flamego.Context, statusCode int, resp any, err error) {
|
||||
w := c.ResponseWriter()
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
if statusCode >= http.StatusInternalServerError && conf.IsProdMode() {
|
||||
msg = "Internal server error"
|
||||
}
|
||||
resp = map[string]any{"error": msg}
|
||||
}
|
||||
if resp == nil {
|
||||
w.WriteHeader(statusCode)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
f.Group("/api/web", func() {
|
||||
f.Group("/user", func() {
|
||||
f.Get("/info", getUserInfo)
|
||||
@@ -196,7 +138,13 @@ func mountWebAPIRoutes(f *flamego.Flame) {
|
||||
})
|
||||
f.Post("/sign-out", postUserSignOut)
|
||||
})
|
||||
}, webAPIBodyLimiter)
|
||||
f.Group("/{owner}/{repo}", func() {
|
||||
f.Get("/header", getRepoHeader)
|
||||
f.Get("/commit/{sha: /[0-9a-f]{7,40}/}", getRepoCommit)
|
||||
f.Combo("/watch").Post(postRepoWatch).Delete(deleteRepoWatch)
|
||||
f.Combo("/star").Post(postRepoStar).Delete(deleteRepoStar)
|
||||
}, withRepoContext)
|
||||
}, enforceWebAPIMaxBodySize)
|
||||
}
|
||||
|
||||
// fieldErrors maps JSON field names to per-field localized messages. A non-nil
|
||||
@@ -269,464 +217,3 @@ func renderBindingErrors(l i18n.Locale, errs binding.Errors) *bindingErrorRespon
|
||||
}
|
||||
return &bindingErrorResponse{Fields: out}
|
||||
}
|
||||
|
||||
type loginSource struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
|
||||
type getUserSignInResponse struct {
|
||||
LoginSources []loginSource `json:"loginSources"`
|
||||
}
|
||||
|
||||
type getUserSignUpResponse struct {
|
||||
RegistrationDisabled bool `json:"registrationDisabled"`
|
||||
CaptchaEnabled bool `json:"captchaEnabled"`
|
||||
}
|
||||
|
||||
func getUserSignUp() (statusCode int, resp *getUserSignUpResponse, err error) {
|
||||
return http.StatusOK, &getUserSignUpResponse{
|
||||
RegistrationDisabled: conf.Auth.DisableRegistration,
|
||||
CaptchaEnabled: conf.Auth.EnableRegistrationCaptcha,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type userSignUpRequest struct {
|
||||
UserName string `json:"userName" validate:"required,alphadashdot,max=35"`
|
||||
Email string `json:"email" validate:"required,email,max=254"`
|
||||
Password string `json:"password" validate:"required,max=255"`
|
||||
Captcha string `json:"captcha"`
|
||||
}
|
||||
|
||||
type userSignUpResponse struct {
|
||||
EmailConfirmationRequired bool `json:"emailConfirmationRequired,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Hours int `json:"hours,omitempty"`
|
||||
}
|
||||
|
||||
func postUserSignUp(r *http.Request, mc *macaron.Context, ca cache.Cache, l i18n.Locale, cpt captcha.Captcha, req userSignUpRequest) (statusCode int, resp any, err error) {
|
||||
if conf.Auth.DisableRegistration {
|
||||
return http.StatusForbidden, &bindingErrorResponse{Error: l.Tr("auth.disable_register_prompt")}, nil
|
||||
}
|
||||
if conf.Auth.EnableRegistrationCaptcha && !cpt.ValidText(req.Captcha) {
|
||||
msg := l.Tr("form.captcha_incorrect")
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{
|
||||
Fields: fieldErrors{"captcha": &msg},
|
||||
}, nil
|
||||
}
|
||||
u, err := database.Handle.Users().Create(
|
||||
r.Context(),
|
||||
req.UserName,
|
||||
req.Email,
|
||||
database.CreateUserOptions{
|
||||
Password: req.Password,
|
||||
Activated: !conf.Auth.RequireEmailConfirmation,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch {
|
||||
case database.IsErrUserAlreadyExist(err):
|
||||
msg := l.Tr("form.username_been_taken")
|
||||
return http.StatusUnprocessableEntity, &bindingErrorResponse{Fields: fieldErrors{"userName": &msg}}, nil
|
||||
case database.IsErrEmailAlreadyUsed(err):
|
||||
msg := l.Tr("form.email_been_used")
|
||||
return http.StatusUnprocessableEntity, &bindingErrorResponse{Fields: fieldErrors{"email": &msg}}, nil
|
||||
case database.IsErrNameNotAllowed(err):
|
||||
msg := l.Tr("user.form.name_not_allowed", err.(database.ErrNameNotAllowed).Value())
|
||||
return http.StatusBadRequest, &bindingErrorResponse{Fields: fieldErrors{"userName": &msg}}, nil
|
||||
default:
|
||||
log.Error("postUserSignUp: create user %q: %v", req.UserName, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "create user")
|
||||
}
|
||||
}
|
||||
log.Trace("Account created: %s", u.Name)
|
||||
|
||||
if database.Handle.Users().Count(r.Context()) == 1 {
|
||||
v := true
|
||||
err := database.Handle.Users().Update(
|
||||
r.Context(),
|
||||
u.ID,
|
||||
database.UpdateUserOptions{
|
||||
IsActivated: &v,
|
||||
IsAdmin: &v,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Error("postUserSignUp: update first user %q: %v", u.Name, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "update user")
|
||||
}
|
||||
}
|
||||
|
||||
if conf.Auth.RequireEmailConfirmation && u.ID > 1 {
|
||||
if err := email.SendActivateAccountMail(mc, database.NewMailerUser(u)); err != nil {
|
||||
log.Error("postUserSignUp: send activation mail to user %q: %v", u.Name, err)
|
||||
}
|
||||
if err := ca.Set(r.Context(), userx.MailResendCacheKey(u.ID), 1, 180*time.Second); err != nil {
|
||||
log.Error("postUserSignUp: put mail resend cache for user %q: %v", u.Name, err)
|
||||
}
|
||||
return http.StatusOK, &userSignUpResponse{
|
||||
EmailConfirmationRequired: true,
|
||||
Email: u.Email,
|
||||
Hours: conf.Auth.ActivateCodeLives / 60,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return http.StatusOK, &userSignUpResponse{}, nil
|
||||
}
|
||||
|
||||
func getUserSignIn(r *http.Request) (statusCode int, resp *getUserSignInResponse, err error) {
|
||||
sources, err := database.Handle.LoginSources().List(r.Context(), database.ListLoginSourceOptions{OnlyActivated: true})
|
||||
if err != nil {
|
||||
log.Error("getUserSignIn: list activated login sources: %v", err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "list activated login sources")
|
||||
}
|
||||
loginSources := make([]loginSource, 0, len(sources))
|
||||
for _, s := range sources {
|
||||
loginSources = append(loginSources, loginSource{ID: s.ID, Name: s.Name, IsDefault: s.IsDefault})
|
||||
}
|
||||
return http.StatusOK, &getUserSignInResponse{LoginSources: loginSources}, nil
|
||||
}
|
||||
|
||||
type userSignInRequest struct {
|
||||
Username string `json:"username" validate:"required,max=254"`
|
||||
Password string `json:"password" validate:"required,max=255"`
|
||||
LoginSource int64 `json:"loginSource"`
|
||||
}
|
||||
|
||||
type getUserResetPasswordResponse struct {
|
||||
EmailEnabled bool `json:"emailEnabled"`
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
func getUserResetPassword(r *http.Request) (statusCode int, resp *getUserResetPasswordResponse, err error) {
|
||||
code := r.URL.Query().Get("code")
|
||||
return http.StatusOK, &getUserResetPasswordResponse{
|
||||
EmailEnabled: conf.Email.Enabled,
|
||||
Valid: code != "" && verifyUserActiveCode(r.Context(), code) != nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type userResetPasswordEmailRequest struct {
|
||||
Email string `json:"email" validate:"required,email,max=254"`
|
||||
}
|
||||
|
||||
type userResetPasswordCompleteRequest struct {
|
||||
Code string `json:"code" validate:"required"`
|
||||
Password string `json:"password" validate:"required,min=6,max=255"`
|
||||
}
|
||||
|
||||
type userResetPasswordResponse struct {
|
||||
Hours int `json:"hours,omitempty"`
|
||||
ResendLimited bool `json:"resendLimited,omitempty"`
|
||||
}
|
||||
|
||||
func postUserResetPassword(r *http.Request, ca cache.Cache, l i18n.Locale, req userResetPasswordEmailRequest) (statusCode int, resp any, err error) {
|
||||
if !conf.Email.Enabled {
|
||||
return http.StatusForbidden, &bindingErrorResponse{Error: l.Tr("auth.disable_register_mail")}, nil
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
u, err := database.Handle.Users().GetByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
if database.IsErrUserNotExist(err) {
|
||||
return http.StatusOK, &userResetPasswordResponse{Hours: conf.Auth.ActivateCodeLives / 60}, nil
|
||||
}
|
||||
log.Error("postUserResetPassword: get user by email %q: %v", req.Email, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "get user by email")
|
||||
}
|
||||
|
||||
if !u.IsLocal() {
|
||||
msg := l.Tr("auth.non_local_account")
|
||||
return http.StatusForbidden, &bindingErrorResponse{Fields: fieldErrors{"email": &msg}}, nil
|
||||
}
|
||||
|
||||
if _, err := ca.Get(ctx, userx.MailResendCacheKey(u.ID)); err == nil {
|
||||
return http.StatusOK, &userResetPasswordResponse{
|
||||
Hours: conf.Auth.ActivateCodeLives / 60,
|
||||
ResendLimited: true,
|
||||
}, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Error("postUserResetPassword: get mail resend cache for user %q: %v", u.Name, err)
|
||||
}
|
||||
|
||||
if err = email.SendResetPasswordMail(l, database.NewMailerUser(u)); err != nil {
|
||||
log.Error("postUserResetPassword: send reset password mail to user %q: %v", u.Name, err)
|
||||
}
|
||||
if err = ca.Set(ctx, userx.MailResendCacheKey(u.ID), 1, 180*time.Second); err != nil {
|
||||
log.Error("postUserResetPassword: put mail resend cache for user %q: %v", u.Name, err)
|
||||
}
|
||||
|
||||
return http.StatusOK, &userResetPasswordResponse{Hours: conf.Auth.ActivateCodeLives / 60}, nil
|
||||
}
|
||||
|
||||
func postUserResetPasswordComplete(r *http.Request, l i18n.Locale, req userResetPasswordCompleteRequest) (statusCode int, resp any, err error) {
|
||||
u := verifyUserActiveCode(r.Context(), req.Code)
|
||||
if u == nil {
|
||||
return http.StatusBadRequest, &bindingErrorResponse{Error: l.Tr("auth.invalid_code")}, nil
|
||||
}
|
||||
|
||||
if err := database.Handle.Users().Update(r.Context(), u.ID, database.UpdateUserOptions{Password: &req.Password}); err != nil {
|
||||
log.Error("postUserResetPasswordComplete: update password for user %q: %v", u.Name, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "update user")
|
||||
}
|
||||
|
||||
log.Trace("User password reset: %s", u.Name)
|
||||
return http.StatusNoContent, nil, nil
|
||||
}
|
||||
|
||||
type userSignInResponse struct {
|
||||
// MFA is true when the account has MFA enabled and the password step
|
||||
// succeeded but a second factor is still required. The client should
|
||||
// navigate to /user/mfa to complete the challenge.
|
||||
MFA bool `json:"mfa,omitempty"`
|
||||
}
|
||||
|
||||
func postUserSignIn(r *http.Request, sess session.Session, mc *macaron.Context, l i18n.Locale, req userSignInRequest) (statusCode int, resp any, err error) {
|
||||
u, err := database.Handle.Users().Authenticate(r.Context(), req.Username, req.Password, req.LoginSource)
|
||||
if err != nil {
|
||||
switch {
|
||||
case auth.IsErrBadCredentials(err):
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{
|
||||
Error: l.Tr("form.username_password_incorrect"),
|
||||
Fields: fieldErrors{"username": nil, "password": nil},
|
||||
}, nil
|
||||
case database.IsErrLoginSourceMismatch(err):
|
||||
return http.StatusUnprocessableEntity, nil, errors.New(l.Tr("form.auth_source_mismatch"))
|
||||
default:
|
||||
log.Error("postUserSignIn: authenticate user %q: %v", req.Username, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "authenticate user")
|
||||
}
|
||||
}
|
||||
|
||||
if database.Handle.TwoFactors().IsEnabled(r.Context(), u.ID) {
|
||||
sess.Set("mfaUserID", u.ID)
|
||||
return http.StatusOK, &userSignInResponse{MFA: true}, nil
|
||||
}
|
||||
|
||||
completeSignIn(sess, mc, u)
|
||||
return http.StatusOK, &userSignInResponse{}, nil
|
||||
}
|
||||
|
||||
// completeSignIn finalizes the sign-in session for u: writes the auth session,
|
||||
// clears any in-flight MFA state, and sets the login-status cookie. The
|
||||
// caller is responsible for navigating to a post-login destination via
|
||||
// /redirect?to=.
|
||||
func completeSignIn(sess session.Session, mc *macaron.Context, u *database.User) {
|
||||
sess.Set("uid", u.ID)
|
||||
sess.Set("uname", u.Name)
|
||||
sess.Delete("mfaUserID")
|
||||
|
||||
mc.SetCookie(conf.Session.CSRFCookieName, "", -1, conf.Server.Subpath)
|
||||
if conf.Security.EnableLoginStatusCookie {
|
||||
mc.SetCookie(conf.Security.LoginStatusCookieName, "true", 0, conf.Server.Subpath)
|
||||
}
|
||||
}
|
||||
|
||||
func getUserMFA(sess session.Session) (statusCode int, resp any, err error) {
|
||||
if _, ok := sess.Get("mfaUserID").(int64); !ok {
|
||||
return http.StatusNotFound, nil, nil
|
||||
}
|
||||
return http.StatusNoContent, nil, nil
|
||||
}
|
||||
|
||||
type userMFARequest struct {
|
||||
Passcode string `json:"passcode" validate:"required,len=6"`
|
||||
}
|
||||
|
||||
type userMFAResponse struct{}
|
||||
|
||||
func postUserMFA(r *http.Request, sess session.Session, mc *macaron.Context, ca cache.Cache, l i18n.Locale, req userMFARequest) (statusCode int, resp any, err error) {
|
||||
userID, ok := sess.Get("mfaUserID").(int64)
|
||||
if !ok {
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{Error: l.Tr("auth.mfa_session_expired")}, nil
|
||||
}
|
||||
|
||||
t, err := database.Handle.TwoFactors().GetByUserID(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Error("postUserMFA: get two factor by user ID %d: %v", userID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "get two factor by user ID")
|
||||
}
|
||||
|
||||
valid, err := t.ValidateTOTP(req.Passcode)
|
||||
if err != nil {
|
||||
log.Error("postUserMFA: validate TOTP for user %d: %v", userID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "validate TOTP")
|
||||
}
|
||||
if !valid {
|
||||
msg := l.Tr("auth.mfa_invalid_passcode")
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{
|
||||
Fields: fieldErrors{"passcode": &msg},
|
||||
}, nil
|
||||
}
|
||||
|
||||
cacheKey := userx.TwoFactorCacheKey(userID, req.Passcode)
|
||||
if _, err := ca.Get(r.Context(), cacheKey); err == nil {
|
||||
msg := l.Tr("auth.mfa_reused_passcode")
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{
|
||||
Fields: fieldErrors{"passcode": &msg},
|
||||
}, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Error("postUserMFA: get two factor passcode cache for user %d: %v", userID, err)
|
||||
}
|
||||
if err = ca.Set(r.Context(), cacheKey, 1, 60*time.Second); err != nil {
|
||||
log.Error("postUserMFA: cache two factor passcode for user %d: %v", userID, err)
|
||||
}
|
||||
|
||||
u, err := database.Handle.Users().GetByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Error("postUserMFA: get user by ID %d: %v", userID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "get user by ID")
|
||||
}
|
||||
|
||||
completeSignIn(sess, mc, u)
|
||||
return http.StatusOK, &userMFAResponse{}, nil
|
||||
}
|
||||
|
||||
type userMFARecoveryRequest struct {
|
||||
RecoveryCode string `json:"recoveryCode" validate:"required,len=11"`
|
||||
}
|
||||
|
||||
func postUserMFARecovery(r *http.Request, sess session.Session, mc *macaron.Context, l i18n.Locale, req userMFARecoveryRequest) (statusCode int, resp any, err error) {
|
||||
userID, ok := sess.Get("mfaUserID").(int64)
|
||||
if !ok {
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{Error: l.Tr("auth.mfa_session_expired")}, nil
|
||||
}
|
||||
|
||||
if err := database.Handle.TwoFactors().UseRecoveryCode(r.Context(), userID, req.RecoveryCode); err != nil {
|
||||
if database.IsTwoFactorRecoveryCodeNotFound(err) {
|
||||
msg := l.Tr("auth.mfa_invalid_recovery_code")
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{
|
||||
Fields: fieldErrors{"recoveryCode": &msg},
|
||||
}, nil
|
||||
}
|
||||
log.Error("postUserMFARecovery: use recovery code for user %d: %v", userID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "use recovery code")
|
||||
}
|
||||
|
||||
u, err := database.Handle.Users().GetByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Error("postUserMFARecovery: get user by ID %d: %v", userID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "get user by ID")
|
||||
}
|
||||
|
||||
completeSignIn(sess, mc, u)
|
||||
return http.StatusOK, &userMFAResponse{}, nil
|
||||
}
|
||||
|
||||
type userInfo struct {
|
||||
Username string `json:"username"`
|
||||
AvatarURL string `json:"avatarURL"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
CanCreateOrganization bool `json:"canCreateOrganization"`
|
||||
}
|
||||
|
||||
func getUserInfo(user *database.User) (statusCode int, resp *userInfo, err error) {
|
||||
if user == nil {
|
||||
return http.StatusNoContent, nil, nil
|
||||
}
|
||||
return http.StatusOK,
|
||||
&userInfo{
|
||||
Username: user.Name,
|
||||
AvatarURL: user.AvatarURL(),
|
||||
IsAdmin: user.IsAdmin,
|
||||
CanCreateOrganization: user.CanCreateOrganization(),
|
||||
},
|
||||
nil
|
||||
}
|
||||
|
||||
type getUserActivateResponse struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
CodeLifetimeHours int `json:"codeLifetimeHours,omitempty"`
|
||||
}
|
||||
|
||||
func getUserActivate(u *database.User) (statusCode int, resp any, err error) {
|
||||
if u == nil {
|
||||
return http.StatusUnauthorized, nil, nil
|
||||
}
|
||||
// An already-active and authenticated user has no business on the activation page.
|
||||
if u.IsActive {
|
||||
return http.StatusNotFound, nil, nil
|
||||
}
|
||||
return http.StatusOK, &getUserActivateResponse{
|
||||
Email: u.Email,
|
||||
CodeLifetimeHours: conf.Auth.ActivateCodeLives / 60,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type postUserActivateResponse struct {
|
||||
RateLimited bool `json:"rateLimited,omitempty"`
|
||||
CodeLifetimeHours int `json:"codeLifetimeHours,omitempty"`
|
||||
}
|
||||
|
||||
func postUserActivate(r *http.Request, u *database.User, mc *macaron.Context, ca cache.Cache, l i18n.Locale) (statusCode int, resp any, err error) {
|
||||
if u == nil {
|
||||
return http.StatusUnauthorized, nil, nil
|
||||
}
|
||||
if u.IsActive {
|
||||
return http.StatusNotFound, nil, nil
|
||||
}
|
||||
if !conf.Auth.RequireEmailConfirmation {
|
||||
return http.StatusForbidden, &bindingErrorResponse{Error: l.Tr("auth.disable_register_mail")}, nil
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if _, err := ca.Get(ctx, userx.MailResendCacheKey(u.ID)); err == nil {
|
||||
return http.StatusOK, &postUserActivateResponse{
|
||||
RateLimited: true,
|
||||
CodeLifetimeHours: conf.Auth.ActivateCodeLives / 60,
|
||||
}, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Error("postUserActivate: get mail resend cache for user %q: %v", u.Name, err)
|
||||
}
|
||||
|
||||
if err := email.SendActivateAccountMail(mc, database.NewMailerUser(u)); err != nil {
|
||||
log.Error("postUserActivate: send activation mail to user %q: %v", u.Name, err)
|
||||
}
|
||||
if err := ca.Set(ctx, userx.MailResendCacheKey(u.ID), 1, 180*time.Second); err != nil {
|
||||
log.Error("postUserActivate: put mail resend cache for user %q: %v", u.Name, err)
|
||||
}
|
||||
return http.StatusOK, &postUserActivateResponse{CodeLifetimeHours: conf.Auth.ActivateCodeLives / 60}, nil
|
||||
}
|
||||
|
||||
type userActivateCompleteRequest struct {
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
|
||||
func postUserActivateComplete(r *http.Request, sess session.Session, mc *macaron.Context, l i18n.Locale, req userActivateCompleteRequest) (statusCode int, resp any, err error) {
|
||||
target := verifyUserActiveCode(r.Context(), req.Code)
|
||||
if target == nil {
|
||||
return http.StatusBadRequest, &bindingErrorResponse{Error: l.Tr("auth.invalid_code")}, nil
|
||||
}
|
||||
|
||||
v := true
|
||||
if err := database.Handle.Users().Update(
|
||||
r.Context(),
|
||||
target.ID,
|
||||
database.UpdateUserOptions{
|
||||
GenerateNewRands: true,
|
||||
IsActivated: &v,
|
||||
},
|
||||
); err != nil {
|
||||
log.Error("postUserActivateComplete: update user %q: %v", target.Name, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "update user")
|
||||
}
|
||||
|
||||
log.Trace("User activated: %s", target.Name)
|
||||
completeSignIn(sess, mc, target)
|
||||
return http.StatusNoContent, nil, nil
|
||||
}
|
||||
|
||||
type postUserSignOutResponse struct {
|
||||
RedirectTo string `json:"redirectTo,omitempty"`
|
||||
}
|
||||
|
||||
func postUserSignOut(sess macaronsession.Store, mc *macaron.Context) (statusCode int, resp *postUserSignOutResponse, err error) {
|
||||
_ = sess.Flush()
|
||||
_ = sess.Destory(mc)
|
||||
mc.SetCookie(conf.Session.CSRFCookieName, "", -1, conf.Server.Subpath)
|
||||
if conf.Auth.CustomLogoutURL != "" {
|
||||
return http.StatusOK, &postUserSignOutResponse{RedirectTo: conf.Auth.CustomLogoutURL}, nil
|
||||
}
|
||||
return http.StatusNoContent, nil, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/flamego/flamego"
|
||||
"github.com/gogs/git-module"
|
||||
log "unknwon.dev/clog/v2"
|
||||
|
||||
"gogs.io/gogs/internal/conf"
|
||||
"gogs.io/gogs/internal/database"
|
||||
"gogs.io/gogs/internal/gitx"
|
||||
"gogs.io/gogs/internal/repox"
|
||||
"gogs.io/gogs/internal/strx"
|
||||
"gogs.io/gogs/internal/tool"
|
||||
)
|
||||
|
||||
type repoHeader struct {
|
||||
ID int64 `json:"id"`
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatarURL"`
|
||||
Visibility string `json:"visibility"`
|
||||
MirrorOf string `json:"mirrorOf,omitempty"`
|
||||
WatchCount int `json:"watchCount"`
|
||||
StarCount int `json:"starCount"`
|
||||
ForkCount int `json:"forkCount"`
|
||||
IssuesEnabled bool `json:"issuesEnabled"`
|
||||
OpenIssueCount int `json:"openIssueCount"`
|
||||
PullRequestsEnabled bool `json:"pullRequestsEnabled"`
|
||||
OpenPullRequestCount int `json:"openPullRequestCount"`
|
||||
WikiEnabled bool `json:"wikiEnabled"`
|
||||
|
||||
ViewerCanAdminister bool `json:"viewerCanAdminister"`
|
||||
ViewerIsWatching bool `json:"viewerIsWatching"`
|
||||
ViewerIsStarring bool `json:"viewerIsStarring"`
|
||||
}
|
||||
|
||||
func getRepoHeader(repoCtx *repoContext) (statusCode int, resp *repoHeader, err error) {
|
||||
owner := repoCtx.Owner
|
||||
repo := repoCtx.Repo
|
||||
|
||||
issuesEnabled := repo.EnableIssues
|
||||
wikiEnabled := repo.EnableWiki
|
||||
if !repoCtx.ViewerCanRead() {
|
||||
if !repo.IsPartialPublic() {
|
||||
return http.StatusNotFound, nil, errors.New("repository does not exist")
|
||||
}
|
||||
issuesEnabled = repo.CanGuestViewIssues()
|
||||
wikiEnabled = repo.CanGuestViewWiki()
|
||||
}
|
||||
|
||||
visibility := "public"
|
||||
if repo.IsPrivate {
|
||||
visibility = "private"
|
||||
}
|
||||
|
||||
resp = &repoHeader{
|
||||
ID: repo.ID,
|
||||
Owner: owner.Name,
|
||||
Name: repo.Name,
|
||||
AvatarURL: strx.Coalesce(repo.AvatarLink(), owner.AvatarURL()),
|
||||
Visibility: visibility,
|
||||
WatchCount: repo.NumWatches,
|
||||
StarCount: repo.NumStars,
|
||||
ForkCount: repo.NumForks,
|
||||
IssuesEnabled: issuesEnabled,
|
||||
OpenIssueCount: repo.NumIssues - repo.NumClosedIssues,
|
||||
PullRequestsEnabled: repo.AllowsPulls(),
|
||||
OpenPullRequestCount: repo.NumPulls - repo.NumClosedPulls,
|
||||
WikiEnabled: wikiEnabled,
|
||||
|
||||
ViewerCanAdminister: repoCtx.ViewerCanAdminister(),
|
||||
ViewerIsWatching: database.IsWatching(repoCtx.ViewerID, repo.ID),
|
||||
ViewerIsStarring: database.IsStarring(repoCtx.ViewerID, repo.ID),
|
||||
}
|
||||
|
||||
if repo.IsMirror {
|
||||
mirror, err := database.GetMirrorByRepoID(repo.ID)
|
||||
if err != nil {
|
||||
log.Error("getRepoHeader: get mirror by repo ID %d: %v", repo.ID, err)
|
||||
} else if mirror != nil {
|
||||
resp.MirrorOf = mirror.Address()
|
||||
}
|
||||
}
|
||||
|
||||
return http.StatusOK, resp, nil
|
||||
}
|
||||
|
||||
type repoCommitSignature struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
When time.Time `json:"when"`
|
||||
AvatarURL string `json:"avatarURL"`
|
||||
ProfileURL string `json:"profileURL,omitempty"`
|
||||
}
|
||||
|
||||
type repoCommit struct {
|
||||
SHA string `json:"sha"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
Author repoCommitSignature `json:"author"`
|
||||
Parents []string `json:"parents"`
|
||||
}
|
||||
|
||||
func getRepoCommit(c flamego.Context, repoCtx *repoContext) (statusCode int, resp *repoCommit, err error) {
|
||||
if !repoCtx.ViewerCanRead() {
|
||||
return http.StatusNotFound, nil, errors.New("repository does not exist")
|
||||
}
|
||||
|
||||
ctx := c.Request().Context()
|
||||
owner := repoCtx.Owner
|
||||
repo := repoCtx.Repo
|
||||
commitID := c.Param("sha")
|
||||
|
||||
gitRepo, err := git.Open(repox.RepositoryPath(owner.Name, repo.Name))
|
||||
if err != nil {
|
||||
log.Error("getRepoCommit: open repository %q/%q: %v", owner.Name, repo.Name, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "open repository")
|
||||
}
|
||||
|
||||
commit, err := gitRepo.CatFileCommit(commitID)
|
||||
if err != nil {
|
||||
if gitx.IsErrRevisionNotExist(err) {
|
||||
return http.StatusNotFound, nil, nil
|
||||
}
|
||||
log.Error("getRepoCommit: cat-file commit %q in %q/%q: %v", commitID, owner.Name, repo.Name, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "cat-file commit")
|
||||
}
|
||||
|
||||
parents := make([]string, commit.ParentsCount())
|
||||
for i := 0; i < commit.ParentsCount(); i++ {
|
||||
sha, err := commit.ParentID(i)
|
||||
if err != nil {
|
||||
log.Error("getRepoCommit: parent ID %d for %q in %q/%q: %v", i, commitID, owner.Name, repo.Name, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "parent ID")
|
||||
}
|
||||
parents[i] = sha.String()
|
||||
}
|
||||
|
||||
toSignature := func(s *git.Signature) repoCommitSignature {
|
||||
sig := repoCommitSignature{
|
||||
Name: s.Name,
|
||||
Email: s.Email,
|
||||
When: s.When.UTC(),
|
||||
AvatarURL: tool.AvatarLink(s.Email),
|
||||
}
|
||||
if u, err := database.Handle.Users().GetByEmail(ctx, s.Email); err == nil && u != nil {
|
||||
sig.ProfileURL = conf.Server.Subpath + "/" + u.Name
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
subject := commit.Summary()
|
||||
var body string
|
||||
if msg := commit.Message; len(msg) > len(subject) {
|
||||
body = msg[len(subject):]
|
||||
}
|
||||
|
||||
return http.StatusOK, &repoCommit{
|
||||
SHA: commitID,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
Author: toSignature(commit.Author),
|
||||
Parents: parents,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type repoWatchResponse struct {
|
||||
WatchCount int `json:"watchCount"`
|
||||
}
|
||||
|
||||
func repoWatchAction(ctx context.Context, repoCtx *repoContext, watching bool) (statusCode int, resp *repoWatchResponse, err error) {
|
||||
if repoCtx.ViewerCanRead() {
|
||||
return http.StatusNotFound, nil, errors.New("repository does not exist")
|
||||
}
|
||||
|
||||
repo := repoCtx.Repo
|
||||
|
||||
if watching {
|
||||
err = database.Handle.Repositories().Watch(ctx, repoCtx.ViewerID, repo.ID)
|
||||
} else {
|
||||
err = database.WatchRepo(repoCtx.ViewerID, repo.ID, false)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("repoWatchAction: set watching=%t for user %d on repo %d: %v", watching, repoCtx.ViewerID, repo.ID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "watch repo")
|
||||
}
|
||||
|
||||
updated, err := database.Handle.Repositories().GetByName(ctx, repo.OwnerID, repo.Name)
|
||||
if err != nil {
|
||||
log.Error("repoWatchAction: reload repo %d (%q): %v", repo.ID, repo.Name, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "reload repo")
|
||||
}
|
||||
return http.StatusOK, &repoWatchResponse{
|
||||
WatchCount: updated.NumWatches,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func postRepoWatch(c flamego.Context, repoCtx *repoContext) (statusCode int, resp *repoWatchResponse, err error) {
|
||||
return repoWatchAction(c.Request().Context(), repoCtx, true)
|
||||
}
|
||||
|
||||
func deleteRepoWatch(c flamego.Context, repoCtx *repoContext) (statusCode int, resp *repoWatchResponse, err error) {
|
||||
return repoWatchAction(c.Request().Context(), repoCtx, false)
|
||||
}
|
||||
|
||||
type repoStarResponse struct {
|
||||
StarCount int `json:"starCount"`
|
||||
}
|
||||
|
||||
func repoStarAction(ctx context.Context, repoCtx *repoContext, starring bool) (statusCode int, resp *repoStarResponse, err error) {
|
||||
if !repoCtx.ViewerCanRead() {
|
||||
return http.StatusNotFound, nil, errors.New("repository does not exist")
|
||||
}
|
||||
|
||||
repo := repoCtx.Repo
|
||||
|
||||
if starring {
|
||||
err = database.Handle.Repositories().Star(ctx, repoCtx.ViewerID, repo.ID)
|
||||
} else {
|
||||
err = database.StarRepo(repoCtx.ViewerID, repo.ID, false)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("repoStarAction: set starred=%t for user %d on repo %d: %v", starring, repoCtx.ViewerID, repo.ID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "star repo")
|
||||
}
|
||||
|
||||
updated, err := database.Handle.Repositories().GetByName(ctx, repo.OwnerID, repo.Name)
|
||||
if err != nil {
|
||||
log.Error("repoStarAction: reload repo %d (%q): %v", repo.ID, repo.Name, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "reload repo")
|
||||
}
|
||||
return http.StatusOK, &repoStarResponse{
|
||||
StarCount: updated.NumStars,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func postRepoStar(c flamego.Context, repoCtx *repoContext) (statusCode int, resp *repoStarResponse, err error) {
|
||||
return repoStarAction(c.Request().Context(), repoCtx, true)
|
||||
}
|
||||
|
||||
func deleteRepoStar(c flamego.Context, repoCtx *repoContext) (statusCode int, resp *repoStarResponse, err error) {
|
||||
return repoStarAction(c.Request().Context(), repoCtx, false)
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/flamego/cache"
|
||||
"github.com/flamego/captcha"
|
||||
"github.com/flamego/session"
|
||||
"github.com/go-macaron/i18n"
|
||||
macaronsession "github.com/go-macaron/session"
|
||||
"gopkg.in/macaron.v1"
|
||||
log "unknwon.dev/clog/v2"
|
||||
|
||||
"gogs.io/gogs/internal/auth"
|
||||
"gogs.io/gogs/internal/conf"
|
||||
"gogs.io/gogs/internal/database"
|
||||
"gogs.io/gogs/internal/email"
|
||||
"gogs.io/gogs/internal/tool"
|
||||
"gogs.io/gogs/internal/userx"
|
||||
)
|
||||
|
||||
func parseUserFromCode(ctx stdctx.Context, code string) (user *database.User) {
|
||||
if len(code) <= tool.TimeLimitCodeLength {
|
||||
return nil
|
||||
}
|
||||
|
||||
hexStr := code[tool.TimeLimitCodeLength:]
|
||||
if b, err := hex.DecodeString(hexStr); err == nil {
|
||||
if user, err = database.Handle.Users().GetByUsername(ctx, string(b)); user != nil {
|
||||
return user
|
||||
} else if !database.IsErrUserNotExist(err) {
|
||||
log.Error("parseUserFromCode: get user by name %q: %v", string(b), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyUserActiveCode(ctx stdctx.Context, code string) (user *database.User) {
|
||||
if user = parseUserFromCode(ctx, code); user != nil {
|
||||
prefix := code[:tool.TimeLimitCodeLength]
|
||||
data := strconv.FormatInt(user.ID, 10) + user.Email + user.LowerName + user.Password + user.Rands
|
||||
if tool.VerifyTimeLimitCode(data, conf.Auth.ActivateCodeLives, prefix) {
|
||||
return user
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type loginSource struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
|
||||
type getUserSignInResponse struct {
|
||||
LoginSources []loginSource `json:"loginSources"`
|
||||
}
|
||||
|
||||
type getUserSignUpResponse struct {
|
||||
RegistrationDisabled bool `json:"registrationDisabled"`
|
||||
CaptchaEnabled bool `json:"captchaEnabled"`
|
||||
}
|
||||
|
||||
func getUserSignUp() (statusCode int, resp *getUserSignUpResponse, err error) {
|
||||
return http.StatusOK, &getUserSignUpResponse{
|
||||
RegistrationDisabled: conf.Auth.DisableRegistration,
|
||||
CaptchaEnabled: conf.Auth.EnableRegistrationCaptcha,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type userSignUpRequest struct {
|
||||
UserName string `json:"userName" validate:"required,alphadashdot,max=35"`
|
||||
Email string `json:"email" validate:"required,email,max=254"`
|
||||
Password string `json:"password" validate:"required,max=255"`
|
||||
Captcha string `json:"captcha"`
|
||||
}
|
||||
|
||||
type userSignUpResponse struct {
|
||||
EmailConfirmationRequired bool `json:"emailConfirmationRequired,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Hours int `json:"hours,omitempty"`
|
||||
}
|
||||
|
||||
func postUserSignUp(r *http.Request, mc *macaron.Context, ca cache.Cache, l i18n.Locale, cpt captcha.Captcha, req userSignUpRequest) (statusCode int, resp any, err error) {
|
||||
if conf.Auth.DisableRegistration {
|
||||
return http.StatusForbidden, &bindingErrorResponse{Error: l.Tr("auth.disable_register_prompt")}, nil
|
||||
}
|
||||
if conf.Auth.EnableRegistrationCaptcha && !cpt.ValidText(req.Captcha) {
|
||||
msg := l.Tr("form.captcha_incorrect")
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{
|
||||
Fields: fieldErrors{"captcha": &msg},
|
||||
}, nil
|
||||
}
|
||||
u, err := database.Handle.Users().Create(
|
||||
r.Context(),
|
||||
req.UserName,
|
||||
req.Email,
|
||||
database.CreateUserOptions{
|
||||
Password: req.Password,
|
||||
Activated: !conf.Auth.RequireEmailConfirmation,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch {
|
||||
case database.IsErrUserAlreadyExist(err):
|
||||
msg := l.Tr("form.username_been_taken")
|
||||
return http.StatusUnprocessableEntity, &bindingErrorResponse{Fields: fieldErrors{"userName": &msg}}, nil
|
||||
case database.IsErrEmailAlreadyUsed(err):
|
||||
msg := l.Tr("form.email_been_used")
|
||||
return http.StatusUnprocessableEntity, &bindingErrorResponse{Fields: fieldErrors{"email": &msg}}, nil
|
||||
case database.IsErrNameNotAllowed(err):
|
||||
msg := l.Tr("user.form.name_not_allowed", err.(database.ErrNameNotAllowed).Value())
|
||||
return http.StatusBadRequest, &bindingErrorResponse{Fields: fieldErrors{"userName": &msg}}, nil
|
||||
default:
|
||||
log.Error("postUserSignUp: create user %q: %v", req.UserName, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "create user")
|
||||
}
|
||||
}
|
||||
log.Trace("Account created: %s", u.Name)
|
||||
|
||||
if database.Handle.Users().Count(r.Context()) == 1 {
|
||||
v := true
|
||||
err := database.Handle.Users().Update(
|
||||
r.Context(),
|
||||
u.ID,
|
||||
database.UpdateUserOptions{
|
||||
IsActivated: &v,
|
||||
IsAdmin: &v,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Error("postUserSignUp: update first user %q: %v", u.Name, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "update user")
|
||||
}
|
||||
}
|
||||
|
||||
if conf.Auth.RequireEmailConfirmation && u.ID > 1 {
|
||||
if err := email.SendActivateAccountMail(mc, database.NewMailerUser(u)); err != nil {
|
||||
log.Error("postUserSignUp: send activation mail to user %q: %v", u.Name, err)
|
||||
}
|
||||
if err := ca.Set(r.Context(), userx.MailResendCacheKey(u.ID), 1, 180*time.Second); err != nil {
|
||||
log.Error("postUserSignUp: put mail resend cache for user %q: %v", u.Name, err)
|
||||
}
|
||||
return http.StatusOK, &userSignUpResponse{
|
||||
EmailConfirmationRequired: true,
|
||||
Email: u.Email,
|
||||
Hours: conf.Auth.ActivateCodeLives / 60,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return http.StatusOK, &userSignUpResponse{}, nil
|
||||
}
|
||||
|
||||
func getUserSignIn(r *http.Request) (statusCode int, resp *getUserSignInResponse, err error) {
|
||||
sources, err := database.Handle.LoginSources().List(r.Context(), database.ListLoginSourceOptions{OnlyActivated: true})
|
||||
if err != nil {
|
||||
log.Error("getUserSignIn: list activated login sources: %v", err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "list activated login sources")
|
||||
}
|
||||
loginSources := make([]loginSource, 0, len(sources))
|
||||
for _, s := range sources {
|
||||
loginSources = append(loginSources, loginSource{ID: s.ID, Name: s.Name, IsDefault: s.IsDefault})
|
||||
}
|
||||
return http.StatusOK, &getUserSignInResponse{LoginSources: loginSources}, nil
|
||||
}
|
||||
|
||||
type userSignInRequest struct {
|
||||
Username string `json:"username" validate:"required,max=254"`
|
||||
Password string `json:"password" validate:"required,max=255"`
|
||||
LoginSource int64 `json:"loginSource"`
|
||||
}
|
||||
|
||||
type getUserResetPasswordResponse struct {
|
||||
EmailEnabled bool `json:"emailEnabled"`
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
func getUserResetPassword(r *http.Request) (statusCode int, resp *getUserResetPasswordResponse, err error) {
|
||||
code := r.URL.Query().Get("code")
|
||||
return http.StatusOK, &getUserResetPasswordResponse{
|
||||
EmailEnabled: conf.Email.Enabled,
|
||||
Valid: code != "" && verifyUserActiveCode(r.Context(), code) != nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type userResetPasswordEmailRequest struct {
|
||||
Email string `json:"email" validate:"required,email,max=254"`
|
||||
}
|
||||
|
||||
type userResetPasswordCompleteRequest struct {
|
||||
Code string `json:"code" validate:"required"`
|
||||
Password string `json:"password" validate:"required,min=6,max=255"`
|
||||
}
|
||||
|
||||
type userResetPasswordResponse struct {
|
||||
Hours int `json:"hours,omitempty"`
|
||||
ResendLimited bool `json:"resendLimited,omitempty"`
|
||||
}
|
||||
|
||||
func postUserResetPassword(r *http.Request, ca cache.Cache, l i18n.Locale, req userResetPasswordEmailRequest) (statusCode int, resp any, err error) {
|
||||
if !conf.Email.Enabled {
|
||||
return http.StatusForbidden, &bindingErrorResponse{Error: l.Tr("auth.disable_register_mail")}, nil
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
u, err := database.Handle.Users().GetByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
if database.IsErrUserNotExist(err) {
|
||||
return http.StatusOK, &userResetPasswordResponse{Hours: conf.Auth.ActivateCodeLives / 60}, nil
|
||||
}
|
||||
log.Error("postUserResetPassword: get user by email %q: %v", req.Email, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "get user by email")
|
||||
}
|
||||
|
||||
if !u.IsLocal() {
|
||||
msg := l.Tr("auth.non_local_account")
|
||||
return http.StatusForbidden, &bindingErrorResponse{Fields: fieldErrors{"email": &msg}}, nil
|
||||
}
|
||||
|
||||
if _, err := ca.Get(ctx, userx.MailResendCacheKey(u.ID)); err == nil {
|
||||
return http.StatusOK, &userResetPasswordResponse{
|
||||
Hours: conf.Auth.ActivateCodeLives / 60,
|
||||
ResendLimited: true,
|
||||
}, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Error("postUserResetPassword: get mail resend cache for user %q: %v", u.Name, err)
|
||||
}
|
||||
|
||||
if err = email.SendResetPasswordMail(l, database.NewMailerUser(u)); err != nil {
|
||||
log.Error("postUserResetPassword: send reset password mail to user %q: %v", u.Name, err)
|
||||
}
|
||||
if err = ca.Set(ctx, userx.MailResendCacheKey(u.ID), 1, 180*time.Second); err != nil {
|
||||
log.Error("postUserResetPassword: put mail resend cache for user %q: %v", u.Name, err)
|
||||
}
|
||||
|
||||
return http.StatusOK, &userResetPasswordResponse{Hours: conf.Auth.ActivateCodeLives / 60}, nil
|
||||
}
|
||||
|
||||
func postUserResetPasswordComplete(r *http.Request, l i18n.Locale, req userResetPasswordCompleteRequest) (statusCode int, resp any, err error) {
|
||||
u := verifyUserActiveCode(r.Context(), req.Code)
|
||||
if u == nil {
|
||||
return http.StatusBadRequest, &bindingErrorResponse{Error: l.Tr("auth.invalid_code")}, nil
|
||||
}
|
||||
|
||||
if err := database.Handle.Users().Update(r.Context(), u.ID, database.UpdateUserOptions{Password: &req.Password}); err != nil {
|
||||
log.Error("postUserResetPasswordComplete: update password for user %q: %v", u.Name, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "update user")
|
||||
}
|
||||
|
||||
log.Trace("User password reset: %s", u.Name)
|
||||
return http.StatusNoContent, nil, nil
|
||||
}
|
||||
|
||||
type userSignInResponse struct {
|
||||
// MFA is true when the account has MFA enabled and the password step
|
||||
// succeeded but a second factor is still required. The client should
|
||||
// navigate to /user/mfa to complete the challenge.
|
||||
MFA bool `json:"mfa,omitempty"`
|
||||
}
|
||||
|
||||
func postUserSignIn(r *http.Request, sess session.Session, mc *macaron.Context, l i18n.Locale, req userSignInRequest) (statusCode int, resp any, err error) {
|
||||
u, err := database.Handle.Users().Authenticate(r.Context(), req.Username, req.Password, req.LoginSource)
|
||||
if err != nil {
|
||||
switch {
|
||||
case auth.IsErrBadCredentials(err):
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{
|
||||
Error: l.Tr("form.username_password_incorrect"),
|
||||
Fields: fieldErrors{"username": nil, "password": nil},
|
||||
}, nil
|
||||
case database.IsErrLoginSourceMismatch(err):
|
||||
return http.StatusUnprocessableEntity, nil, errors.New(l.Tr("form.auth_source_mismatch"))
|
||||
default:
|
||||
log.Error("postUserSignIn: authenticate user %q: %v", req.Username, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "authenticate user")
|
||||
}
|
||||
}
|
||||
|
||||
if database.Handle.TwoFactors().IsEnabled(r.Context(), u.ID) {
|
||||
sess.Set("mfaUserID", u.ID)
|
||||
return http.StatusOK, &userSignInResponse{MFA: true}, nil
|
||||
}
|
||||
|
||||
completeSignIn(sess, mc, u)
|
||||
return http.StatusOK, &userSignInResponse{}, nil
|
||||
}
|
||||
|
||||
// completeSignIn finalizes the sign-in session for u: writes the auth session,
|
||||
// clears any in-flight MFA state, and sets the login-status cookie. The
|
||||
// caller is responsible for navigating to a post-login destination via
|
||||
// /redirect?to=.
|
||||
func completeSignIn(sess session.Session, mc *macaron.Context, u *database.User) {
|
||||
sess.Set("uid", u.ID)
|
||||
sess.Set("uname", u.Name)
|
||||
sess.Delete("mfaUserID")
|
||||
|
||||
mc.SetCookie(conf.Session.CSRFCookieName, "", -1, conf.Server.Subpath)
|
||||
if conf.Security.EnableLoginStatusCookie {
|
||||
mc.SetCookie(conf.Security.LoginStatusCookieName, "true", 0, conf.Server.Subpath)
|
||||
}
|
||||
}
|
||||
|
||||
func getUserMFA(sess session.Session) (statusCode int, resp any, err error) {
|
||||
if _, ok := sess.Get("mfaUserID").(int64); !ok {
|
||||
return http.StatusNotFound, nil, nil
|
||||
}
|
||||
return http.StatusNoContent, nil, nil
|
||||
}
|
||||
|
||||
type userMFARequest struct {
|
||||
Passcode string `json:"passcode" validate:"required,len=6"`
|
||||
}
|
||||
|
||||
type userMFAResponse struct{}
|
||||
|
||||
func postUserMFA(r *http.Request, sess session.Session, mc *macaron.Context, ca cache.Cache, l i18n.Locale, req userMFARequest) (statusCode int, resp any, err error) {
|
||||
userID, ok := sess.Get("mfaUserID").(int64)
|
||||
if !ok {
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{Error: l.Tr("auth.mfa_session_expired")}, nil
|
||||
}
|
||||
|
||||
t, err := database.Handle.TwoFactors().GetByUserID(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Error("postUserMFA: get two factor by user ID %d: %v", userID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "get two factor by user ID")
|
||||
}
|
||||
|
||||
valid, err := t.ValidateTOTP(req.Passcode)
|
||||
if err != nil {
|
||||
log.Error("postUserMFA: validate TOTP for user %d: %v", userID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "validate TOTP")
|
||||
}
|
||||
if !valid {
|
||||
msg := l.Tr("auth.mfa_invalid_passcode")
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{
|
||||
Fields: fieldErrors{"passcode": &msg},
|
||||
}, nil
|
||||
}
|
||||
|
||||
cacheKey := userx.TwoFactorCacheKey(userID, req.Passcode)
|
||||
if _, err := ca.Get(r.Context(), cacheKey); err == nil {
|
||||
msg := l.Tr("auth.mfa_reused_passcode")
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{
|
||||
Fields: fieldErrors{"passcode": &msg},
|
||||
}, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Error("postUserMFA: get two factor passcode cache for user %d: %v", userID, err)
|
||||
}
|
||||
if err = ca.Set(r.Context(), cacheKey, 1, 60*time.Second); err != nil {
|
||||
log.Error("postUserMFA: cache two factor passcode for user %d: %v", userID, err)
|
||||
}
|
||||
|
||||
u, err := database.Handle.Users().GetByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Error("postUserMFA: get user by ID %d: %v", userID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "get user by ID")
|
||||
}
|
||||
|
||||
completeSignIn(sess, mc, u)
|
||||
return http.StatusOK, &userMFAResponse{}, nil
|
||||
}
|
||||
|
||||
type userMFARecoveryRequest struct {
|
||||
RecoveryCode string `json:"recoveryCode" validate:"required,len=11"`
|
||||
}
|
||||
|
||||
func postUserMFARecovery(r *http.Request, sess session.Session, mc *macaron.Context, l i18n.Locale, req userMFARecoveryRequest) (statusCode int, resp any, err error) {
|
||||
userID, ok := sess.Get("mfaUserID").(int64)
|
||||
if !ok {
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{Error: l.Tr("auth.mfa_session_expired")}, nil
|
||||
}
|
||||
|
||||
if err := database.Handle.TwoFactors().UseRecoveryCode(r.Context(), userID, req.RecoveryCode); err != nil {
|
||||
if database.IsTwoFactorRecoveryCodeNotFound(err) {
|
||||
msg := l.Tr("auth.mfa_invalid_recovery_code")
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{
|
||||
Fields: fieldErrors{"recoveryCode": &msg},
|
||||
}, nil
|
||||
}
|
||||
log.Error("postUserMFARecovery: use recovery code for user %d: %v", userID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "use recovery code")
|
||||
}
|
||||
|
||||
u, err := database.Handle.Users().GetByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Error("postUserMFARecovery: get user by ID %d: %v", userID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "get user by ID")
|
||||
}
|
||||
|
||||
completeSignIn(sess, mc, u)
|
||||
return http.StatusOK, &userMFAResponse{}, nil
|
||||
}
|
||||
|
||||
type userInfo struct {
|
||||
Username string `json:"username"`
|
||||
AvatarURL string `json:"avatarURL"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
CanCreateOrganization bool `json:"canCreateOrganization"`
|
||||
}
|
||||
|
||||
func getUserInfo(user *database.User) (statusCode int, resp *userInfo, err error) {
|
||||
if user == nil {
|
||||
return http.StatusNoContent, nil, nil
|
||||
}
|
||||
return http.StatusOK,
|
||||
&userInfo{
|
||||
Username: user.Name,
|
||||
AvatarURL: user.AvatarURL(),
|
||||
IsAdmin: user.IsAdmin,
|
||||
CanCreateOrganization: user.CanCreateOrganization(),
|
||||
},
|
||||
nil
|
||||
}
|
||||
|
||||
type getUserActivateResponse struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
CodeLifetimeHours int `json:"codeLifetimeHours,omitempty"`
|
||||
}
|
||||
|
||||
func getUserActivate(u *database.User) (statusCode int, resp any, err error) {
|
||||
if u == nil {
|
||||
return http.StatusUnauthorized, nil, nil
|
||||
}
|
||||
// An already-active and authenticated user has no business on the activation page.
|
||||
if u.IsActive {
|
||||
return http.StatusNotFound, nil, nil
|
||||
}
|
||||
return http.StatusOK, &getUserActivateResponse{
|
||||
Email: u.Email,
|
||||
CodeLifetimeHours: conf.Auth.ActivateCodeLives / 60,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type postUserActivateResponse struct {
|
||||
RateLimited bool `json:"rateLimited,omitempty"`
|
||||
CodeLifetimeHours int `json:"codeLifetimeHours,omitempty"`
|
||||
}
|
||||
|
||||
func postUserActivate(r *http.Request, u *database.User, mc *macaron.Context, ca cache.Cache, l i18n.Locale) (statusCode int, resp any, err error) {
|
||||
if u == nil {
|
||||
return http.StatusUnauthorized, nil, nil
|
||||
}
|
||||
if u.IsActive {
|
||||
return http.StatusNotFound, nil, nil
|
||||
}
|
||||
if !conf.Auth.RequireEmailConfirmation {
|
||||
return http.StatusForbidden, &bindingErrorResponse{Error: l.Tr("auth.disable_register_mail")}, nil
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if _, err := ca.Get(ctx, userx.MailResendCacheKey(u.ID)); err == nil {
|
||||
return http.StatusOK, &postUserActivateResponse{
|
||||
RateLimited: true,
|
||||
CodeLifetimeHours: conf.Auth.ActivateCodeLives / 60,
|
||||
}, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Error("postUserActivate: get mail resend cache for user %q: %v", u.Name, err)
|
||||
}
|
||||
|
||||
if err := email.SendActivateAccountMail(mc, database.NewMailerUser(u)); err != nil {
|
||||
log.Error("postUserActivate: send activation mail to user %q: %v", u.Name, err)
|
||||
}
|
||||
if err := ca.Set(ctx, userx.MailResendCacheKey(u.ID), 1, 180*time.Second); err != nil {
|
||||
log.Error("postUserActivate: put mail resend cache for user %q: %v", u.Name, err)
|
||||
}
|
||||
return http.StatusOK, &postUserActivateResponse{CodeLifetimeHours: conf.Auth.ActivateCodeLives / 60}, nil
|
||||
}
|
||||
|
||||
type userActivateCompleteRequest struct {
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
|
||||
func postUserActivateComplete(r *http.Request, sess session.Session, mc *macaron.Context, l i18n.Locale, req userActivateCompleteRequest) (statusCode int, resp any, err error) {
|
||||
target := verifyUserActiveCode(r.Context(), req.Code)
|
||||
if target == nil {
|
||||
return http.StatusBadRequest, &bindingErrorResponse{Error: l.Tr("auth.invalid_code")}, nil
|
||||
}
|
||||
|
||||
v := true
|
||||
if err := database.Handle.Users().Update(
|
||||
r.Context(),
|
||||
target.ID,
|
||||
database.UpdateUserOptions{
|
||||
GenerateNewRands: true,
|
||||
IsActivated: &v,
|
||||
},
|
||||
); err != nil {
|
||||
log.Error("postUserActivateComplete: update user %q: %v", target.Name, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "update user")
|
||||
}
|
||||
|
||||
log.Trace("User activated: %s", target.Name)
|
||||
completeSignIn(sess, mc, target)
|
||||
return http.StatusNoContent, nil, nil
|
||||
}
|
||||
|
||||
type postUserSignOutResponse struct {
|
||||
RedirectTo string `json:"redirectTo,omitempty"`
|
||||
}
|
||||
|
||||
func postUserSignOut(sess macaronsession.Store, mc *macaron.Context) (statusCode int, resp *postUserSignOutResponse, err error) {
|
||||
_ = sess.Flush()
|
||||
_ = sess.Destory(mc)
|
||||
mc.SetCookie(conf.Session.CSRFCookieName, "", -1, conf.Server.Subpath)
|
||||
if conf.Auth.CustomLogoutURL != "" {
|
||||
return http.StatusOK, &postUserSignOutResponse{RedirectTo: conf.Auth.CustomLogoutURL}, nil
|
||||
}
|
||||
return http.StatusNoContent, nil, nil
|
||||
}
|
||||
@@ -53,6 +53,13 @@ pull_requests = Pull requests
|
||||
issues = Issues
|
||||
|
||||
cancel = Cancel
|
||||
close = Close
|
||||
show_more = Show more
|
||||
show_less = Show less
|
||||
resize_sidebar = Resize sidebar
|
||||
more = More
|
||||
more_tabs = More tabs
|
||||
more_actions = More actions
|
||||
|
||||
[status]
|
||||
page_not_found = Page not found
|
||||
@@ -193,7 +200,6 @@ local = Local
|
||||
forgot_password= Forgot Password
|
||||
forget_password = Forgot password?
|
||||
sign_up_now = Create a new account
|
||||
confirmation_email_sent = A new confirmation email has been sent to <b>%s</b>, please check your inbox within the next %d hours to complete the registration process.
|
||||
activate_your_account = Activate your account
|
||||
prohibit_login = Login Prohibited
|
||||
prohibit_login_desc = Your account is prohibited from logging in. Please contact the site admin.
|
||||
@@ -218,6 +224,7 @@ reset_password_resend_limited = You already requested a password reset email rec
|
||||
reset_password_failed = Could not reset password, please try again.
|
||||
new_password = New password
|
||||
new_password_placeholder = Enter your new password
|
||||
confirm_password = Confirm password
|
||||
confirm_password_placeholder = Re-enter your password
|
||||
confirm_new_password = Confirm new password
|
||||
confirm_new_password_placeholder = Re-enter your new password
|
||||
@@ -502,7 +509,32 @@ unwatch = Unwatch
|
||||
watch = Watch
|
||||
unstar = Unstar
|
||||
star = Star
|
||||
starred = Starred
|
||||
fork = Fork
|
||||
mirror_of = mirror of
|
||||
sign_in_to_watch = Sign in to watch this repository
|
||||
sign_in_to_star = Sign in to star this repository
|
||||
sign_in_to_fork = Sign in to fork this repository
|
||||
watch_this_repository = Watch this repository
|
||||
unwatch_this_repository = Unwatch this repository
|
||||
star_this_repository = Star this repository
|
||||
unstar_this_repository = Unstar this repository
|
||||
fork_this_repository = Fork this repository
|
||||
visibility_private = This repository is private
|
||||
visibility_public = This repository is public
|
||||
view_watchers = View watchers
|
||||
view_stargazers = View stargazers
|
||||
view_forks = View forks
|
||||
browse_files = Browse files
|
||||
view_history = View history
|
||||
view_raw = View raw
|
||||
copy_file_path = Copy file path
|
||||
copy_full_sha = Copy full SHA
|
||||
renamed_from = Renamed from
|
||||
authored = authored
|
||||
parents = parents
|
||||
diff_label = diff
|
||||
patch_label = patch
|
||||
|
||||
no_desc = No Description
|
||||
quick_guide = Quick Guide
|
||||
@@ -931,9 +963,39 @@ diff.show_split_view = Split View
|
||||
diff.show_unified_view = Unified View
|
||||
diff.stats_desc = <strong> %d changed files</strong> with <strong>%d additions</strong> and <strong>%d deletions</strong>
|
||||
diff.bin = BIN
|
||||
diff.view_file = View File
|
||||
diff.view_file = View file
|
||||
diff.file_suppressed = File diff suppressed because it is too large
|
||||
diff.too_many_files = Some files were not shown because too many files changed in this diff
|
||||
diff.showing_changed_files = Showing <count>{count} changed files</count>
|
||||
diff.additions = additions
|
||||
diff.deletions = deletions
|
||||
diff.unified = Unified
|
||||
diff.split = Split
|
||||
diff.diff_settings = Diff settings
|
||||
diff.whitespace = Whitespace
|
||||
diff.show_whitespace = Show whitespace
|
||||
diff.ignore_whitespace_changes = Ignore whitespace changes
|
||||
diff.ignore_all_whitespace = Ignore all whitespace
|
||||
diff.display = Display
|
||||
diff.wrap_long_lines = Wrap long line
|
||||
diff.expand_all_files = Expand all files
|
||||
diff.collapse_all_files = Collapse all files
|
||||
show_file_tree = Show file tree
|
||||
hide_file_tree = Hide file tree
|
||||
expand_all_directories = Expand all directories
|
||||
collapse_all_directories = Collapse all directories
|
||||
search_files = Search files
|
||||
search_hide = Hide search
|
||||
search_diff = Search in diff
|
||||
search_previous_match = Previous match
|
||||
search_next_match = Next match
|
||||
commit_parent = parent
|
||||
commit_label = commit
|
||||
view_file = View file
|
||||
diff.expand_file = Expand file
|
||||
diff.collapse_file = Collapse file
|
||||
diff.expand_all_lines = Expand all lines
|
||||
diff.all_lines_expanded = All lines expanded
|
||||
|
||||
release.releases = Releases
|
||||
release.new_release = New Release
|
||||
|
||||
@@ -261,7 +261,7 @@ func RepoAssignment(pages ...bool) macaron.Handler {
|
||||
|
||||
if c.IsLogged {
|
||||
c.Data["IsWatchingRepo"] = database.IsWatching(c.User.ID, repo.ID)
|
||||
c.Data["IsStaringRepo"] = database.IsStaring(c.User.ID, repo.ID)
|
||||
c.Data["IsStaringRepo"] = database.IsStarring(c.User.ID, repo.ID)
|
||||
}
|
||||
|
||||
// repo is bare and display enable
|
||||
|
||||
@@ -331,7 +331,7 @@ func (r *Repository) RelAvatarLink() string {
|
||||
// AvatarLink returns repository avatar absolute link.
|
||||
func (r *Repository) AvatarLink() string {
|
||||
link := r.RelAvatarLink()
|
||||
if link[0] == '/' && link[1] != '/' {
|
||||
if len(link) > 2 && link[0] == '/' && link[1] != '/' {
|
||||
return conf.Server.ExternalURL + strings.TrimPrefix(link, conf.Server.Subpath)[1:]
|
||||
}
|
||||
return link
|
||||
@@ -2362,6 +2362,9 @@ type Watch struct {
|
||||
}
|
||||
|
||||
func isWatching(e Engine, userID, repoID int64) bool {
|
||||
if userID <= 0 {
|
||||
return false
|
||||
}
|
||||
has, _ := e.Get(&Watch{0, userID, repoID})
|
||||
return has
|
||||
}
|
||||
@@ -2484,7 +2487,7 @@ type Star struct {
|
||||
// Deprecated: Use Stars.Star instead.
|
||||
func StarRepo(userID, repoID int64, star bool) (err error) {
|
||||
if star {
|
||||
if IsStaring(userID, repoID) {
|
||||
if IsStarring(userID, repoID) {
|
||||
return nil
|
||||
}
|
||||
if _, err = x.Insert(&Star{UserID: userID, RepoID: repoID}); err != nil {
|
||||
@@ -2494,7 +2497,7 @@ func StarRepo(userID, repoID int64, star bool) (err error) {
|
||||
}
|
||||
_, err = x.Exec("UPDATE `user` SET num_stars = num_stars + 1 WHERE id = ?", userID)
|
||||
} else {
|
||||
if !IsStaring(userID, repoID) {
|
||||
if !IsStarring(userID, repoID) {
|
||||
return nil
|
||||
}
|
||||
if _, err = x.Delete(&Star{0, userID, repoID}); err != nil {
|
||||
@@ -2507,8 +2510,11 @@ func StarRepo(userID, repoID int64, star bool) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
// IsStaring checks if user has starred given repository.
|
||||
func IsStaring(userID, repoID int64) bool {
|
||||
// IsStarring checks if user has starred given repository.
|
||||
func IsStarring(userID, repoID int64) bool {
|
||||
if userID <= 0 {
|
||||
return false
|
||||
}
|
||||
has, _ := x.Get(&Star{0, userID, repoID})
|
||||
return has
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package ptrx
|
||||
|
||||
// Deref safely dereferences a pointer. If pointer is nil, returns default value,
|
||||
// otherwise returns dereferenced value.
|
||||
func Deref[T any](v *T, defaultValue T) T {
|
||||
if v != nil {
|
||||
return *v
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package ptrx
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDeref(t *testing.T) {
|
||||
t.Run("nil pointer returns default", func(t *testing.T) {
|
||||
assert.Equal(t, 42, Deref(nil, 42))
|
||||
assert.Equal(t, "", Deref(nil, ""))
|
||||
assert.Equal(t, "fallback", Deref(nil, "fallback"))
|
||||
assert.Equal(t, false, Deref(nil, false))
|
||||
})
|
||||
|
||||
t.Run("non-nil pointer returns dereferenced value", func(t *testing.T) {
|
||||
intVal := 7
|
||||
assert.Equal(t, 7, Deref(&intVal, 0))
|
||||
|
||||
strVal := "hello"
|
||||
assert.Equal(t, "hello", Deref(&strVal, "default"))
|
||||
|
||||
boolVal := true
|
||||
assert.Equal(t, true, Deref(&boolVal, false))
|
||||
})
|
||||
|
||||
t.Run("zero value pointer returns zero value", func(t *testing.T) {
|
||||
zeroInt := 0
|
||||
assert.Equal(t, 0, Deref(&zeroInt, 99))
|
||||
|
||||
emptyStr := ""
|
||||
assert.Equal(t, "", Deref(&emptyStr, "fallback"))
|
||||
})
|
||||
}
|
||||
@@ -111,76 +111,6 @@ func tryGetUserByEmail(ctx gocontext.Context, email string) *database.User {
|
||||
return user
|
||||
}
|
||||
|
||||
func Diff(c *context.Context) {
|
||||
c.PageIs("Diff")
|
||||
c.RequireHighlightJS()
|
||||
|
||||
userName := c.Repo.Owner.Name
|
||||
repoName := c.Repo.Repository.Name
|
||||
commitID := c.Params(":sha")
|
||||
|
||||
commit, err := c.Repo.GitRepo.CatFileCommit(commitID)
|
||||
if err != nil {
|
||||
c.NotFoundOrError(gitx.NewError(err), "get commit by ID")
|
||||
return
|
||||
}
|
||||
|
||||
diff, err := gitx.RepoDiff(c.Repo.GitRepo,
|
||||
commitID, conf.Git.MaxDiffFiles, conf.Git.MaxDiffLines, conf.Git.MaxDiffLineChars,
|
||||
git.DiffOptions{Timeout: time.Duration(conf.Git.Timeout.Diff) * time.Second},
|
||||
)
|
||||
if err != nil {
|
||||
c.NotFoundOrError(gitx.NewError(err), "get diff")
|
||||
return
|
||||
}
|
||||
|
||||
parents := make([]string, commit.ParentsCount())
|
||||
for i := 0; i < commit.ParentsCount(); i++ {
|
||||
sha, err := commit.ParentID(i)
|
||||
if err != nil {
|
||||
c.NotFound()
|
||||
return
|
||||
}
|
||||
parents[i] = sha.String()
|
||||
}
|
||||
|
||||
setEditorconfigIfExists(c)
|
||||
if c.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
c.RawTitle(commit.Summary() + " · " + tool.ShortSHA1(commitID))
|
||||
c.Data["CommitID"] = commitID
|
||||
c.Data["IsSplitStyle"] = c.Query("style") == "split"
|
||||
c.Data["Username"] = userName
|
||||
c.Data["Reponame"] = repoName
|
||||
c.Data["IsImageFile"] = commit.IsImageFile
|
||||
c.Data["IsImageFileByIndex"] = commit.IsImageFileByIndex
|
||||
c.Data["Commit"] = commit
|
||||
c.Data["Author"] = tryGetUserByEmail(c.Req.Context(), commit.Author.Email)
|
||||
c.Data["Diff"] = diff
|
||||
c.Data["Parents"] = parents
|
||||
c.Data["DiffNotAvailable"] = diff.NumFiles() == 0
|
||||
c.Data["SourcePath"] = conf.Server.Subpath + "/" + path.Join(userName, repoName, "src", commitID)
|
||||
c.Data["RawPath"] = conf.Server.Subpath + "/" + path.Join(userName, repoName, "raw", commitID)
|
||||
if commit.ParentsCount() > 0 {
|
||||
c.Data["BeforeSourcePath"] = conf.Server.Subpath + "/" + path.Join(userName, repoName, "src", parents[0])
|
||||
c.Data["BeforeRawPath"] = conf.Server.Subpath + "/" + path.Join(userName, repoName, "raw", parents[0])
|
||||
}
|
||||
c.Success(DIFF)
|
||||
}
|
||||
|
||||
func RawDiff(c *context.Context) {
|
||||
if err := c.Repo.GitRepo.RawDiff(
|
||||
c.Params(":sha"),
|
||||
git.RawDiffFormat(c.Params(":ext")),
|
||||
c.Resp,
|
||||
); err != nil {
|
||||
c.NotFoundOrError(gitx.NewError(err), "get raw diff")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type userCommit struct {
|
||||
User *database.User
|
||||
*git.Commit
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"gogs.io/gogs/internal/conf"
|
||||
"gogs.io/gogs/internal/context"
|
||||
"gogs.io/gogs/internal/gitx"
|
||||
"gogs.io/gogs/internal/tool"
|
||||
)
|
||||
|
||||
@@ -43,16 +42,3 @@ func ServeBlob(c *context.Context, blob *git.Blob) error {
|
||||
|
||||
return serveData(c, path.Base(c.Repo.TreePath), p)
|
||||
}
|
||||
|
||||
func SingleDownload(c *context.Context) {
|
||||
blob, err := c.Repo.Commit.Blob(c.Repo.TreePath)
|
||||
if err != nil {
|
||||
c.NotFoundOrError(gitx.NewError(err), "get blob")
|
||||
return
|
||||
}
|
||||
|
||||
if err = ServeBlob(c, blob); err != nil {
|
||||
c.Error(err, "serve blob")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+497
@@ -16,9 +16,18 @@ importers:
|
||||
'@fontsource-variable/geist-mono':
|
||||
specifier: ^5.2.8
|
||||
version: 5.2.8
|
||||
'@pierre/diffs':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@pierre/trees':
|
||||
specifier: 1.0.0-beta.4
|
||||
version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-checkbox':
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.8
|
||||
version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -34,6 +43,12 @@ importers:
|
||||
'@radix-ui/react-toggle-group':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.100.14
|
||||
version: 5.100.14(react@19.2.6)
|
||||
'@tanstack/react-router':
|
||||
specifier: ^1.137.0
|
||||
version: 1.170.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -58,6 +73,9 @@ importers:
|
||||
react-i18next:
|
||||
specifier: ^17.0.8
|
||||
version: 17.0.8(i18next@26.2.0(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
tailwind-merge:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
@@ -301,6 +319,22 @@ packages:
|
||||
'@oxc-project/types@0.130.0':
|
||||
resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==}
|
||||
|
||||
'@pierre/diffs@1.2.3':
|
||||
resolution: {integrity: sha512-ul83DHH1yqgGxJAw2tqQm2gDO+oQsaF82ZVocwJYfXAm2FhZyyKPTdtv6jswR4A5eF/ILPjiQxyfScMhQcofbA==}
|
||||
peerDependencies:
|
||||
react: ^18.3.1 || ^19.0.0
|
||||
react-dom: ^18.3.1 || ^19.0.0
|
||||
|
||||
'@pierre/theme@1.0.3':
|
||||
resolution: {integrity: sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA==}
|
||||
engines: {vscode: ^1.0.0}
|
||||
|
||||
'@pierre/trees@1.0.0-beta.4':
|
||||
resolution: {integrity: sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag==}
|
||||
peerDependencies:
|
||||
react: ^18.3.1 || ^19.0.0
|
||||
react-dom: ^18.3.1 || ^19.0.0
|
||||
|
||||
'@radix-ui/number@1.1.1':
|
||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||
|
||||
@@ -364,6 +398,19 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dialog@1.1.15':
|
||||
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-direction@1.1.1':
|
||||
resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
|
||||
peerDependencies:
|
||||
@@ -578,6 +625,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8':
|
||||
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
peerDependencies:
|
||||
@@ -767,6 +827,30 @@ packages:
|
||||
'@rtsao/scc@1.1.0':
|
||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||
|
||||
'@shikijs/core@3.23.0':
|
||||
resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
|
||||
|
||||
'@shikijs/engine-javascript@3.23.0':
|
||||
resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
|
||||
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
|
||||
|
||||
'@shikijs/langs@3.23.0':
|
||||
resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
|
||||
|
||||
'@shikijs/themes@3.23.0':
|
||||
resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
|
||||
|
||||
'@shikijs/transformers@3.23.0':
|
||||
resolution: {integrity: sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==}
|
||||
|
||||
'@shikijs/types@3.23.0':
|
||||
resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==}
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||
|
||||
'@tailwindcss/node@4.3.0':
|
||||
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
|
||||
|
||||
@@ -865,6 +949,14 @@ packages:
|
||||
resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
||||
'@tanstack/query-core@5.100.14':
|
||||
resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==}
|
||||
|
||||
'@tanstack/react-query@5.100.14':
|
||||
resolution: {integrity: sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-router@1.170.4':
|
||||
resolution: {integrity: sha512-cusL4YCTuGGJhjfsXEBm6/SmOAs/G8wRVNadeyN3ofu4OZwX69KAybBEf217buxYzI+FohdJVoigEpJV+tGzIw==}
|
||||
engines: {node: '>=20.19'}
|
||||
@@ -913,12 +1005,18 @@ packages:
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/json5@0.0.29':
|
||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
'@types/node@25.9.0':
|
||||
resolution: {integrity: sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==}
|
||||
|
||||
@@ -930,6 +1028,9 @@ packages:
|
||||
'@types/react@19.2.14':
|
||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.59.4':
|
||||
resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -989,6 +1090,9 @@ packages:
|
||||
resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@ungap/structured-clone@1.3.1':
|
||||
resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
|
||||
|
||||
'@vitejs/plugin-react@6.0.2':
|
||||
resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1093,6 +1197,15 @@ packages:
|
||||
caniuse-lite@1.0.30001793:
|
||||
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
character-entities-html4@2.1.0:
|
||||
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
|
||||
|
||||
character-entities-legacy@3.0.0:
|
||||
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
@@ -1100,6 +1213,9 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
@@ -1156,6 +1272,10 @@ packages:
|
||||
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1163,6 +1283,13 @@ packages:
|
||||
detect-node-es@1.1.0:
|
||||
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
||||
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
diff@8.0.3:
|
||||
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
doctrine@2.1.0:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1415,6 +1542,12 @@ packages:
|
||||
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
|
||||
|
||||
hast-util-whitespace@3.0.0:
|
||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||
|
||||
hermes-estree@0.25.1:
|
||||
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
||||
|
||||
@@ -1424,6 +1557,9 @@ packages:
|
||||
html-parse-stringify@3.0.1:
|
||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
i18next@26.2.0:
|
||||
resolution: {integrity: sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==}
|
||||
peerDependencies:
|
||||
@@ -1678,6 +1814,9 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
lru_map@0.4.1:
|
||||
resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
|
||||
|
||||
lucide-react@1.16.0:
|
||||
resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==}
|
||||
peerDependencies:
|
||||
@@ -1690,6 +1829,24 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mdast-util-to-hast@13.2.1:
|
||||
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
|
||||
|
||||
micromark-util-character@2.1.1:
|
||||
resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
|
||||
|
||||
micromark-util-encode@2.0.1:
|
||||
resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
|
||||
|
||||
micromark-util-sanitize-uri@2.0.1:
|
||||
resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
|
||||
|
||||
micromark-util-symbol@2.0.1:
|
||||
resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
|
||||
|
||||
micromark-util-types@2.0.2:
|
||||
resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
|
||||
|
||||
minimatch@10.2.5:
|
||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -1750,6 +1907,12 @@ packages:
|
||||
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
oniguruma-parser@0.12.2:
|
||||
resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
|
||||
|
||||
oniguruma-to-es@4.3.6:
|
||||
resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1798,6 +1961,14 @@ packages:
|
||||
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
preact-render-to-string@6.6.5:
|
||||
resolution: {integrity: sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==}
|
||||
peerDependencies:
|
||||
preact: '>=10 || >= 11.0.0-0'
|
||||
|
||||
preact@11.0.0-beta.0:
|
||||
resolution: {integrity: sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==}
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1807,6 +1978,9 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1870,6 +2044,15 @@ packages:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
regex-utilities@2.3.0:
|
||||
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
|
||||
|
||||
regex@6.1.0:
|
||||
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
|
||||
|
||||
regexp.prototype.flags@1.5.4:
|
||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1938,6 +2121,9 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shiki@3.23.0:
|
||||
resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1954,10 +2140,19 @@ packages:
|
||||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
sonner@2.0.7:
|
||||
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
space-separated-tokens@2.0.2:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1974,6 +2169,9 @@ packages:
|
||||
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||
|
||||
strip-bom@3.0.0:
|
||||
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1996,6 +2194,9 @@ packages:
|
||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
trim-lines@3.0.1:
|
||||
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
||||
|
||||
ts-api-utils@2.5.0:
|
||||
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
||||
engines: {node: '>=18.12'}
|
||||
@@ -2050,6 +2251,21 @@ packages:
|
||||
undici-types@7.24.6:
|
||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
|
||||
|
||||
unist-util-position@5.0.0:
|
||||
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
|
||||
|
||||
unist-util-stringify-position@4.0.0:
|
||||
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
|
||||
|
||||
unist-util-visit-parents@6.0.2:
|
||||
resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
|
||||
|
||||
unist-util-visit@5.1.0:
|
||||
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
|
||||
|
||||
update-browserslist-db@1.2.3:
|
||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||
hasBin: true
|
||||
@@ -2084,6 +2300,12 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
vfile-message@4.0.3:
|
||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||
|
||||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
vite@8.0.13:
|
||||
resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -2172,6 +2394,9 @@ packages:
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
@@ -2391,6 +2616,26 @@ snapshots:
|
||||
|
||||
'@oxc-project/types@0.130.0': {}
|
||||
|
||||
'@pierre/diffs@1.2.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@pierre/theme': 1.0.3
|
||||
'@shikijs/transformers': 3.23.0
|
||||
diff: 8.0.3
|
||||
hast-util-to-html: 9.0.5
|
||||
lru_map: 0.4.1
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
shiki: 3.23.0
|
||||
|
||||
'@pierre/theme@1.0.3': {}
|
||||
|
||||
'@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
preact: 11.0.0-beta.0
|
||||
preact-render-to-string: 6.6.5(preact@11.0.0-beta.0)
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
@@ -2444,6 +2689,28 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.6)':
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
@@ -2661,6 +2928,26 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.6)':
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
@@ -2779,6 +3066,44 @@ snapshots:
|
||||
|
||||
'@rtsao/scc@1.1.0': {}
|
||||
|
||||
'@shikijs/core@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 4.3.6
|
||||
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/langs@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
|
||||
'@shikijs/themes@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
|
||||
'@shikijs/transformers@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/core': 3.23.0
|
||||
'@shikijs/types': 3.23.0
|
||||
|
||||
'@shikijs/types@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2': {}
|
||||
|
||||
'@tailwindcss/node@4.3.0':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
@@ -2849,6 +3174,13 @@ snapshots:
|
||||
|
||||
'@tanstack/history@1.162.0': {}
|
||||
|
||||
'@tanstack/query-core@5.100.14': {}
|
||||
|
||||
'@tanstack/react-query@5.100.14(react@19.2.6)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.100.14
|
||||
react: 19.2.6
|
||||
|
||||
'@tanstack/react-router@1.170.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@tanstack/history': 1.162.0
|
||||
@@ -2897,10 +3229,18 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/json5@0.0.29': {}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/node@25.9.0':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
@@ -2913,6 +3253,8 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
@@ -3004,6 +3346,8 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.59.4
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@ungap/structured-clone@1.3.1': {}
|
||||
|
||||
'@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
@@ -3128,12 +3472,20 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001793: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
character-entities-html4@2.1.0: {}
|
||||
|
||||
character-entities-legacy@3.0.0: {}
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
@@ -3188,10 +3540,18 @@ snapshots:
|
||||
has-property-descriptors: 1.0.2
|
||||
object-keys: 1.1.1
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
|
||||
devlop@1.1.0:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
diff@8.0.3: {}
|
||||
|
||||
doctrine@2.1.0:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
@@ -3532,6 +3892,24 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
ccount: 2.0.1
|
||||
comma-separated-tokens: 2.0.3
|
||||
hast-util-whitespace: 3.0.0
|
||||
html-void-elements: 3.0.0
|
||||
mdast-util-to-hast: 13.2.1
|
||||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
stringify-entities: 4.0.4
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-whitespace@3.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
hermes-estree@0.25.1: {}
|
||||
|
||||
hermes-parser@0.25.1:
|
||||
@@ -3542,6 +3920,8 @@ snapshots:
|
||||
dependencies:
|
||||
void-elements: 3.1.0
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
i18next@26.2.0(typescript@6.0.3):
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
@@ -3758,6 +4138,8 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lru_map@0.4.1: {}
|
||||
|
||||
lucide-react@1.16.0(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
@@ -3768,6 +4150,35 @@ snapshots:
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdast-util-to-hast@13.2.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.4
|
||||
'@ungap/structured-clone': 1.3.1
|
||||
devlop: 1.1.0
|
||||
micromark-util-sanitize-uri: 2.0.1
|
||||
trim-lines: 3.0.1
|
||||
unist-util-position: 5.0.0
|
||||
unist-util-visit: 5.1.0
|
||||
vfile: 6.0.3
|
||||
|
||||
micromark-util-character@2.1.1:
|
||||
dependencies:
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-util-encode@2.0.1: {}
|
||||
|
||||
micromark-util-sanitize-uri@2.0.1:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-encode: 2.0.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
|
||||
micromark-util-symbol@2.0.1: {}
|
||||
|
||||
micromark-util-types@2.0.2: {}
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.6
|
||||
@@ -3837,6 +4248,14 @@ snapshots:
|
||||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
oniguruma-parser@0.12.2: {}
|
||||
|
||||
oniguruma-to-es@4.3.6:
|
||||
dependencies:
|
||||
oniguruma-parser: 0.12.2
|
||||
regex: 6.1.0
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -3884,10 +4303,18 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
preact-render-to-string@6.6.5(preact@11.0.0-beta.0):
|
||||
dependencies:
|
||||
preact: 11.0.0-beta.0
|
||||
|
||||
preact@11.0.0-beta.0: {}
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier@3.8.3: {}
|
||||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
react-dom@19.2.6(react@19.2.6):
|
||||
@@ -3946,6 +4373,16 @@ snapshots:
|
||||
get-proto: 1.0.1
|
||||
which-builtin-type: 1.2.1
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
regex-utilities@2.3.0: {}
|
||||
|
||||
regex@6.1.0:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
regexp.prototype.flags@1.5.4:
|
||||
dependencies:
|
||||
call-bind: 1.0.9
|
||||
@@ -4044,6 +4481,17 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shiki@3.23.0:
|
||||
dependencies:
|
||||
'@shikijs/core': 3.23.0
|
||||
'@shikijs/engine-javascript': 3.23.0
|
||||
'@shikijs/engine-oniguruma': 3.23.0
|
||||
'@shikijs/langs': 3.23.0
|
||||
'@shikijs/themes': 3.23.0
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -4072,8 +4520,15 @@ snapshots:
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
sonner@2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -4102,6 +4557,11 @@ snapshots:
|
||||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
dependencies:
|
||||
character-entities-html4: 2.1.0
|
||||
character-entities-legacy: 3.0.0
|
||||
|
||||
strip-bom@3.0.0: {}
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
@@ -4117,6 +4577,8 @@ snapshots:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
trim-lines@3.0.1: {}
|
||||
|
||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
@@ -4191,6 +4653,29 @@ snapshots:
|
||||
|
||||
undici-types@7.24.6: {}
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
unist-util-position@5.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
unist-util-stringify-position@4.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
unist-util-visit-parents@6.0.2:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-is: 6.0.1
|
||||
|
||||
unist-util-visit@5.1.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.2):
|
||||
dependencies:
|
||||
browserslist: 4.28.2
|
||||
@@ -4220,6 +4705,16 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
|
||||
vfile-message@4.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-stringify-position: 4.0.0
|
||||
|
||||
vfile@6.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vite@8.0.13(@types/node@25.9.0)(jiti@2.7.0):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
@@ -4290,3 +4785,5 @@ snapshots:
|
||||
zod: 4.4.3
|
||||
|
||||
zod@4.4.3: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
+15
-1
@@ -35,6 +35,8 @@ Use these tokens. Don't introduce raw hex values in components.
|
||||
- `--color-primary` / `--color-primary-foreground`: brand blue (`#009fff` in both modes). Reserved for genuine brand emphasis. Don't use it to mean "primary action" between two peer links (see the peer-item rule below). Note: white-on-primary contrast is 2.84:1, which is below WCAG AA in both modes since the token is identical light and dark. Avoid using primary as a fill for body-sized text. Use it for chrome accents, ring/focus, and large CTAs only.
|
||||
- `--color-secondary` / `--color-secondary-foreground`: neutral support fill. Available for chips, tags, low-emphasis fills.
|
||||
- `--color-destructive` / `--color-destructive-foreground`: error and danger. The 404 page uses `text-(--color-destructive)` on the `fatal:` token, always paired with the word itself (color is never the sole signal).
|
||||
- `--color-success`: affirmative state for signature verification badges and copy-confirm checkmarks. Lighter in dark mode (`#4ade80`) than light (`#15803d`) so it reads on both backgrounds. Always pair with a label or icon, never color alone.
|
||||
- `--color-diff-added` / `--color-diff-removed`: diff change markers (the +/- dots in the diff toolbar stats row, and any future per-line tints). Separate from `--color-success`/`--color-destructive` so the diff palette can drift toward the universal git green/red without dragging the success/error semantics along.
|
||||
- `--color-ring`: keyboard focus ring color. Don't override per-component. If a default ring looks wrong, fix it at the token level.
|
||||
|
||||
**Structure**
|
||||
@@ -75,6 +77,18 @@ Anchor links inside the form aren't covered by `disabled`. For each, set `tabInd
|
||||
|
||||
Swap the submit label to a present-continuous string ("Signing in…", "Verifying…") while submitting. Keep idle and active strings as separate locale keys.
|
||||
|
||||
## Interactive affordances
|
||||
|
||||
Tailwind 4's preflight removes the browser default `cursor: pointer` on `<button>`. Without it, controls visually read as static text. Apply `cursor-pointer` on every interactive element that isn't a plain link: buttons, custom clickable rows, menu triggers, anything whose `onClick` activates an action. `<a href>` keeps the link cursor automatically.
|
||||
|
||||
When in doubt, hover-test the new control: if the cursor is still an I-beam or arrow, add `cursor-pointer`.
|
||||
|
||||
## Tooltips
|
||||
|
||||
Use the `Tooltip` component from `@/components/ui/tooltip` (built on `@radix-ui/react-tooltip`) for any hover hint. Never use the native HTML `title` attribute: it renders unstyled, has inconsistent timing across browsers, and is invisible on touch. The tooltip provider lives at the router root, so `Tooltip`/`TooltipTrigger`/`TooltipContent` work anywhere downstream.
|
||||
|
||||
The tooltip is supplementary information, not a substitute for an accessible name. Icon-only buttons still need `aria-label`. The tooltip just makes the same label visible to sighted users.
|
||||
|
||||
## Accessibility
|
||||
|
||||
WCAG 2.2 AA is the floor. Apply these patterns in components:
|
||||
@@ -83,7 +97,7 @@ WCAG 2.2 AA is the floor. Apply these patterns in components:
|
||||
- **Decorative icons inside a labeled control** get `aria-hidden`. If the button already has visible text or a sibling label, mark the SVG `aria-hidden` so screen readers don't double-announce.
|
||||
- **Interactive states must be reachable by keyboard.** Anything that handles `onClick` must also be focusable (use a `<button>` or `<a>`, not a `<div>`). Tab order should follow visual order. Esc closes overlays. Click-outside also closes overlays, but Esc is mandatory and click-outside is convenience.
|
||||
- **Don't disable focus rings.** If the default ring is visually wrong, restyle via `--color-ring` or `focus-visible:` utilities. Never remove it. Sighted keyboard users need to see where focus is.
|
||||
- **Touch targets are 24×24 CSS px at minimum.** Compact chrome (settings cog, hamburger) uses `size-9` (36px). Full-width tap rows in popovers and the mobile menu use `px-2 py-1.5`, which yields ~28px in height. The full row width gives the tap area enough horizontal slack to clear the minimum comfortably.
|
||||
- **Touch targets are 24×24 CSS px at minimum.** Compact chrome (settings cog, hamburger) uses `size-9` (36px). Full-width tap rows in popovers and the mobile menu use `px-2 py-1.5`, which yields ~28px in height. The full row width gives the tap area enough horizontal slack to clear the minimum comfortably. Exception: edge-seam affordances like the resize handle in `ResizableSidebar` stay visually thin (4–8px) and rely on a keyboard fallback (`tabIndex={0}` plus arrow-key handlers) for accessibility. The handle is desktop-only (`lg:block`), so the 24px tap floor for touch users does not apply.
|
||||
- **Color is never the sole signal.** The current-item indicator in the language list is a ✓ icon, not just a color shift. The destructive token is paired with the word `fatal:` in the 404 terminal, not just red text. The theme toggle has Sun/Moon/Monitor icons alongside the label.
|
||||
- **Respect `prefers-reduced-motion`.** Popover animations from `tw-animate-css` honor this by default. If hand-rolling animations, gate them behind `motion-safe:`.
|
||||
- **Test before merging:** tab through the new UI with the keyboard only; resize to 375px; toggle dark mode; check focus rings are visible against both themes.
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html class="h-full">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@@ -27,7 +27,7 @@
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="h-full">
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -14,12 +14,17 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@fontsource-variable/geist-mono": "^5.2.8",
|
||||
"@pierre/diffs": "^1.2.3",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-router": "^1.137.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -28,6 +33,7 @@
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
|
||||
+157
-66
@@ -12,7 +12,7 @@ const outDir = join(here, "..", "src/locales");
|
||||
|
||||
// Keys pulled from Gogs's INI files. Add new entries here when the SPA needs
|
||||
// another translation. Locales missing a key fall back to en-US via react-i18next.
|
||||
const REUSED_KEYS = [
|
||||
const KEYS = [
|
||||
"app_desc",
|
||||
"home",
|
||||
"dashboard",
|
||||
@@ -34,8 +34,7 @@ const REUSED_KEYS = [
|
||||
"admin_panel",
|
||||
"settings",
|
||||
"language",
|
||||
"page_not_found",
|
||||
"internal_server_error",
|
||||
"repository",
|
||||
"theme",
|
||||
"theme_light",
|
||||
"theme_dark",
|
||||
@@ -52,78 +51,170 @@ const REUSED_KEYS = [
|
||||
"captcha_image_alt",
|
||||
"refresh_captcha",
|
||||
"click_to_refresh_captcha",
|
||||
"auth_source",
|
||||
"local",
|
||||
"forget_password",
|
||||
"send_reset_email",
|
||||
"reset_password_email_submitting",
|
||||
"reset_password_email_failed",
|
||||
"reset_password_email_sent",
|
||||
"disable_register_mail",
|
||||
"disable_register_prompt",
|
||||
"reset_password_resend_limited",
|
||||
"non_local_account",
|
||||
"create_new_account",
|
||||
"register_hepler_msg",
|
||||
"sign_up",
|
||||
"sign_up_now",
|
||||
"sign_up_submitting",
|
||||
"sign_up_failed",
|
||||
"sign_in_submitting",
|
||||
"sign_in_failed",
|
||||
"show_password",
|
||||
"hide_password",
|
||||
"back_to_sign_in",
|
||||
"reset_password",
|
||||
"invalid_code",
|
||||
"reset_password_submit",
|
||||
"reset_password_submitting",
|
||||
"reset_password_failed",
|
||||
"new_password",
|
||||
"new_password_placeholder",
|
||||
"confirm_password",
|
||||
"confirm_password_placeholder",
|
||||
"confirm_new_password",
|
||||
"confirm_new_password_placeholder",
|
||||
"password_mismatch",
|
||||
"mfa_title",
|
||||
"mfa_passcode",
|
||||
"mfa_passcode_placeholder",
|
||||
"mfa_recovery_code",
|
||||
"mfa_recovery_code_placeholder",
|
||||
"mfa_use_recovery_code",
|
||||
"mfa_use_passcode",
|
||||
"mfa_verify",
|
||||
"mfa_verifying",
|
||||
"mfa_session_expired",
|
||||
"mfa_verify_failed",
|
||||
"activate_your_account",
|
||||
"resend_rate_limited",
|
||||
"send_activation_email",
|
||||
"check_activation_email",
|
||||
"activation_email_pending",
|
||||
"activation_email_sent",
|
||||
"sending_activation_email",
|
||||
"send_activation_email_failed",
|
||||
"activating_account",
|
||||
"close",
|
||||
"show_more",
|
||||
"show_less",
|
||||
"resize_sidebar",
|
||||
"more",
|
||||
"more_tabs",
|
||||
"more_actions",
|
||||
"status.page_not_found",
|
||||
"status.internal_server_error",
|
||||
"auth.auth_source",
|
||||
"auth.local",
|
||||
"auth.forget_password",
|
||||
"auth.send_reset_email",
|
||||
"auth.reset_password_email_submitting",
|
||||
"auth.reset_password_email_failed",
|
||||
"auth.reset_password_email_sent",
|
||||
"auth.disable_register_mail",
|
||||
"auth.disable_register_prompt",
|
||||
"auth.reset_password_resend_limited",
|
||||
"auth.non_local_account",
|
||||
"auth.create_new_account",
|
||||
"auth.register_hepler_msg",
|
||||
"auth.sign_up_now",
|
||||
"auth.sign_up_submitting",
|
||||
"auth.sign_up_failed",
|
||||
"auth.sign_in_submitting",
|
||||
"auth.sign_in_failed",
|
||||
"auth.show_password",
|
||||
"auth.hide_password",
|
||||
"auth.back_to_sign_in",
|
||||
"auth.reset_password",
|
||||
"auth.invalid_code",
|
||||
"auth.reset_password_submit",
|
||||
"auth.reset_password_submitting",
|
||||
"auth.reset_password_failed",
|
||||
"auth.new_password",
|
||||
"auth.new_password_placeholder",
|
||||
"auth.confirm_password",
|
||||
"auth.confirm_password_placeholder",
|
||||
"auth.confirm_new_password",
|
||||
"auth.confirm_new_password_placeholder",
|
||||
"auth.password_mismatch",
|
||||
"auth.mfa_title",
|
||||
"auth.mfa_passcode",
|
||||
"auth.mfa_passcode_placeholder",
|
||||
"auth.mfa_recovery_code",
|
||||
"auth.mfa_recovery_code_placeholder",
|
||||
"auth.mfa_use_recovery_code",
|
||||
"auth.mfa_use_passcode",
|
||||
"auth.mfa_verify",
|
||||
"auth.mfa_verifying",
|
||||
"auth.mfa_session_expired",
|
||||
"auth.mfa_verify_failed",
|
||||
"auth.activate_your_account",
|
||||
"auth.resend_rate_limited",
|
||||
"auth.send_activation_email",
|
||||
"auth.check_activation_email",
|
||||
"auth.activation_email_pending",
|
||||
"auth.activation_email_sent",
|
||||
"auth.sending_activation_email",
|
||||
"auth.send_activation_email_failed",
|
||||
"auth.activating_account",
|
||||
"tool.now",
|
||||
"tool.ago",
|
||||
"tool.from_now",
|
||||
"tool.1s",
|
||||
"tool.1m",
|
||||
"tool.1h",
|
||||
"tool.1d",
|
||||
"tool.1w",
|
||||
"tool.1mon",
|
||||
"tool.1y",
|
||||
"tool.seconds",
|
||||
"tool.minutes",
|
||||
"tool.hours",
|
||||
"tool.days",
|
||||
"tool.weeks",
|
||||
"tool.months",
|
||||
"tool.years",
|
||||
"repo.diff.showing_changed_files",
|
||||
"repo.diff.additions",
|
||||
"repo.diff.deletions",
|
||||
"repo.diff.unified",
|
||||
"repo.diff.split",
|
||||
"repo.diff.diff_settings",
|
||||
"repo.diff.whitespace",
|
||||
"repo.diff.show_whitespace",
|
||||
"repo.diff.ignore_whitespace_changes",
|
||||
"repo.diff.ignore_all_whitespace",
|
||||
"repo.diff.display",
|
||||
"repo.diff.wrap_long_lines",
|
||||
"repo.diff.expand_all_files",
|
||||
"repo.diff.collapse_all_files",
|
||||
"repo.show_file_tree",
|
||||
"repo.hide_file_tree",
|
||||
"repo.expand_all_directories",
|
||||
"repo.collapse_all_directories",
|
||||
"repo.search_files",
|
||||
"repo.search_hide",
|
||||
"repo.search_diff",
|
||||
"repo.search_previous_match",
|
||||
"repo.search_next_match",
|
||||
"repo.diff.expand_file",
|
||||
"repo.diff.collapse_file",
|
||||
"repo.diff.expand_all_lines",
|
||||
"repo.diff.all_lines_expanded",
|
||||
"repo.commit_parent",
|
||||
"repo.commit_label",
|
||||
"repo.view_file",
|
||||
"repo.editor.edit_file",
|
||||
"repo.editor.delete_this_file",
|
||||
"repo.files",
|
||||
"repo.settings",
|
||||
"repo.wiki",
|
||||
"repo.watch",
|
||||
"repo.unwatch",
|
||||
"repo.star",
|
||||
"repo.starred",
|
||||
"repo.fork",
|
||||
"repo.mirror_of",
|
||||
"repo.sign_in_to_watch",
|
||||
"repo.sign_in_to_star",
|
||||
"repo.sign_in_to_fork",
|
||||
"repo.watch_this_repository",
|
||||
"repo.unwatch_this_repository",
|
||||
"repo.star_this_repository",
|
||||
"repo.unstar_this_repository",
|
||||
"repo.fork_this_repository",
|
||||
"repo.visibility_private",
|
||||
"repo.visibility_public",
|
||||
"repo.view_watchers",
|
||||
"repo.view_stargazers",
|
||||
"repo.view_forks",
|
||||
"repo.browse_files",
|
||||
"repo.view_history",
|
||||
"repo.view_raw",
|
||||
"repo.copy_file_path",
|
||||
"repo.copy_full_sha",
|
||||
"repo.renamed_from",
|
||||
"repo.authored",
|
||||
"repo.parents",
|
||||
"repo.diff_label",
|
||||
"repo.patch_label",
|
||||
];
|
||||
|
||||
// Lightweight INI parser: handles `key = value` and `key=value`, ignores
|
||||
// comments, and flattens sections into a single namespace. Gogs's locale
|
||||
// files group keys under sections like [status] (e.g. status.page_not_found
|
||||
// resolves to a key named "page_not_found" inside [status]), but downstream
|
||||
// callers reference keys by their bare name, so the section header is
|
||||
// dropped here. First occurrence wins on collisions.
|
||||
// Parse the INI into a single `section.key` to value map. Top-level keys
|
||||
// (above any section header) are stored bare. First occurrence wins.
|
||||
function parseIni(text) {
|
||||
const out = {};
|
||||
let section = "";
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith(";") || line.startsWith("#") || line.startsWith("[")) continue;
|
||||
if (!line || line.startsWith(";") || line.startsWith("#")) continue;
|
||||
if (line.startsWith("[") && line.endsWith("]")) {
|
||||
section = line.slice(1, -1).trim();
|
||||
continue;
|
||||
}
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
const value = line.slice(eq + 1).trim();
|
||||
if (key && !(key in out)) out[key] = value;
|
||||
if (!key) continue;
|
||||
const qualified = section ? `${section}.${key}` : key;
|
||||
if (!(qualified in out)) out[qualified] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -135,7 +226,7 @@ for (const file of files) {
|
||||
const lang = file.slice("locale_".length, -".ini".length);
|
||||
const parsed = parseIni(readFileSync(join(inDir, file), "utf8"));
|
||||
const out = {};
|
||||
for (const key of REUSED_KEYS) {
|
||||
for (const key of KEYS) {
|
||||
if (parsed[key]) out[key] = parsed[key];
|
||||
}
|
||||
writeFileSync(join(outDir, `${lang}.json`), JSON.stringify(out, null, 2) + "\n", "utf8");
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { ChangeTypes } from "@pierre/diffs";
|
||||
import type { CodeViewItem } from "@pierre/diffs/react";
|
||||
import type {
|
||||
FileTreeDirectoryHandle,
|
||||
FileTreeIcons,
|
||||
FileTreeItemHandle,
|
||||
GitStatus,
|
||||
GitStatusEntry,
|
||||
} from "@pierre/trees";
|
||||
import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
interface Props {
|
||||
items: readonly CodeViewItem[];
|
||||
// Selecting a row in the tree fires this with the CodeViewItem id so the
|
||||
// page can drive the diff view's scroll. The callback abstraction lets us
|
||||
// avoid plumbing the diff view's `LAnnotation` generic through forwardRef.
|
||||
onSelectItem: (itemId: string) => void;
|
||||
// Show or hide Pierre's built-in search row. The model itself stays around,
|
||||
// so toggling does not lose tree state.
|
||||
searchOpen?: boolean;
|
||||
header?: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
// Pierre's FileTree renders into a shadow root. We can inject CSS via the
|
||||
// `unsafeCSS` option, and gate the search row's visibility off a host
|
||||
// attribute we set via the spread `data-*` prop on `<FileTree>`. That gives
|
||||
// us a CSS-only toggle without remounting the tree.
|
||||
const TREE_UNSAFE_CSS = `
|
||||
:host([data-search-open="false"]) [data-file-tree-search-input],
|
||||
:host([data-search-open="false"]) [data-file-tree-search-input]
|
||||
~ *:not([data-file-tree-list]):not([role="tree"]) {
|
||||
display: none !important;
|
||||
}
|
||||
/* The search input lives inside a wrapper row; hide that whole row when
|
||||
closed so we don't leave an empty band of padding. */
|
||||
:host([data-search-open="false"]) [data-file-tree-search] {
|
||||
display: none !important;
|
||||
}
|
||||
[data-file-tree-search],
|
||||
[data-file-tree-search-input] {
|
||||
margin-top: 6px;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface DiffFileTreeHandle {
|
||||
expandAll(): void;
|
||||
collapseAll(): void;
|
||||
focusSearch(): void;
|
||||
}
|
||||
|
||||
// Pierre's icon sets ship with file-type glyphs and chevrons. `standard`
|
||||
// covers most common file types. `colored` adds the semantic per-type tint
|
||||
// that makes 50+ files in a list scannable.
|
||||
const ICON_OPTIONS: FileTreeIcons = { set: "standard", colored: true };
|
||||
|
||||
// Map `@pierre/diffs` change types onto the tree's git status vocabulary.
|
||||
// `rename-pure` and `rename-changed` both surface as `renamed` (the tree only
|
||||
// uses status for the colored row marker).
|
||||
function diffTypeToStatus(t: ChangeTypes): GitStatus | null {
|
||||
switch (t) {
|
||||
case "new":
|
||||
return "added";
|
||||
case "deleted":
|
||||
return "deleted";
|
||||
case "change":
|
||||
return "modified";
|
||||
case "rename-pure":
|
||||
case "rename-changed":
|
||||
return "renamed";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// `isDirectory()` is declared `boolean` on the union base, so TS won't
|
||||
// narrow `FileTreeItemHandle` to `FileTreeDirectoryHandle` from a call-site
|
||||
// check alone. This guard does the discrimination explicitly.
|
||||
function asDirectoryHandle(handle: FileTreeItemHandle | null | undefined): FileTreeDirectoryHandle | null {
|
||||
return handle && handle.isDirectory() ? (handle as FileTreeDirectoryHandle) : null;
|
||||
}
|
||||
|
||||
// Pierre keeps its search input inside its shadow root, and may nest shadow
|
||||
// roots further. Recurse depth-first across both light-DOM children and any
|
||||
// shadow root attached to a descendant, returning the first matching input.
|
||||
function findSearchInput(root: ParentNode | null | undefined): HTMLInputElement | null {
|
||||
if (!root) return null;
|
||||
const direct = root.querySelector<HTMLInputElement>("input[data-file-tree-search-input]");
|
||||
if (direct) return direct;
|
||||
const descendants = root.querySelectorAll("*");
|
||||
for (const el of descendants) {
|
||||
const sr = (el as Element & { shadowRoot?: ShadowRoot | null }).shadowRoot;
|
||||
if (!sr) continue;
|
||||
const inner = findSearchInput(sr);
|
||||
if (inner) return inner;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Walk path segments to collect every directory prefix (e.g. "a/b/c.ts" →
|
||||
// ["a", "a/b"]). Used to drive expand-all/collapse-all from the toolbar
|
||||
// because @pierre/trees doesn't ship a one-shot bulk-toggle API.
|
||||
function collectDirectoryPaths(paths: readonly string[]): string[] {
|
||||
const dirs = new Set<string>();
|
||||
for (const p of paths) {
|
||||
const parts = p.split("/");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
dirs.add(parts.slice(0, i).join("/"));
|
||||
}
|
||||
}
|
||||
return Array.from(dirs);
|
||||
}
|
||||
|
||||
export const DiffFileTree = forwardRef<DiffFileTreeHandle, Props>(function DiffFileTreeImpl(
|
||||
{ items, onSelectItem, searchOpen = true, header, className, style },
|
||||
ref,
|
||||
) {
|
||||
const { paths, gitStatus, pathToItemId } = useMemo(() => {
|
||||
const collectedPaths: string[] = [];
|
||||
const status: GitStatusEntry[] = [];
|
||||
const map = new Map<string, string>();
|
||||
for (const item of items) {
|
||||
if (item.type !== "diff") continue;
|
||||
const path = item.fileDiff.name;
|
||||
collectedPaths.push(path);
|
||||
map.set(path, item.id);
|
||||
const s = diffTypeToStatus(item.fileDiff.type);
|
||||
if (s) status.push({ path, status: s });
|
||||
}
|
||||
return { paths: collectedPaths, gitStatus: status, pathToItemId: map };
|
||||
}, [items]);
|
||||
|
||||
const directoryPaths = useMemo(() => collectDirectoryPaths(paths), [paths]);
|
||||
|
||||
// Stable refs so the `onSelectionChange` closure passed to `useFileTree`
|
||||
// (which captures only on first render) always sees the latest path map
|
||||
// and select handler.
|
||||
const pathToItemIdRef = useRef(pathToItemId);
|
||||
const onSelectItemRef = useRef(onSelectItem);
|
||||
useEffect(() => {
|
||||
pathToItemIdRef.current = pathToItemId;
|
||||
}, [pathToItemId]);
|
||||
useEffect(() => {
|
||||
onSelectItemRef.current = onSelectItem;
|
||||
}, [onSelectItem]);
|
||||
|
||||
// Set when a row is clicked, then consumed by the search-restore effect so
|
||||
// we only reopen search on selection (not on blur from clicks outside).
|
||||
const selectionJustFiredRef = useRef(false);
|
||||
const onSelectionChange = useCallback((selectedPaths: readonly string[]) => {
|
||||
const target = selectedPaths[0];
|
||||
if (!target) return;
|
||||
const id = pathToItemIdRef.current.get(target);
|
||||
if (!id) return;
|
||||
selectionJustFiredRef.current = true;
|
||||
onSelectItemRef.current(id);
|
||||
}, []);
|
||||
|
||||
// Build a comparator that orders tree rows the same way the patch lists
|
||||
// them, so the file tree always agrees with what `CodeView` renders below.
|
||||
// Without this, Pierre's default alpha sort places root-level files
|
||||
// (e.g. go.mod, go.sum) in a different spot than the diff body shows them.
|
||||
// Each directory's index is the patch index of the earliest file under it,
|
||||
// so a directory sorts to the position of its first appearing child.
|
||||
const patchOrder = useMemo(() => {
|
||||
const fileIdx = new Map<string, number>();
|
||||
paths.forEach((p, i) => fileIdx.set(p, i));
|
||||
const dirIdx = new Map<string, number>();
|
||||
for (const [p, i] of fileIdx) {
|
||||
const parts = p.split("/");
|
||||
for (let n = 1; n < parts.length; n++) {
|
||||
const dir = parts.slice(0, n).join("/");
|
||||
const existing = dirIdx.get(dir);
|
||||
if (existing === undefined || i < existing) dirIdx.set(dir, i);
|
||||
}
|
||||
}
|
||||
return { fileIdx, dirIdx };
|
||||
}, [paths]);
|
||||
|
||||
const sortComparator = useCallback(
|
||||
(a: { path: string; isDirectory: boolean }, b: { path: string; isDirectory: boolean }) => {
|
||||
// Unknown rows fall through to the end. A `?? 0` default would tie them
|
||||
// with the patch's first entry, silently floating new or flattened
|
||||
// paths to the top during a `resetPaths` transition.
|
||||
const ai = a.isDirectory ? patchOrder.dirIdx.get(a.path) : patchOrder.fileIdx.get(a.path);
|
||||
const bi = b.isDirectory ? patchOrder.dirIdx.get(b.path) : patchOrder.fileIdx.get(b.path);
|
||||
return (ai ?? Number.POSITIVE_INFINITY) - (bi ?? Number.POSITIVE_INFINITY);
|
||||
},
|
||||
[patchOrder],
|
||||
);
|
||||
|
||||
const { model } = useFileTree({
|
||||
paths,
|
||||
sort: sortComparator,
|
||||
icons: ICON_OPTIONS,
|
||||
initialExpansion: "open",
|
||||
flattenEmptyDirectories: true,
|
||||
search: true,
|
||||
stickyFolders: true,
|
||||
gitStatus,
|
||||
onSelectionChange,
|
||||
unsafeCSS: TREE_UNSAFE_CSS,
|
||||
});
|
||||
|
||||
// Pierre closes search on row click. Reopen with the last typed value so
|
||||
// the user does not have to retype after navigating to a matched file.
|
||||
// Blur (clicking outside the tree) is intentionally NOT restored. Only
|
||||
// row-click closures are, gated by `selectionJustFiredRef`.
|
||||
//
|
||||
// `lastQueryRef` tracks the last query the user actually intended. It is
|
||||
// updated to any non-empty `search.value`, and cleared to `""` only when
|
||||
// we observe `search.value === ""` *while search is still open* (that is
|
||||
// the user emptying the box). An empty `search.value` that arrives in the
|
||||
// same tick as `isOpen` flipping to `false` is Pierre's auto-clear on row
|
||||
// click and must not wipe the ref.
|
||||
const search = useFileTreeSearch(model);
|
||||
const lastQueryRef = useRef(search.value);
|
||||
// Track `search.open` via an effect rather than render-body assignment so
|
||||
// discarded renders (Strict Mode, concurrent interruptions) cannot leave
|
||||
// the ref pointing at a function captured from a never-committed render.
|
||||
const searchOpenFnRef = useRef(search.open);
|
||||
useEffect(() => {
|
||||
searchOpenFnRef.current = search.open;
|
||||
}, [search.open]);
|
||||
useEffect(() => {
|
||||
if (search.value !== "") {
|
||||
lastQueryRef.current = search.value;
|
||||
} else if (search.isOpen) {
|
||||
lastQueryRef.current = "";
|
||||
}
|
||||
if (!search.isOpen && selectionJustFiredRef.current && lastQueryRef.current !== "") {
|
||||
searchOpenFnRef.current(lastQueryRef.current);
|
||||
}
|
||||
if (!search.isOpen) {
|
||||
selectionJustFiredRef.current = false;
|
||||
}
|
||||
}, [search.value, search.isOpen]);
|
||||
|
||||
// When the patch changes (e.g. navigating to a different commit without
|
||||
// unmounting), reset the tree contents and refresh git status without
|
||||
// recreating the model.
|
||||
const pathsRef = useRef(paths);
|
||||
const statusRef = useRef(gitStatus);
|
||||
useEffect(() => {
|
||||
if (pathsRef.current !== paths) {
|
||||
model.resetPaths(paths);
|
||||
pathsRef.current = paths;
|
||||
}
|
||||
if (statusRef.current !== gitStatus) {
|
||||
model.setGitStatus(gitStatus);
|
||||
statusRef.current = gitStatus;
|
||||
}
|
||||
}, [model, paths, gitStatus]);
|
||||
|
||||
// Wrap the tree so we can reach its host element (and its shadow root) to
|
||||
// imperatively focus the search input on demand.
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
expandAll: () => {
|
||||
for (const dir of directoryPaths) {
|
||||
const handle = asDirectoryHandle(model.getItem(dir));
|
||||
handle?.expand();
|
||||
}
|
||||
},
|
||||
collapseAll: () => {
|
||||
for (const dir of directoryPaths) {
|
||||
const handle = asDirectoryHandle(model.getItem(dir));
|
||||
handle?.collapse();
|
||||
}
|
||||
},
|
||||
focusSearch: () => {
|
||||
const input = findSearchInput(wrapperRef.current);
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
},
|
||||
}),
|
||||
[directoryPaths, model],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={className} style={style}>
|
||||
<FileTree
|
||||
model={model}
|
||||
header={header}
|
||||
data-search-open={searchOpen ? "true" : "false"}
|
||||
style={{ height: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { CodeViewHandle, CodeViewItem } from "@pierre/diffs/react";
|
||||
import { ChevronDown, ChevronUp, Search } from "lucide-react";
|
||||
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Match {
|
||||
itemId: string;
|
||||
side: "additions" | "deletions";
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
// Walk `hunkContent` so context lines (which exist on both `additionLines` and
|
||||
// `deletionLines`) are counted once, not twice. Changes are counted on each
|
||||
// side they actually appear. Guard every array read because a malformed patch
|
||||
// or stale Pierre indices would otherwise crash the whole search panel on
|
||||
// `undefined.toLowerCase()`.
|
||||
function buildMatches(items: readonly CodeViewItem[], query: string): Match[] {
|
||||
if (!query) return [];
|
||||
const needle = query.toLowerCase();
|
||||
const out: Match[] = [];
|
||||
for (const item of items) {
|
||||
if (item.type !== "diff") continue;
|
||||
const { additionLines, deletionLines, hunks } = item.fileDiff;
|
||||
for (const h of hunks) {
|
||||
for (const c of h.hunkContent) {
|
||||
if (c.type === "context") {
|
||||
for (let k = 0; k < c.lines; k++) {
|
||||
const line = additionLines[c.additionLineIndex + k];
|
||||
if (line === undefined) continue;
|
||||
if (line.toLowerCase().includes(needle)) {
|
||||
out.push({
|
||||
itemId: item.id,
|
||||
side: "additions",
|
||||
lineNumber: h.additionStart + (c.additionLineIndex + k - h.additionLineIndex),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let k = 0; k < c.deletions; k++) {
|
||||
const line = deletionLines[c.deletionLineIndex + k];
|
||||
if (line === undefined) continue;
|
||||
if (line.toLowerCase().includes(needle)) {
|
||||
out.push({
|
||||
itemId: item.id,
|
||||
side: "deletions",
|
||||
lineNumber: h.deletionStart + (c.deletionLineIndex + k - h.deletionLineIndex),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (let k = 0; k < c.additions; k++) {
|
||||
const line = additionLines[c.additionLineIndex + k];
|
||||
if (line === undefined) continue;
|
||||
if (line.toLowerCase().includes(needle)) {
|
||||
out.push({
|
||||
itemId: item.id,
|
||||
side: "additions",
|
||||
lineNumber: h.additionStart + (c.additionLineIndex + k - h.additionLineIndex),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Props<L> {
|
||||
items: readonly CodeViewItem[];
|
||||
viewRef: RefObject<CodeViewHandle<L> | null>;
|
||||
}
|
||||
|
||||
export function DiffSearch<L>({ items, viewRef }: Props<L>) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Recompute matches whenever the query (or item set) changes. Debounce-free
|
||||
// because users expect instant feedback while typing.
|
||||
const matches = useMemo(() => buildMatches(items, query), [items, query]);
|
||||
|
||||
// Surface a match by scrolling to it and emphasizing its line on the
|
||||
// underlying CodeView. Returns the clamped index so callers can sync state.
|
||||
const surface = useCallback(
|
||||
(index: number, list: Match[]): number => {
|
||||
const view = viewRef.current;
|
||||
if (!view || list.length === 0) return 0;
|
||||
const safe = ((index % list.length) + list.length) % list.length;
|
||||
const m = list[safe];
|
||||
view.scrollTo({
|
||||
type: "line",
|
||||
id: m.itemId,
|
||||
lineNumber: m.lineNumber,
|
||||
side: m.side,
|
||||
align: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
view.setSelectedLines({
|
||||
id: m.itemId,
|
||||
range: { start: m.lineNumber, end: m.lineNumber, side: m.side, endSide: m.side },
|
||||
});
|
||||
return safe;
|
||||
},
|
||||
[viewRef],
|
||||
);
|
||||
|
||||
// Update query + immediately surface the first match. Doing it inline (not
|
||||
// in an effect) avoids react-hooks/set-state-in-effect and keeps the search
|
||||
// UX feeling synchronous.
|
||||
const updateQuery = useCallback(
|
||||
(next: string) => {
|
||||
setQuery(next);
|
||||
const list = buildMatches(items, next);
|
||||
if (list.length === 0) {
|
||||
viewRef.current?.setSelectedLines(null);
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
setActiveIndex(surface(0, list));
|
||||
},
|
||||
[items, surface, viewRef],
|
||||
);
|
||||
|
||||
const navigate = useCallback(
|
||||
(delta: number) => {
|
||||
if (matches.length === 0) return;
|
||||
setActiveIndex((prev) => surface(prev + delta, matches));
|
||||
},
|
||||
[matches, surface],
|
||||
);
|
||||
|
||||
// Window-level Cmd/Ctrl-F intercept. First press focuses the diff search.
|
||||
// A second press within 500ms falls through to the browser's native
|
||||
// find-in-page, so users can still search outside the diff (e.g. their
|
||||
// own comment text) without having to remap muscle memory.
|
||||
useEffect(() => {
|
||||
let lastFindAt = 0;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f")) return;
|
||||
const now = Date.now();
|
||||
if (now - lastFindAt < 500) {
|
||||
lastFindAt = 0;
|
||||
return;
|
||||
}
|
||||
lastFindAt = now;
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="search"
|
||||
className="flex h-7 items-center gap-1 rounded-md border border-(--color-border) bg-(--color-background) px-1 focus-within:border-(--color-ring) focus-within:ring-2 focus-within:ring-(--color-ring)/30"
|
||||
>
|
||||
<Search className="ml-1 size-3.5 text-(--color-muted-foreground)" aria-hidden />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => updateQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
navigate(e.shiftKey ? -1 : 1);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
updateQuery("");
|
||||
viewRef.current?.setSelectedLines(null);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}}
|
||||
placeholder={t("repo.search_diff")}
|
||||
aria-label={t("repo.search_diff")}
|
||||
className="w-40 min-w-0 flex-1 bg-transparent px-1 py-0.5 text-sm outline-none placeholder:text-(--color-muted-foreground)"
|
||||
/>
|
||||
<span className="px-1 text-xs tabular-nums text-(--color-muted-foreground)">
|
||||
{matches.length === 0 ? (query ? "0/0" : "") : `${activeIndex + 1}/${matches.length}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
disabled={matches.length === 0}
|
||||
aria-label={t("repo.search_previous_match")}
|
||||
className="cursor-pointer rounded p-1 hover:bg-(--color-surface) disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronUp className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(1)}
|
||||
disabled={matches.length === 0}
|
||||
aria-label={t("repo.search_next_match")}
|
||||
className="cursor-pointer rounded p-1 hover:bg-(--color-surface) disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronDown className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, forwardRef } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type DiffStyle = "unified" | "split";
|
||||
|
||||
// Whitespace UX vocabulary, including the explicit "show" state that the URL
|
||||
// represents as absence. See `repo/Commit.search.ts` for the URL-side type.
|
||||
export type WhitespaceMode = "show" | "ignore-all" | "ignore-change";
|
||||
|
||||
export interface DiffToolbarStats {
|
||||
fileCount: number;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export interface DiffToolbarSettings {
|
||||
diffStyle: DiffStyle;
|
||||
wrapLines: boolean;
|
||||
}
|
||||
|
||||
export interface DiffToolbarProps {
|
||||
stats: DiffToolbarStats;
|
||||
settings: DiffToolbarSettings;
|
||||
onSettingsChange: (next: DiffToolbarSettings) => void;
|
||||
whitespace: WhitespaceMode;
|
||||
onWhitespaceChange: (next: WhitespaceMode) => void;
|
||||
onExpandAll: () => void;
|
||||
onCollapseAll: () => void;
|
||||
// Slot for the always-visible in-diff search box. Rendered between the
|
||||
// left-side stats and the right-side controls on desktop; wraps to its own
|
||||
// line on narrow viewports.
|
||||
search?: ReactNode;
|
||||
// Mobile sheet trigger: opens the slide-over file tree below `lg` since
|
||||
// the desktop sidebar doesn't render at that breakpoint.
|
||||
onShowTreeMobile?: () => void;
|
||||
// Desktop sidebar toggle: shows when the sidebar is collapsed, hides
|
||||
// when it's open. Same icon slot as the mobile trigger; CSS picks which
|
||||
// one shows at each breakpoint.
|
||||
onToggleTreeDesktop?: () => void;
|
||||
desktopTreeOpen?: boolean;
|
||||
}
|
||||
|
||||
export function DiffToolbar({
|
||||
stats,
|
||||
settings,
|
||||
onSettingsChange,
|
||||
whitespace,
|
||||
onWhitespaceChange,
|
||||
onExpandAll,
|
||||
onCollapseAll,
|
||||
search,
|
||||
onShowTreeMobile,
|
||||
onToggleTreeDesktop,
|
||||
desktopTreeOpen,
|
||||
}: DiffToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const setStyle = (diffStyle: DiffStyle) => onSettingsChange({ ...settings, diffStyle });
|
||||
const setWrap = (wrapLines: boolean) => onSettingsChange({ ...settings, wrapLines });
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-(--color-border) bg-(--color-background) py-2 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<span className="flex items-center gap-1.5 text-(--color-foreground)">
|
||||
{onShowTreeMobile ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowTreeMobile}
|
||||
aria-label={t("repo.show_file_tree")}
|
||||
className="grid size-6 cursor-pointer place-items-center rounded text-(--color-muted-foreground) hover:bg-(--color-surface) hover:text-(--color-foreground) lg:hidden"
|
||||
>
|
||||
<PanelLeftOpen className="size-4" aria-hidden />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("repo.show_file_tree")}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{onToggleTreeDesktop ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleTreeDesktop}
|
||||
aria-label={desktopTreeOpen ? t("repo.hide_file_tree") : t("repo.show_file_tree")}
|
||||
aria-pressed={!!desktopTreeOpen}
|
||||
// `pl-1` nudges the icon right so it visually aligns with
|
||||
// the sidebar's collapsed-rail edge on desktop.
|
||||
className="hidden size-6 cursor-pointer place-items-center rounded text-(--color-muted-foreground) hover:bg-(--color-surface) hover:text-(--color-foreground) lg:grid lg:pl-1"
|
||||
>
|
||||
{desktopTreeOpen ? (
|
||||
<PanelLeftClose className="size-4" aria-hidden />
|
||||
) : (
|
||||
<PanelLeftOpen className="size-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{desktopTreeOpen ? t("repo.hide_file_tree") : t("repo.show_file_tree")}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey="repo.diff.showing_changed_files"
|
||||
values={{ count: stats.fileCount }}
|
||||
components={{ count: <strong className="font-semibold" /> }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
{/* On mobile, force the additions+deletions pair onto its own row
|
||||
below the "Showing X changed files" label via `basis-full`. From
|
||||
`sm+` they fit inline on one line. */}
|
||||
<span className="flex basis-full items-center gap-3 sm:basis-auto">
|
||||
<span className="tabular-nums text-(--color-diff-added)">
|
||||
+{stats.additions.toLocaleString()} {t("repo.diff.additions")}
|
||||
</span>
|
||||
<span className="tabular-nums text-(--color-diff-removed)">
|
||||
-{stats.deletions.toLocaleString()} {t("repo.diff.deletions")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{search ? (
|
||||
// `order-last` on mobile + `lg:order-none` on desktop puts the search
|
||||
// box on its own row at narrow viewports (after the right-side
|
||||
// controls wrap) and inline next to the right controls on wide ones.
|
||||
// `basis-full` on mobile forces the row break; `lg:ml-auto` on
|
||||
// desktop pushes the search box to the right side of the toolbar.
|
||||
<div className="order-last basis-full lg:order-none lg:ml-auto lg:basis-auto">{search}</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex h-7 items-stretch overflow-hidden rounded-md border border-(--color-border) text-xs">
|
||||
<SegmentButton active={settings.diffStyle === "unified"} onClick={() => setStyle("unified")}>
|
||||
{t("repo.diff.unified")}
|
||||
</SegmentButton>
|
||||
<SegmentButton active={settings.diffStyle === "split"} onClick={() => setStyle("split")}>
|
||||
{t("repo.diff.split")}
|
||||
</SegmentButton>
|
||||
</div>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<ToolbarButton icon={SlidersHorizontal} label={t("repo.diff.diff_settings")} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64 p-2">
|
||||
<div className="px-2 pb-1 text-xs font-semibold tracking-wide text-(--color-muted-foreground) uppercase">
|
||||
{t("repo.diff.whitespace")}
|
||||
</div>
|
||||
<MenuRadio checked={whitespace === "show"} onSelect={() => onWhitespaceChange("show")}>
|
||||
{t("repo.diff.show_whitespace")}
|
||||
</MenuRadio>
|
||||
<MenuRadio checked={whitespace === "ignore-change"} onSelect={() => onWhitespaceChange("ignore-change")}>
|
||||
{t("repo.diff.ignore_whitespace_changes")}
|
||||
</MenuRadio>
|
||||
<MenuRadio checked={whitespace === "ignore-all"} onSelect={() => onWhitespaceChange("ignore-all")}>
|
||||
{t("repo.diff.ignore_all_whitespace")}
|
||||
</MenuRadio>
|
||||
<div className="my-1 h-px bg-(--color-border)" />
|
||||
<div className="px-2 pb-1 text-xs font-semibold tracking-wide text-(--color-muted-foreground) uppercase">
|
||||
{t("repo.diff.display")}
|
||||
</div>
|
||||
<MenuCheckbox checked={settings.wrapLines} onSelect={() => setWrap(!settings.wrapLines)}>
|
||||
{t("repo.diff.wrap_long_lines")}
|
||||
</MenuCheckbox>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="inline-flex h-7 items-stretch overflow-hidden rounded-md border border-(--color-border) text-xs">
|
||||
<IconActionButton
|
||||
onClick={onExpandAll}
|
||||
label={t("repo.diff.expand_all_files")}
|
||||
icon={<Maximize2 className="size-3.5" aria-hidden />}
|
||||
/>
|
||||
<IconActionButton
|
||||
onClick={onCollapseAll}
|
||||
label={t("repo.diff.collapse_all_files")}
|
||||
icon={<Minimize2 className="size-3.5" aria-hidden />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ToolbarButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
icon: typeof SlidersHorizontal;
|
||||
label: string;
|
||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "type">
|
||||
>(function ToolbarButton({ icon: Icon, label, className, ...rest }, ref) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
{...rest}
|
||||
className={cn(
|
||||
"inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-(--color-border) px-2 text-xs hover:bg-(--color-surface)",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
<span>{label}</span>
|
||||
<ChevronDown className="size-3 text-(--color-muted-foreground)" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
function SegmentButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"cursor-pointer px-2.5 text-xs",
|
||||
active
|
||||
? "bg-(--color-surface) font-semibold text-(--color-foreground)"
|
||||
: "text-(--color-muted-foreground) hover:bg-(--color-surface)/60",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function IconActionButton({ onClick, label, icon }: { onClick?: () => void; label: string; icon: ReactNode }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className="flex cursor-pointer items-center px-2 text-(--color-muted-foreground) hover:bg-(--color-surface) hover:text-(--color-foreground) [&:not(:first-child)]:border-l [&:not(:first-child)]:border-(--color-border)"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuRadio({ checked, onSelect, children }: { checked: boolean; onSelect: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={checked}
|
||||
onClick={onSelect}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-(--color-surface)"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"grid size-4 place-items-center rounded-full border border-(--color-border)",
|
||||
checked && "border-(--color-primary)",
|
||||
)}
|
||||
>
|
||||
{checked ? <span className="size-2 rounded-full bg-(--color-primary)" /> : null}
|
||||
</span>
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuCheckbox({
|
||||
checked,
|
||||
onSelect,
|
||||
children,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onSelect: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={checked}
|
||||
onClick={onSelect}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-(--color-surface)"
|
||||
>
|
||||
<span className="grid size-4 place-items-center">
|
||||
{checked ? <Check className="size-3.5 text-(--color-primary)" aria-hidden /> : null}
|
||||
</span>
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Binary, FileCode2, History, Loader2, MoreHorizontal, Pencil, Trash2, UnfoldVertical } from "lucide-react";
|
||||
import { type ButtonHTMLAttributes, forwardRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export interface FileHeaderMenuProps {
|
||||
filePath: string;
|
||||
prevFilePath?: string;
|
||||
viewFileHref: string;
|
||||
rawFileHref: string;
|
||||
historyHref: string;
|
||||
// Edit/Delete only make sense when the diff is anchored to a branch (e.g.
|
||||
// PR diffs). Omit on commit pages, since gogs' editor needs a branch ref
|
||||
// and routing through a SHA returns 404.
|
||||
editFileHref?: string;
|
||||
deleteFileHref?: string;
|
||||
// Mobile-only "Expand all lines" surfaced inside the menu (only visible
|
||||
// below `lg`). The desktop chrome renders the button inline in the right-
|
||||
// side metadata, so it's hidden on desktop here to avoid double-listing.
|
||||
onExpandAllLines?: () => void;
|
||||
expandAllLinesState?: "loading" | "done";
|
||||
}
|
||||
|
||||
// Per-file overflow menu rendered into Pierre's `renderHeaderMetadata` slot.
|
||||
// Sits on the right of each file header (next to the +/- stats and collapse
|
||||
// chevron) and matches GitHub's three-dot pattern.
|
||||
export function FileHeaderMenu({
|
||||
prevFilePath,
|
||||
viewFileHref,
|
||||
rawFileHref,
|
||||
historyHref,
|
||||
editFileHref,
|
||||
deleteFileHref,
|
||||
onExpandAllLines,
|
||||
expandAllLinesState,
|
||||
}: FileHeaderMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const expandLoading = expandAllLinesState === "loading";
|
||||
const expandDone = expandAllLinesState === "done";
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<MenuTrigger aria-label={t("more_actions")} />
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
{!open ? <TooltipContent>{t("more_actions")}</TooltipContent> : null}
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" sideOffset={4} className="w-48 p-1 text-sm">
|
||||
<ul className="flex flex-col">
|
||||
{onExpandAllLines ? (
|
||||
<li className="lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
disabled={expandLoading || expandDone}
|
||||
onClick={() => {
|
||||
onExpandAllLines();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-(--color-surface) disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
>
|
||||
{expandLoading ? (
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<UnfoldVertical className="size-3.5 shrink-0" aria-hidden />
|
||||
)}
|
||||
<span>{expandDone ? t("repo.diff.all_lines_expanded") : t("repo.diff.expand_all_lines")}</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{onExpandAllLines ? <li role="separator" className="my-1 h-px bg-(--color-border) lg:hidden" /> : null}
|
||||
<li>
|
||||
<a href={viewFileHref} className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-(--color-surface)">
|
||||
<FileCode2 className="size-3.5 shrink-0" aria-hidden />
|
||||
<span>{t("repo.view_file")}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={rawFileHref} className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-(--color-surface)">
|
||||
<Binary className="size-3.5 shrink-0" aria-hidden />
|
||||
<span>{t("repo.view_raw")}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={historyHref} className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-(--color-surface)">
|
||||
<History className="size-3.5 shrink-0" aria-hidden />
|
||||
<span>{t("repo.view_history")}</span>
|
||||
</a>
|
||||
</li>
|
||||
{editFileHref || deleteFileHref ? <li role="separator" className="my-1 h-px bg-(--color-border)" /> : null}
|
||||
{editFileHref ? (
|
||||
<li>
|
||||
<a href={editFileHref} className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-(--color-surface)">
|
||||
<Pencil className="size-3.5 shrink-0" aria-hidden />
|
||||
<span>{t("repo.editor.edit_file")}</span>
|
||||
</a>
|
||||
</li>
|
||||
) : null}
|
||||
{deleteFileHref ? (
|
||||
<li>
|
||||
<a
|
||||
href={deleteFileHref}
|
||||
className="flex items-center gap-2 rounded px-2 py-1.5 text-(--color-destructive) hover:bg-(--color-surface)"
|
||||
>
|
||||
<Trash2 className="size-3.5 shrink-0" aria-hidden />
|
||||
<span>{t("repo.editor.delete_this_file")}</span>
|
||||
</a>
|
||||
</li>
|
||||
) : null}
|
||||
{prevFilePath ? (
|
||||
<>
|
||||
<li role="separator" className="my-1 h-px bg-(--color-border)" />
|
||||
<li className="px-2 py-1.5 text-xs text-(--color-muted-foreground)">
|
||||
{t("repo.renamed_from")} <span className="font-mono">{prevFilePath}</span>
|
||||
</li>
|
||||
</>
|
||||
) : null}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// Pierre's `renderHeaderMetadata` callback returns the node into a slot inside
|
||||
// its rendered header. Radix's `asChild` requires a forwardRef so the popover
|
||||
// can attach its trigger refs and ARIA wiring.
|
||||
// Pierre's `<diffs-container>` attaches gesture handlers higher up the tree
|
||||
// that interpret clicks anywhere inside the file header. Without stopping
|
||||
// propagation, clicking the kebab triggers Pierre's header click path,
|
||||
// which steals focus and bubbles through to the next focusable navbar link.
|
||||
// Stop the events at the source so the menu trigger behaves like a normal
|
||||
// button in isolation.
|
||||
const MenuTrigger = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>(function MenuTrigger(
|
||||
{ onPointerDown, onClick, onMouseDown, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className="grid size-6 cursor-pointer place-items-center rounded text-(--color-muted-foreground) hover:bg-(--color-surface) hover:text-(--color-foreground)"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onPointerDown?.(e);
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onMouseDown?.(e);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<MoreHorizontal className="size-4" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -54,7 +54,7 @@ export function PasswordInput({
|
||||
tabIndex={tabIndex + 1}
|
||||
disabled={disabled}
|
||||
onClick={onToggleShow}
|
||||
aria-label={show ? t("hide_password") : t("show_password")}
|
||||
aria-label={show ? t("auth.hide_password") : t("auth.show_password")}
|
||||
aria-pressed={show}
|
||||
className="absolute inset-y-0 right-0 flex w-10 cursor-pointer items-center justify-center rounded-r-md text-(--color-muted-foreground) outline-none hover:text-(--color-foreground) focus-visible:text-(--color-foreground) focus-visible:ring-1 focus-visible:ring-(--color-ring) disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
Bell,
|
||||
CircleDot,
|
||||
Code,
|
||||
FileText,
|
||||
GitFork,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
Link as LinkIcon,
|
||||
Lock,
|
||||
Menu,
|
||||
Settings,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
type RepoHeaderData,
|
||||
type RepoStarResult,
|
||||
type RepoWatchResult,
|
||||
repoHeaderQuery,
|
||||
starRepo,
|
||||
unstarRepo,
|
||||
unwatchRepo,
|
||||
watchRepo,
|
||||
} from "@/lib/queries/repo";
|
||||
import { subUrl } from "@/lib/url";
|
||||
import { useUserInfo } from "@/lib/use-user-info";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Mobile collapses the tab strip after this many items into a hamburger
|
||||
// overflow menu. The active tab is always pulled into the inline group so the
|
||||
// user can see the active indicator without opening the menu.
|
||||
const MOBILE_INLINE_LIMIT = 3;
|
||||
|
||||
export type RepoTab = "code" | "issues" | "pulls" | "commits" | "wiki" | "settings";
|
||||
|
||||
export interface RepoHeaderProps {
|
||||
repo: RepoHeaderData;
|
||||
activeTab: RepoTab;
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n < 1000) return n.toLocaleString();
|
||||
if (n < 10_000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
|
||||
if (n < 1_000_000) return Math.round(n / 1000) + "k";
|
||||
return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "m";
|
||||
}
|
||||
|
||||
export function RepoHeader({ repo, activeTab }: RepoHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const repoLink = subUrl(`/${repo.owner}/${repo.name}`);
|
||||
const user = useUserInfo();
|
||||
const queryClient = useQueryClient();
|
||||
const signedIn = user !== null;
|
||||
const signInHref = subUrl(
|
||||
`/user/sign-in?redirect_to=${encodeURIComponent(window.location.pathname + window.location.search + window.location.hash)}`,
|
||||
);
|
||||
|
||||
// Merge each mutation's response into the cached `repoInfo` so the button
|
||||
// labels and counts update without a refetch. The new `viewer*` flag is
|
||||
// derived from the action that was just dispatched, since the server's
|
||||
// outcome is unambiguous (POST always watches/stars, DELETE always undoes).
|
||||
const applyWatchResult = (result: RepoWatchResult, nextViewerIsWatching: boolean) => {
|
||||
queryClient.setQueryData(repoHeaderQuery(repo.owner, repo.name).queryKey, (prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
viewerIsWatching: nextViewerIsWatching,
|
||||
watchCount: result.watchCount,
|
||||
};
|
||||
});
|
||||
};
|
||||
const applyStarResult = (result: RepoStarResult, nextViewerIsStarring: boolean) => {
|
||||
queryClient.setQueryData(repoHeaderQuery(repo.owner, repo.name).queryKey, (prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
viewerIsStarring: nextViewerIsStarring,
|
||||
starCount: result.starCount,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const watchMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const next = !repo.viewerIsWatching;
|
||||
const result = next ? await watchRepo(repo.owner, repo.name) : await unwatchRepo(repo.owner, repo.name);
|
||||
return { result, next };
|
||||
},
|
||||
onSuccess: ({ result, next }) => applyWatchResult(result, next),
|
||||
onError: (err) => toast.error(t("status.internal_server_error"), { description: err.message }),
|
||||
});
|
||||
const starMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const next = !repo.viewerIsStarring;
|
||||
const result = next ? await starRepo(repo.owner, repo.name) : await unstarRepo(repo.owner, repo.name);
|
||||
return { result, next };
|
||||
},
|
||||
onSuccess: ({ result, next }) => applyStarResult(result, next),
|
||||
onError: (err) => toast.error(t("status.internal_server_error"), { description: err.message }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="border-b border-(--color-border) bg-(--color-background)">
|
||||
<div className="mx-auto max-w-7xl px-4 pt-4 sm:px-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 pb-3">
|
||||
<h1 className="flex min-w-0 flex-wrap items-center gap-2 text-base">
|
||||
<img
|
||||
src={repo.avatarURL}
|
||||
alt=""
|
||||
className="relative size-5 shrink-0 rounded border border-(--color-border) bg-(--color-surface) object-cover"
|
||||
/>
|
||||
<a href={subUrl(`/${repo.owner}`)} className="text-(--color-primary) hover:underline">
|
||||
{repo.owner}
|
||||
</a>
|
||||
<span className="text-(--color-muted-foreground)" aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<a href={repoLink} className="font-semibold text-(--color-primary) hover:underline">
|
||||
{repo.name}
|
||||
</a>
|
||||
<VisibilityBadge visibility={repo.visibility} />
|
||||
{repo.mirrorOf ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1 text-xs text-(--color-muted-foreground)">
|
||||
<LinkIcon className="size-3 shrink-0" aria-hidden />
|
||||
<span className="shrink-0">{t("repo.mirror_of")}</span>
|
||||
<a href={repo.mirrorOf} className="truncate hover:underline" rel="noopener noreferrer" target="_blank">
|
||||
{repo.mirrorOf}
|
||||
</a>
|
||||
</span>
|
||||
) : null}
|
||||
</h1>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<SplitActionButton
|
||||
countHref={`${repoLink}/watchers`}
|
||||
onAction={signedIn ? () => watchMutation.mutate() : undefined}
|
||||
signInHref={signInHref}
|
||||
disabled={watchMutation.isPending}
|
||||
signedIn={signedIn}
|
||||
signInTooltip={t("repo.sign_in_to_watch")}
|
||||
icon={Bell}
|
||||
label={repo.viewerIsWatching ? t("repo.unwatch") : t("repo.watch")}
|
||||
count={repo.watchCount}
|
||||
ariaLabel={repo.viewerIsWatching ? t("repo.unwatch_this_repository") : t("repo.watch_this_repository")}
|
||||
countAriaLabel={t("repo.view_watchers")}
|
||||
active={repo.viewerIsWatching}
|
||||
/>
|
||||
<SplitActionButton
|
||||
countHref={`${repoLink}/stars`}
|
||||
onAction={signedIn ? () => starMutation.mutate() : undefined}
|
||||
signInHref={signInHref}
|
||||
disabled={starMutation.isPending}
|
||||
signedIn={signedIn}
|
||||
signInTooltip={t("repo.sign_in_to_star")}
|
||||
icon={Star}
|
||||
label={repo.viewerIsStarring ? t("repo.starred") : t("repo.star")}
|
||||
count={repo.starCount}
|
||||
ariaLabel={repo.viewerIsStarring ? t("repo.unstar_this_repository") : t("repo.star_this_repository")}
|
||||
countAriaLabel={t("repo.view_stargazers")}
|
||||
active={repo.viewerIsStarring}
|
||||
/>
|
||||
<SplitActionButton
|
||||
countHref={`${repoLink}/forks`}
|
||||
// Fork still goes through the legacy "choose where to fork to"
|
||||
// page (not yet migrated). Treat it as a navigation link, not a
|
||||
// one-click action.
|
||||
actionHref={signedIn ? subUrl(`/repo/fork/${repo.id}`) : undefined}
|
||||
signInHref={signInHref}
|
||||
signedIn={signedIn}
|
||||
signInTooltip={t("repo.sign_in_to_fork")}
|
||||
icon={GitFork}
|
||||
label={t("repo.fork")}
|
||||
count={repo.forkCount}
|
||||
ariaLabel={t("repo.fork_this_repository")}
|
||||
countAriaLabel={t("repo.view_forks")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RepoTabs repo={repo} activeTab={activeTab} repoLink={repoLink} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabDescriptor {
|
||||
key: RepoTab;
|
||||
href: string;
|
||||
icon: ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
|
||||
label: string;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
function buildTabs(repo: RepoHeaderData, repoLink: string, t: TFunction): TabDescriptor[] {
|
||||
const tabs: TabDescriptor[] = [{ key: "code", href: repoLink, icon: Code, label: t("repo.files") }];
|
||||
if (repo.issuesEnabled !== false) {
|
||||
tabs.push({
|
||||
key: "issues",
|
||||
href: `${repoLink}/issues`,
|
||||
icon: CircleDot,
|
||||
label: t("issues"),
|
||||
badge: repo.openIssueCount,
|
||||
});
|
||||
}
|
||||
if (repo.pullRequestsEnabled !== false) {
|
||||
tabs.push({
|
||||
key: "pulls",
|
||||
href: `${repoLink}/pulls`,
|
||||
icon: GitPullRequest,
|
||||
label: t("pull_requests"),
|
||||
badge: repo.openPullRequestCount,
|
||||
});
|
||||
}
|
||||
if (repo.wikiEnabled !== false) {
|
||||
tabs.push({ key: "wiki", href: `${repoLink}/wiki`, icon: FileText, label: t("repo.wiki") });
|
||||
}
|
||||
if (repo.viewerCanAdminister) {
|
||||
tabs.push({ key: "settings", href: `${repoLink}/settings`, icon: Settings, label: t("repo.settings") });
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
function RepoTabs({
|
||||
repo,
|
||||
activeTab,
|
||||
repoLink,
|
||||
t,
|
||||
}: {
|
||||
repo: RepoHeaderData;
|
||||
activeTab: RepoTab;
|
||||
repoLink: string;
|
||||
t: TFunction;
|
||||
}) {
|
||||
const tabs = buildTabs(repo, repoLink, t);
|
||||
|
||||
// On mobile, only `MOBILE_INLINE_LIMIT` tabs are shown inline; the rest
|
||||
// fold into a hamburger overflow. If the active tab is past the cutoff,
|
||||
// swap it into the last inline slot so the indicator stays visible without
|
||||
// opening the menu.
|
||||
const activeIndex = tabs.findIndex((t) => t.key === activeTab);
|
||||
let mobileInline = tabs.slice(0, MOBILE_INLINE_LIMIT);
|
||||
let mobileOverflow = tabs.slice(MOBILE_INLINE_LIMIT);
|
||||
if (activeIndex >= MOBILE_INLINE_LIMIT) {
|
||||
const swappedOut = mobileInline[MOBILE_INLINE_LIMIT - 1];
|
||||
mobileInline = [...mobileInline.slice(0, MOBILE_INLINE_LIMIT - 1), tabs[activeIndex]];
|
||||
mobileOverflow = mobileOverflow.map((t) => (t.key === tabs[activeIndex].key ? swappedOut : t));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile: first 3 inline + hamburger overflow for the rest. */}
|
||||
<nav className="-mb-px flex items-end gap-1 sm:hidden" aria-label={t("repository")}>
|
||||
{mobileInline.map((tab) => (
|
||||
<TabLink key={tab.key} href={tab.href} icon={tab.icon} active={activeTab === tab.key} badge={tab.badge}>
|
||||
{tab.label}
|
||||
</TabLink>
|
||||
))}
|
||||
{mobileOverflow.length > 0 ? <OverflowMenu tabs={mobileOverflow} activeTab={activeTab} t={t} /> : null}
|
||||
</nav>
|
||||
|
||||
{/* sm and up: full strip, scrolls horizontally if it ever overflows. */}
|
||||
<nav className="-mb-px hidden gap-1 overflow-x-auto sm:flex" aria-label={t("repository")}>
|
||||
{tabs.map((tab) => (
|
||||
<TabLink key={tab.key} href={tab.href} icon={tab.icon} active={activeTab === tab.key} badge={tab.badge}>
|
||||
{tab.label}
|
||||
</TabLink>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OverflowMenu({ tabs, activeTab, t }: { tabs: TabDescriptor[]; activeTab: RepoTab; t: TFunction }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("more_tabs")}
|
||||
className="flex items-center gap-2 border-b-2 border-transparent px-3 py-2 text-sm whitespace-nowrap text-(--color-muted-foreground) hover:border-(--color-border) hover:text-(--color-foreground)"
|
||||
>
|
||||
<Menu className="size-4" aria-hidden />
|
||||
<span>{t("more")}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-56 p-1">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const active = tab.key === activeTab;
|
||||
return (
|
||||
<a
|
||||
key={tab.key}
|
||||
href={tab.href}
|
||||
onClick={() => setOpen(false)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded px-2 py-1.5 text-sm",
|
||||
active
|
||||
? "bg-(--color-surface) font-semibold text-(--color-foreground)"
|
||||
: "text-(--color-foreground) hover:bg-(--color-surface)",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
<span className="flex-1">{tab.label}</span>
|
||||
{tab.badge && tab.badge > 0 ? (
|
||||
<span className="rounded-full bg-(--color-background) px-1.5 text-xs leading-5 tabular-nums text-(--color-muted-foreground)">
|
||||
{formatCount(tab.badge)}
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function VisibilityBadge({ visibility }: { visibility: RepoHeaderData["visibility"] }) {
|
||||
const { t } = useTranslation();
|
||||
const isPrivate = visibility === "private";
|
||||
const Icon = isPrivate ? Lock : Globe;
|
||||
const tooltip = isPrivate ? t("repo.visibility_private") : t("repo.visibility_public");
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-label={tooltip}
|
||||
className="ml-1 grid size-5 place-items-center rounded text-(--color-muted-foreground)"
|
||||
>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface SplitActionButtonProps {
|
||||
// URL for the count half (always renders as `<a>`).
|
||||
countHref: string;
|
||||
// When set, the action half fires this callback on click (used for
|
||||
// signed-in users with a real mutation in flight).
|
||||
onAction?: () => void;
|
||||
// Fallback href for the action half when `onAction` is not set (legacy
|
||||
// page-navigation actions like Fork). Only used when the viewer is signed
|
||||
// in; `signInHref` takes over when signed out.
|
||||
actionHref?: string;
|
||||
// Where to send signed-out viewers when they click the action half. The
|
||||
// half renders disabled-looking but stays clickable so the affordance
|
||||
// works without forcing the viewer to dig for a sign-in button.
|
||||
signInHref?: string;
|
||||
// Whether the viewer is signed in. Drives both the click target and the
|
||||
// disabled-looking styling + sign-in tooltip below.
|
||||
signedIn?: boolean;
|
||||
// Tooltip text shown when signed out. Should explain the gated action,
|
||||
// e.g. "Sign in to watch this repository".
|
||||
signInTooltip?: string;
|
||||
icon: ComponentType<{ className?: string; "aria-hidden"?: boolean; fill?: string }>;
|
||||
label: string;
|
||||
count: number;
|
||||
ariaLabel: string;
|
||||
// Accessible name for the count half (e.g. "View watchers"). The visible
|
||||
// text is just the number, so this label tells assistive tech what the
|
||||
// link goes to.
|
||||
countAriaLabel: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function SplitActionButton({
|
||||
countHref,
|
||||
onAction,
|
||||
actionHref,
|
||||
signInHref,
|
||||
signedIn = true,
|
||||
signInTooltip,
|
||||
icon: Icon,
|
||||
label,
|
||||
count,
|
||||
ariaLabel,
|
||||
countAriaLabel,
|
||||
active,
|
||||
disabled,
|
||||
}: SplitActionButtonProps) {
|
||||
const actionClassName = cn(
|
||||
"flex items-center gap-1.5 px-2 hover:bg-(--color-surface)",
|
||||
active && "text-(--color-primary)",
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent",
|
||||
);
|
||||
const actionContent = (
|
||||
<>
|
||||
<Icon className="size-3.5" aria-hidden fill={active ? "currentColor" : "none"} />
|
||||
<span>{label}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
let action: ReactNode;
|
||||
if (!signedIn) {
|
||||
const href = signInHref ?? countHref;
|
||||
action = (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a href={href} aria-label={ariaLabel} className={actionClassName}>
|
||||
{actionContent}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
{signInTooltip ? <TooltipContent>{signInTooltip}</TooltipContent> : null}
|
||||
</Tooltip>
|
||||
);
|
||||
} else if (onAction) {
|
||||
action = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAction}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(actionClassName, "cursor-pointer")}
|
||||
>
|
||||
{actionContent}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
const href = actionHref ?? countHref;
|
||||
action = (
|
||||
<a href={href} aria-label={ariaLabel} className={actionClassName}>
|
||||
{actionContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex h-7 items-stretch overflow-hidden rounded-md border border-(--color-border) text-xs">
|
||||
{action}
|
||||
<a
|
||||
href={countHref}
|
||||
aria-label={countAriaLabel}
|
||||
className="flex items-center border-l border-(--color-border) bg-(--color-surface)/60 px-2 tabular-nums hover:bg-(--color-surface)"
|
||||
>
|
||||
{formatCount(count)}
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabLinkProps {
|
||||
href: string;
|
||||
icon: ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
|
||||
active: boolean;
|
||||
badge?: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function TabLink({ href, icon: Icon, active, badge, children }: TabLinkProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 border-b-2 px-3 py-2 text-sm whitespace-nowrap",
|
||||
active
|
||||
? "border-(--color-primary) font-semibold text-(--color-foreground)"
|
||||
: "border-transparent text-(--color-muted-foreground) hover:border-(--color-border) hover:text-(--color-foreground)",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
<span>{children}</span>
|
||||
{badge && badge > 0 ? (
|
||||
<span className="rounded-full bg-(--color-surface) px-1.5 text-xs leading-5 tabular-nums text-(--color-muted-foreground)">
|
||||
{formatCount(badge)}
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { type ReactNode, useCallback, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** Width in pixels. Resets to this on every mount; not persisted. */
|
||||
defaultWidth?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
// Resizable left sidebar. Drag the right-edge handle to widen or narrow.
|
||||
// The chosen width is in-memory only and resets each page load, so updating
|
||||
// `defaultWidth` in code always takes effect for every user. Below the lg
|
||||
// breakpoint, the sidebar stacks above content at full width, so the handle
|
||||
// (and resize) is hidden.
|
||||
export function ResizableSidebar({
|
||||
children,
|
||||
defaultWidth = 320,
|
||||
minWidth = 220,
|
||||
maxWidth = 560,
|
||||
className,
|
||||
style,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [width, setWidth] = useState(defaultWidth);
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
const draggingRef = useRef(false);
|
||||
const startXRef = useRef(0);
|
||||
const startWidthRef = useRef(0);
|
||||
// Latest width during a drag. Updated on every pointermove and committed to
|
||||
// React state on pointerup, so the tree only re-renders once per drag
|
||||
// instead of once per pointer event.
|
||||
const liveWidthRef = useRef(defaultWidth);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
draggingRef.current = true;
|
||||
startXRef.current = e.clientX;
|
||||
startWidthRef.current = width;
|
||||
liveWidthRef.current = width;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
},
|
||||
[width],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingRef.current) return;
|
||||
const next = Math.min(maxWidth, Math.max(minWidth, startWidthRef.current + (e.clientX - startXRef.current)));
|
||||
liveWidthRef.current = next;
|
||||
asideRef.current?.style.setProperty("--sidebar-w", `${next}px`);
|
||||
},
|
||||
[maxWidth, minWidth],
|
||||
);
|
||||
|
||||
const stopDrag = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
setWidth(liveWidthRef.current);
|
||||
}, []);
|
||||
|
||||
// Keyboard resize for accessibility: focus the handle, then arrow-left/right
|
||||
// adjusts width by a fixed step.
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const step = e.shiftKey ? 40 : 8;
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
setWidth((w) => Math.max(minWidth, w - step));
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
setWidth((w) => Math.min(maxWidth, w + step));
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
setWidth(defaultWidth);
|
||||
}
|
||||
},
|
||||
[defaultWidth, maxWidth, minWidth],
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={asideRef}
|
||||
className={cn("relative flex w-full shrink-0 flex-col lg:w-[var(--sidebar-w)]", className)}
|
||||
style={{
|
||||
...style,
|
||||
["--sidebar-w" as string]: `${width}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full min-w-0 flex-1 flex-col">{children}</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("resize_sidebar")}
|
||||
aria-valuemin={minWidth}
|
||||
aria-valuemax={maxWidth}
|
||||
aria-valuenow={width}
|
||||
tabIndex={0}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={stopDrag}
|
||||
onPointerCancel={stopDrag}
|
||||
onKeyDown={onKeyDown}
|
||||
className="absolute top-0 right-0 bottom-0 hidden w-1 cursor-col-resize touch-none bg-transparent hover:bg-(--color-primary)/30 focus-visible:bg-(--color-primary)/40 focus-visible:outline-none lg:block"
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Check, Monitor, Moon, Settings, Sun } from "lucide-react";
|
||||
import { Bug, Check, Monitor, Moon, Settings, Sun } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { webContext } from "@/lib/context";
|
||||
import { type Theme, useTheme } from "@/lib/theme";
|
||||
import { type Theme, useTheme } from "@/lib/theme-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LANGUAGES: { code: string; name: string }[] = [
|
||||
@@ -43,6 +43,19 @@ const LANGUAGES: { code: string; name: string }[] = [
|
||||
{ code: "ro-RO", name: "Română" },
|
||||
];
|
||||
|
||||
// Dev-only feature flag: toggles a `?i18n_debug=1` query param that makes
|
||||
// every translated string render with its key wrapped around it. `i18n.ts`
|
||||
// reads the param at module load, so toggling forces a full reload.
|
||||
const i18nDebugEnabled = typeof window !== "undefined" && new URLSearchParams(window.location.search).has("i18n_debug");
|
||||
|
||||
function toggleI18nDebug() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (i18nDebugEnabled) params.delete("i18n_debug");
|
||||
else params.set("i18n_debug", "1");
|
||||
const qs = params.toString();
|
||||
window.location.search = qs ? "?" + qs : "";
|
||||
}
|
||||
|
||||
export function SettingsMenu() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -112,6 +125,23 @@ export function SettingsMenu() {
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
<>
|
||||
<div className="my-1 h-px bg-(--color-border)" />
|
||||
<div className="p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleI18nDebug}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-(--color-surface) hover:text-(--color-foreground)"
|
||||
>
|
||||
<Bug className="size-4" aria-hidden />
|
||||
<span className="flex-1">i18n debug</span>
|
||||
<Check className={cn("size-4", i18nDebugEnabled ? "opacity-100" : "opacity-0")} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
|
||||
export type Theme = "light" | "dark" | "system";
|
||||
import { ThemeContext, type ThemeContextValue } from "@/lib/theme-context";
|
||||
|
||||
const STORAGE_KEY = "gogs-theme";
|
||||
|
||||
@@ -8,19 +8,19 @@ function systemPrefersDark(): boolean {
|
||||
return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
function readStoredTheme(): Theme {
|
||||
function readStoredTheme(): ThemeContextValue["theme"] {
|
||||
if (typeof localStorage === "undefined") return "system";
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
return v === "light" || v === "dark" || v === "system" ? v : "system";
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
function applyTheme(theme: ThemeContextValue["theme"]) {
|
||||
const dark = theme === "dark" || (theme === "system" && systemPrefersDark());
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(readStoredTheme);
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<ThemeContextValue["theme"]>(readStoredTheme);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
@@ -31,11 +31,17 @@ export function useTheme() {
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = (next: Theme) => {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
setThemeState(next);
|
||||
applyTheme(next);
|
||||
};
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme,
|
||||
setTheme: (next) => {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
setThemeState(next);
|
||||
applyTheme(next);
|
||||
},
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
|
||||
return { theme, setTheme };
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Sheet = DialogPrimitive.Root;
|
||||
const SheetTrigger = DialogPrimitive.Trigger;
|
||||
const SheetClose = DialogPrimitive.Close;
|
||||
const SheetPortal = DialogPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 bg-(--color-background) shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-200 data-[state=open]:duration-300",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b border-(--color-border) data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t border-(--color-border) data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r border-(--color-border) data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l border-(--color-border) data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>, VariantProps<typeof sheetVariants> {
|
||||
hideCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, SheetContentProps>(
|
||||
({ side = "right", className, children, hideCloseButton, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
{hideCloseButton ? null : (
|
||||
<DialogPrimitive.Close
|
||||
aria-label="Close"
|
||||
className="absolute top-3 right-3 grid size-7 cursor-pointer place-items-center rounded-md text-(--color-muted-foreground) hover:bg-(--color-surface) hover:text-(--color-foreground) focus-visible:outline-2 focus-visible:outline-(--color-ring)"
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
),
|
||||
);
|
||||
SheetContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col gap-1.5 border-b border-(--color-border) p-3 text-sm", className)} {...props} />
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title ref={ref} className={cn("font-semibold text-(--color-foreground)", className)} {...props} />
|
||||
));
|
||||
SheetTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-xs text-(--color-muted-foreground)", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetPortal, SheetTitle, SheetTrigger };
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-(--color-foreground) px-2 py-1 text-xs text-(--color-background) shadow-sm",
|
||||
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=delayed-open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
@@ -35,6 +35,9 @@
|
||||
--color-muted-foreground: initial;
|
||||
--color-destructive: initial;
|
||||
--color-destructive-foreground: initial;
|
||||
--color-success: initial;
|
||||
--color-diff-added: initial;
|
||||
--color-diff-removed: initial;
|
||||
--color-border: initial;
|
||||
--color-input: initial;
|
||||
--color-ring: initial;
|
||||
@@ -60,6 +63,9 @@
|
||||
--color-muted-foreground: #737373;
|
||||
--color-destructive: #d52c36;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--color-success: #15803d;
|
||||
--color-diff-added: #16a34a;
|
||||
--color-diff-removed: #dc2626;
|
||||
--color-border: #e5e5e5;
|
||||
--color-input: #d4d4d4;
|
||||
--color-ring: #009fff;
|
||||
@@ -82,6 +88,9 @@
|
||||
--color-muted-foreground: #8a8a8a;
|
||||
--color-destructive: #ff6762;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--color-success: #4ade80;
|
||||
--color-diff-added: #4ade80;
|
||||
--color-diff-removed: #f87171;
|
||||
--color-border: #1d1d1d;
|
||||
--color-input: #262626;
|
||||
--color-ring: #009fff;
|
||||
@@ -160,3 +169,23 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* @pierre/diffs renders each file into a <diffs-container> custom element with
|
||||
its own shadow root. Custom properties inherit across the boundary, so set
|
||||
the font tokens and the diff bg here so the library's :host font-family and
|
||||
:host bg rules resolve to our tokens instead of its SF Mono / pure-black
|
||||
fallbacks. Matching --diffs-{light,dark}-bg to our page bg eliminates the
|
||||
seam between the diff panel and the surrounding page chrome. */
|
||||
diffs-container {
|
||||
--diffs-font-family: var(--font-mono);
|
||||
--diffs-header-font-family: var(--font-sans);
|
||||
--diffs-light-bg: var(--color-background);
|
||||
--diffs-dark-bg: var(--color-background);
|
||||
--diffs-tab-size: 4;
|
||||
display: block;
|
||||
border: 1px solid var(--color-border);
|
||||
/* Top corners square so when the file header sticks (floating below the
|
||||
card's top edge), no rounded host corners frame the gap. The header
|
||||
carries its own rounded top corners in both docked and sticky state. */
|
||||
border-radius: 0 0 3px 3px;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,20 @@ import zhCN from "@/locales/zh-CN.json";
|
||||
import zhHK from "@/locales/zh-HK.json";
|
||||
import zhTW from "@/locales/zh-TW.json";
|
||||
|
||||
// Toggle via `?i18n_debug=1` to wrap every translated string with its key
|
||||
// for visual inspection. Read once at module load so it survives navigation
|
||||
// within the SPA.
|
||||
const showKeys = typeof window !== "undefined" && new URLSearchParams(window.location.search).has("i18n_debug");
|
||||
|
||||
if (showKeys) {
|
||||
// eslint-disable-next-line import/no-named-as-default-member
|
||||
i18n.use({
|
||||
type: "postProcessor",
|
||||
name: "i18n-debug",
|
||||
process: (value: string, keys: string[]) => `「${keys.join(",")}」${value}`,
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/no-named-as-default-member
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
@@ -75,6 +89,7 @@ void i18n.use(initReactI18next).init({
|
||||
fallbackLng: "en-US",
|
||||
interpolation: { escapeValue: false, prefix: "{", suffix: "}" },
|
||||
returnNull: false,
|
||||
postProcess: showKeys ? ["i18n-debug"] : undefined,
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
|
||||
import { loaderResponseError } from "@/lib/loader-error";
|
||||
import { subUrl } from "@/lib/url";
|
||||
|
||||
export interface RepoHeaderData {
|
||||
id: number;
|
||||
owner: string;
|
||||
name: string;
|
||||
avatarURL: string;
|
||||
visibility: "public" | "private";
|
||||
viewerCanAdminister: boolean;
|
||||
issuesEnabled: boolean;
|
||||
pullRequestsEnabled: boolean;
|
||||
wikiEnabled: boolean;
|
||||
watchCount: number;
|
||||
starCount: number;
|
||||
forkCount: number;
|
||||
openIssueCount: number;
|
||||
openPullRequestCount: number;
|
||||
viewerIsWatching: boolean;
|
||||
viewerIsStarring: boolean;
|
||||
mirrorOf?: string;
|
||||
}
|
||||
|
||||
export function repoHeaderQuery(owner: string, name: string) {
|
||||
return queryOptions({
|
||||
queryKey: ["repo", owner, name, "header"] as const,
|
||||
queryFn: async ({ signal }) => {
|
||||
const res = await fetch(subUrl(`/api/web/${owner}/${name}/header`), {
|
||||
credentials: "same-origin",
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw await loaderResponseError(res);
|
||||
return (await res.json()) as RepoHeaderData;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface RepoWatchResult {
|
||||
watchCount: number;
|
||||
}
|
||||
|
||||
export interface RepoStarResult {
|
||||
starCount: number;
|
||||
}
|
||||
|
||||
async function repoAction<T>(method: "POST" | "DELETE", owner: string, name: string, action: "watch" | "star") {
|
||||
const res = await fetch(subUrl(`/api/web/${owner}/${name}/${action}`), {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) throw await loaderResponseError(res);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export function watchRepo(owner: string, name: string) {
|
||||
return repoAction<RepoWatchResult>("POST", owner, name, "watch");
|
||||
}
|
||||
|
||||
export function unwatchRepo(owner: string, name: string) {
|
||||
return repoAction<RepoWatchResult>("DELETE", owner, name, "watch");
|
||||
}
|
||||
|
||||
export function starRepo(owner: string, name: string) {
|
||||
return repoAction<RepoStarResult>("POST", owner, name, "star");
|
||||
}
|
||||
|
||||
export function unstarRepo(owner: string, name: string) {
|
||||
return repoAction<RepoStarResult>("DELETE", owner, name, "star");
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Date formatting helpers used by commit metadata and similar timestamps.
|
||||
// - Relative time matches `internal/tool/tool.go`'s `timeSince` thresholds
|
||||
// (now / seconds / minutes / hours / days / weeks / months / years ago).
|
||||
// Strings come from Gogs's `[tool]` section so the SPA reuses the existing
|
||||
// community translations. Quantity templates carry `%d` (count) and `%s`
|
||||
// (suffix) printf placeholders, substituted manually since i18next uses
|
||||
// `{count}` style interpolation.
|
||||
// - Absolute time uses `Intl.DateTimeFormat` keyed on `webContext.lang`, so
|
||||
// each locale gets its own calendar conventions out of the box without
|
||||
// shipping per-locale day/month strings.
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import { webContext } from "@/lib/context";
|
||||
|
||||
const MINUTE = 60;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const WEEK = 7 * DAY;
|
||||
const MONTH = 30 * DAY;
|
||||
const YEAR = 12 * MONTH;
|
||||
|
||||
// Substitute Go-style printf placeholders. Quantity templates may use either
|
||||
// the simple `%d` / `%s` form (English, most locales) or the positional
|
||||
// `%[1]d` / `%[2]s` form used by community translations that need to reorder
|
||||
// the count and suffix (e.g. German: `%[2]s %[1]d Jahren`).
|
||||
function fillQuantity(template: string, count: number, suffix: string): string {
|
||||
return template.replace(/%(?:\[1\])?d/g, String(count)).replace(/%(?:\[2\])?s/g, suffix);
|
||||
}
|
||||
|
||||
function fillSuffix(template: string, suffix: string): string {
|
||||
return template.replace(/%(?:\[1\])?s/g, suffix);
|
||||
}
|
||||
|
||||
export function formatRelativeTime(t: TFunction, iso: string, nowMs: number = Date.now()): string {
|
||||
const then = Date.parse(iso);
|
||||
if (Number.isNaN(then)) return iso;
|
||||
|
||||
let diff = Math.floor((nowMs - then) / 1000);
|
||||
const suffix = diff < 0 ? t("tool.from_now") : t("tool.ago");
|
||||
if (diff < 0) diff = -diff;
|
||||
|
||||
if (diff <= 0) return t("tool.now");
|
||||
if (diff <= 2) return fillSuffix(t("tool.1s"), suffix);
|
||||
if (diff < MINUTE) return fillQuantity(t("tool.seconds"), diff, suffix);
|
||||
if (diff < 2 * MINUTE) return fillSuffix(t("tool.1m"), suffix);
|
||||
if (diff < HOUR) return fillQuantity(t("tool.minutes"), Math.floor(diff / MINUTE), suffix);
|
||||
if (diff < 2 * HOUR) return fillSuffix(t("tool.1h"), suffix);
|
||||
if (diff < DAY) return fillQuantity(t("tool.hours"), Math.floor(diff / HOUR), suffix);
|
||||
if (diff < 2 * DAY) return fillSuffix(t("tool.1d"), suffix);
|
||||
if (diff < WEEK) return fillQuantity(t("tool.days"), Math.floor(diff / DAY), suffix);
|
||||
if (diff < 2 * WEEK) return fillSuffix(t("tool.1w"), suffix);
|
||||
if (diff < MONTH) return fillQuantity(t("tool.weeks"), Math.floor(diff / WEEK), suffix);
|
||||
if (diff < 2 * MONTH) return fillSuffix(t("tool.1mon"), suffix);
|
||||
if (diff < YEAR) return fillQuantity(t("tool.months"), Math.floor(diff / MONTH), suffix);
|
||||
if (diff < 2 * YEAR) return fillSuffix(t("tool.1y"), suffix);
|
||||
return fillQuantity(t("tool.years"), Math.floor(diff / YEAR), suffix);
|
||||
}
|
||||
|
||||
const ABSOLUTE_TIME_FMT = new Intl.DateTimeFormat(webContext.lang, {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
timeZoneName: "short",
|
||||
});
|
||||
|
||||
export function formatAbsoluteTime(iso: string, now: Date = new Date(Date.parse(iso))): string {
|
||||
if (Number.isNaN(now.getTime())) return iso;
|
||||
return ABSOLUTE_TIME_FMT.format(now);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type Theme = "light" | "dark" | "system";
|
||||
|
||||
export interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
setTheme: (next: Theme) => void;
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useTheme must be used inside <ThemeProvider>");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Административен панел",
|
||||
"settings": "Настройки",
|
||||
"language": "Език",
|
||||
"page_not_found": "Страницата не е намерена",
|
||||
"internal_server_error": "Вътрешна грешка в сървър",
|
||||
"repository": "Хранилище",
|
||||
"username": "Потребител",
|
||||
"email": "Ел. поща",
|
||||
"password": "Парола",
|
||||
"captcha": "Captcha",
|
||||
"auth_source": "Източник за удостоверяване",
|
||||
"local": "Локален",
|
||||
"forget_password": "Забравена парола?",
|
||||
"disable_register_mail": "За съжаление потвърждението на регистрации е изключено.",
|
||||
"disable_register_prompt": "За съжаление създаването на нови регистрации е изключено. Обърнете се към администратора на сайта.",
|
||||
"non_local_account": "Нелокални потребители не могат да сменят паролата си през Gogs.",
|
||||
"create_new_account": "Създай нов профил",
|
||||
"register_hepler_msg": "Вече имате профил? Впишете се сега!",
|
||||
"sign_up": "Регистрирайте се",
|
||||
"sign_up_now": "Нуждаете се от профил? Регистрирайте се сега.",
|
||||
"reset_password": "Нулиране на паролата",
|
||||
"invalid_code": "За съжаление Вашия код за потвърждение е изтекъл или е невалиден.",
|
||||
"new_password": "Нова парола",
|
||||
"confirm_password": "Потвърждение на паролата"
|
||||
"status.page_not_found": "Страницата не е намерена",
|
||||
"status.internal_server_error": "Вътрешна грешка в сървър",
|
||||
"auth.auth_source": "Източник за удостоверяване",
|
||||
"auth.local": "Локален",
|
||||
"auth.forget_password": "Забравена парола?",
|
||||
"auth.disable_register_mail": "За съжаление потвърждението на регистрации е изключено.",
|
||||
"auth.disable_register_prompt": "За съжаление създаването на нови регистрации е изключено. Обърнете се към администратора на сайта.",
|
||||
"auth.non_local_account": "Нелокални потребители не могат да сменят паролата си през Gogs.",
|
||||
"auth.create_new_account": "Създай нов профил",
|
||||
"auth.register_hepler_msg": "Вече имате профил? Впишете се сега!",
|
||||
"auth.sign_up_now": "Нуждаете се от профил? Регистрирайте се сега.",
|
||||
"auth.reset_password": "Нулиране на паролата",
|
||||
"auth.invalid_code": "За съжаление Вашия код за потвърждение е изтекъл или е невалиден.",
|
||||
"tool.now": "сега",
|
||||
"tool.ago": "преди",
|
||||
"tool.from_now": "след",
|
||||
"tool.1s": "%s 1 секунда",
|
||||
"tool.1m": "%s 1 минута",
|
||||
"tool.1h": "%s 1 час",
|
||||
"tool.1d": "%s 1 ден",
|
||||
"tool.1w": "%s 1 седмица",
|
||||
"tool.1mon": "%s 1 месец",
|
||||
"tool.1y": "%s 1 година",
|
||||
"tool.seconds": "%[2]s %[1]d секунди",
|
||||
"tool.minutes": "%[2]s %[1]d минути",
|
||||
"tool.hours": "%[2]s %[1]d часа",
|
||||
"tool.days": "%[2]s %[1]d дни",
|
||||
"tool.weeks": "%[2]s %[1]d седмици",
|
||||
"tool.months": "%[2]s %[1]d месеца",
|
||||
"tool.years": "%[2]s %[1]d години",
|
||||
"repo.editor.edit_file": "Редактирай файл",
|
||||
"repo.editor.delete_this_file": "Изтрий този файл",
|
||||
"repo.files": "Файлове",
|
||||
"repo.settings": "Настройки",
|
||||
"repo.wiki": "Уики",
|
||||
"repo.watch": "Наблюдаван",
|
||||
"repo.unwatch": "Не наблюдавам",
|
||||
"repo.star": "Харесван",
|
||||
"repo.fork": "Разклонения"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Panel správce",
|
||||
"settings": "Nastavení",
|
||||
"language": "Jazyk",
|
||||
"page_not_found": "Page Not Found",
|
||||
"internal_server_error": "Internal Server Error",
|
||||
"repository": "Repozitář",
|
||||
"username": "Uživatelské jméno",
|
||||
"email": "E-mail",
|
||||
"password": "Heslo",
|
||||
"captcha": "CAPTCHA",
|
||||
"auth_source": "Zdroj ověření",
|
||||
"local": "Lokální",
|
||||
"forget_password": "Zapomněli jste heslo?",
|
||||
"disable_register_mail": "Omlouváme se, ale e-mailové služby jsou vypnuté. Kontaktujte správce systému.",
|
||||
"disable_register_prompt": "Omlouváme se, ale registrace jsou vypnuty. Kontaktujte správce systému.",
|
||||
"non_local_account": "Externí účty nemohou měnit hesla přes Gogs.",
|
||||
"create_new_account": "Vytvořit nový účet",
|
||||
"register_hepler_msg": "Již máte účet? Přihlašte se!",
|
||||
"sign_up": "Registrovat se",
|
||||
"sign_up_now": "Potřebujete účet? Zaregistrujte se.",
|
||||
"reset_password": "Obnova vašeho hesla",
|
||||
"invalid_code": "Omlouváme se, ale kód z vašeho potvrzovacího e-mailu už vypršel nebo není správný.",
|
||||
"new_password": "Nové heslo",
|
||||
"confirm_password": "Potvrdit heslo"
|
||||
"status.page_not_found": "Page Not Found",
|
||||
"status.internal_server_error": "Internal Server Error",
|
||||
"auth.auth_source": "Zdroj ověření",
|
||||
"auth.local": "Lokální",
|
||||
"auth.forget_password": "Zapomněli jste heslo?",
|
||||
"auth.disable_register_mail": "Omlouváme se, ale e-mailové služby jsou vypnuté. Kontaktujte správce systému.",
|
||||
"auth.disable_register_prompt": "Omlouváme se, ale registrace jsou vypnuty. Kontaktujte správce systému.",
|
||||
"auth.non_local_account": "Externí účty nemohou měnit hesla přes Gogs.",
|
||||
"auth.create_new_account": "Vytvořit nový účet",
|
||||
"auth.register_hepler_msg": "Již máte účet? Přihlašte se!",
|
||||
"auth.sign_up_now": "Potřebujete účet? Zaregistrujte se.",
|
||||
"auth.reset_password": "Obnova vašeho hesla",
|
||||
"auth.invalid_code": "Omlouváme se, ale kód z vašeho potvrzovacího e-mailu už vypršel nebo není správný.",
|
||||
"tool.now": "nyní",
|
||||
"tool.ago": "před",
|
||||
"tool.from_now": "od teď",
|
||||
"tool.1s": "%s 1 sekundou",
|
||||
"tool.1m": "%s 1 minutou",
|
||||
"tool.1h": "%s 1 hodinou",
|
||||
"tool.1d": "%s 1 dnem",
|
||||
"tool.1w": "%s 1 týdnem",
|
||||
"tool.1mon": "%s 1 měsícem",
|
||||
"tool.1y": "%s 1 rokem",
|
||||
"tool.seconds": "%[2]s %[1]d sekundami",
|
||||
"tool.minutes": "%[2]s %[1]d minutami",
|
||||
"tool.hours": "%[2]s %[1]d hodinami",
|
||||
"tool.days": "%[2]s %[1]d dny",
|
||||
"tool.weeks": "%[2]s %[1]d týdny",
|
||||
"tool.months": "%[2]s %[1]d měsíci",
|
||||
"tool.years": "%[2]s %[1]d roky",
|
||||
"repo.editor.edit_file": "Upravit soubor",
|
||||
"repo.editor.delete_this_file": "Smazat tento soubor",
|
||||
"repo.files": "Soubory",
|
||||
"repo.settings": "Nastavení",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Sledovat",
|
||||
"repo.unwatch": "Přestat sledovat",
|
||||
"repo.star": "Oblíbit",
|
||||
"repo.fork": "Rozštěpit"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Administration",
|
||||
"settings": "Einstellungen",
|
||||
"language": "Sprache",
|
||||
"page_not_found": "Seite nicht gefunden",
|
||||
"internal_server_error": "Interner Serverfehler",
|
||||
"repository": "Repository",
|
||||
"username": "Benutzername",
|
||||
"email": "E-Mail",
|
||||
"password": "Passwort",
|
||||
"captcha": "Captcha",
|
||||
"auth_source": "Authentifizierungsquelle",
|
||||
"local": "Lokal",
|
||||
"forget_password": "Passwort vergessen?",
|
||||
"disable_register_mail": "Es tut uns leid, die Bestätigung der Registrierungs-E-Mail wurde deaktiviert.",
|
||||
"disable_register_prompt": "Es tut uns leid, die Registrierung wurde deaktiviert. Bitte wenden Sie sich an den Administrator.",
|
||||
"non_local_account": "Nicht-lokale Konten können Passwörter nicht via Gogs ändern.",
|
||||
"create_new_account": "Neues Konto erstellen",
|
||||
"register_hepler_msg": "Haben Sie bereits ein Konto? Jetzt anmelden!",
|
||||
"sign_up": "Registrieren",
|
||||
"sign_up_now": "Benötigen Sie ein Konto? Registrieren Sie sich jetzt.",
|
||||
"reset_password": "Passwort zurücksetzen",
|
||||
"invalid_code": "Es tut uns leid, der Bestätigungscode ist abgelaufen oder ungültig.",
|
||||
"new_password": "Neues Passwort",
|
||||
"confirm_password": "Passwort bestätigen"
|
||||
"status.page_not_found": "Seite nicht gefunden",
|
||||
"status.internal_server_error": "Interner Serverfehler",
|
||||
"auth.auth_source": "Authentifizierungsquelle",
|
||||
"auth.local": "Lokal",
|
||||
"auth.forget_password": "Passwort vergessen?",
|
||||
"auth.disable_register_mail": "Es tut uns leid, die Bestätigung der Registrierungs-E-Mail wurde deaktiviert.",
|
||||
"auth.disable_register_prompt": "Es tut uns leid, die Registrierung wurde deaktiviert. Bitte wenden Sie sich an den Administrator.",
|
||||
"auth.non_local_account": "Nicht-lokale Konten können Passwörter nicht via Gogs ändern.",
|
||||
"auth.create_new_account": "Neues Konto erstellen",
|
||||
"auth.register_hepler_msg": "Haben Sie bereits ein Konto? Jetzt anmelden!",
|
||||
"auth.sign_up_now": "Benötigen Sie ein Konto? Registrieren Sie sich jetzt.",
|
||||
"auth.reset_password": "Passwort zurücksetzen",
|
||||
"auth.invalid_code": "Es tut uns leid, der Bestätigungscode ist abgelaufen oder ungültig.",
|
||||
"tool.now": "jetzt",
|
||||
"tool.ago": "vor",
|
||||
"tool.from_now": "in",
|
||||
"tool.1s": "%s 1 Sekunde",
|
||||
"tool.1m": "%s 1 Minute",
|
||||
"tool.1h": "%s 1 Stunde",
|
||||
"tool.1d": "%s 1 Tag",
|
||||
"tool.1w": "%s 1 Woche",
|
||||
"tool.1mon": "%s 1 Monat",
|
||||
"tool.1y": "%s 1 Jahr",
|
||||
"tool.seconds": "%[2]s %[1]d Sekunden",
|
||||
"tool.minutes": "%[2]s %[1]d Minuten",
|
||||
"tool.hours": "%[2]s %[1]d Stunden",
|
||||
"tool.days": "%[2]s %[1]d Tagen",
|
||||
"tool.weeks": "%[2]s %[1]d Wochen",
|
||||
"tool.months": "%[2]s %[1]d Monaten",
|
||||
"tool.years": "%[2]s %[1]d Jahren",
|
||||
"repo.editor.edit_file": "Datei bearbeiten",
|
||||
"repo.editor.delete_this_file": "Datei löschen",
|
||||
"repo.files": "Dateien",
|
||||
"repo.settings": "Einstellungen",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Beobachten",
|
||||
"repo.unwatch": "Beobachten beenden",
|
||||
"repo.star": "Favorit hinzufügen",
|
||||
"repo.fork": "Fork"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Admin Panel",
|
||||
"settings": "Settings",
|
||||
"language": "Language",
|
||||
"page_not_found": "Page Not Found",
|
||||
"internal_server_error": "Internal Server Error",
|
||||
"repository": "Repository",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"captcha": "Captcha",
|
||||
"auth_source": "Authentication Source",
|
||||
"local": "Local",
|
||||
"forget_password": "Forgot password?",
|
||||
"disable_register_mail": "Sorry, Register Mail Confirmation has been disabled.",
|
||||
"disable_register_prompt": "Sorry, registration has been disabled. Please contact the site administrator.",
|
||||
"non_local_account": "Non-local accounts cannot change passwords through Gogs.",
|
||||
"create_new_account": "Create New Account",
|
||||
"register_hepler_msg": "Already have an account? Sign in now!",
|
||||
"sign_up": "Sign Up",
|
||||
"sign_up_now": "Need an account? Sign up now.",
|
||||
"reset_password": "Reset Your Password",
|
||||
"invalid_code": "Sorry, your confirmation code has expired or not valid.",
|
||||
"new_password": "New Password",
|
||||
"confirm_password": "Confirm Password"
|
||||
"status.page_not_found": "Page Not Found",
|
||||
"status.internal_server_error": "Internal Server Error",
|
||||
"auth.auth_source": "Authentication Source",
|
||||
"auth.local": "Local",
|
||||
"auth.forget_password": "Forgot password?",
|
||||
"auth.disable_register_mail": "Sorry, Register Mail Confirmation has been disabled.",
|
||||
"auth.disable_register_prompt": "Sorry, registration has been disabled. Please contact the site administrator.",
|
||||
"auth.non_local_account": "Non-local accounts cannot change passwords through Gogs.",
|
||||
"auth.create_new_account": "Create New Account",
|
||||
"auth.register_hepler_msg": "Already have an account? Sign in now!",
|
||||
"auth.sign_up_now": "Need an account? Sign up now.",
|
||||
"auth.reset_password": "Reset Your Password",
|
||||
"auth.invalid_code": "Sorry, your confirmation code has expired or not valid.",
|
||||
"tool.now": "now",
|
||||
"tool.ago": "ago",
|
||||
"tool.from_now": "from now",
|
||||
"tool.1s": "1 second %s",
|
||||
"tool.1m": "1 minute %s",
|
||||
"tool.1h": "1 hour %s",
|
||||
"tool.1d": "1 day %s",
|
||||
"tool.1w": "1 week %s",
|
||||
"tool.1mon": "1 month %s",
|
||||
"tool.1y": "1 year %s",
|
||||
"tool.seconds": "%d seconds %s",
|
||||
"tool.minutes": "%d minutes %s",
|
||||
"tool.hours": "%d hours %s",
|
||||
"tool.days": "%d days %s",
|
||||
"tool.weeks": "%d weeks %s",
|
||||
"tool.months": "%d months %s",
|
||||
"tool.years": "%d years %s",
|
||||
"repo.editor.edit_file": "Edit file",
|
||||
"repo.editor.delete_this_file": "Delete this file",
|
||||
"repo.files": "Files",
|
||||
"repo.settings": "Settings",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Watch",
|
||||
"repo.unwatch": "Unwatch",
|
||||
"repo.star": "Star",
|
||||
"repo.fork": "Fork"
|
||||
}
|
||||
|
||||
+144
-56
@@ -20,8 +20,7 @@
|
||||
"admin_panel": "Admin panel",
|
||||
"settings": "Settings",
|
||||
"language": "Language",
|
||||
"page_not_found": "Page not found",
|
||||
"internal_server_error": "Internal server error",
|
||||
"repository": "Repository",
|
||||
"theme": "Theme",
|
||||
"theme_light": "Light",
|
||||
"theme_dark": "Dark",
|
||||
@@ -38,58 +37,147 @@
|
||||
"captcha_image_alt": "Captcha image",
|
||||
"refresh_captcha": "Refresh captcha",
|
||||
"click_to_refresh_captcha": "Click to refresh",
|
||||
"auth_source": "Authentication source",
|
||||
"local": "Local",
|
||||
"forget_password": "Forgot password?",
|
||||
"send_reset_email": "Send password reset email",
|
||||
"reset_password_email_submitting": "Sending password reset email...",
|
||||
"reset_password_email_failed": "Could not send password reset email, please try again.",
|
||||
"reset_password_email_sent": "A password reset email has been sent to <email>{email}</email>, please check your inbox within <hours>{hours} hours</hours>.",
|
||||
"disable_register_mail": "Sorry, email services are disabled. Please contact the site administrator.",
|
||||
"disable_register_prompt": "Sorry, registration has been disabled. Please contact the site administrator.",
|
||||
"reset_password_resend_limited": "You already requested a password reset email recently. Please wait 3 minutes then try again.",
|
||||
"non_local_account": "Non-local accounts cannot change passwords through Gogs.",
|
||||
"create_new_account": "Create new account",
|
||||
"register_hepler_msg": "Already have an account? Sign in now!",
|
||||
"sign_up": "Sign up",
|
||||
"sign_up_now": "Create a new account",
|
||||
"sign_up_submitting": "Creating account...",
|
||||
"sign_up_failed": "Could not create account, please try again.",
|
||||
"sign_in_submitting": "Signing in...",
|
||||
"sign_in_failed": "Could not sign in, please try again.",
|
||||
"show_password": "Show password",
|
||||
"hide_password": "Hide password",
|
||||
"back_to_sign_in": "Back to sign in",
|
||||
"reset_password": "Reset your password",
|
||||
"invalid_code": "The confirmation code has expired or not valid.",
|
||||
"reset_password_submit": "Reset password",
|
||||
"reset_password_submitting": "Resetting password...",
|
||||
"reset_password_failed": "Could not reset password, please try again.",
|
||||
"new_password": "New password",
|
||||
"new_password_placeholder": "Enter your new password",
|
||||
"confirm_password": "Confirm password",
|
||||
"confirm_password_placeholder": "Re-enter your password",
|
||||
"confirm_new_password": "Confirm new password",
|
||||
"confirm_new_password_placeholder": "Re-enter your new password",
|
||||
"password_mismatch": "The two passwords do not match.",
|
||||
"mfa_title": "Multi-factor authentication",
|
||||
"mfa_passcode": "Passcode",
|
||||
"mfa_passcode_placeholder": "Enter the 6-digit code from your authenticator",
|
||||
"mfa_recovery_code": "Recovery code",
|
||||
"mfa_recovery_code_placeholder": "Enter a recovery code",
|
||||
"mfa_use_recovery_code": "Use a recovery code instead",
|
||||
"mfa_use_passcode": "Use a passcode instead",
|
||||
"mfa_verify": "Verify",
|
||||
"mfa_verifying": "Verifying...",
|
||||
"mfa_session_expired": "Your sign-in session has expired. Please sign in again.",
|
||||
"mfa_verify_failed": "Verification failed. Please try again.",
|
||||
"activate_your_account": "Activate your account",
|
||||
"resend_rate_limited": "Sorry, you already requested an activation email recently. Please wait 3 minutes then try again.",
|
||||
"send_activation_email": "Send activation email",
|
||||
"check_activation_email": "Please check your email and click the activation link to finish creating your account.",
|
||||
"activation_email_pending": "Your email address <email>{email}</email> is not yet confirmed. Click below to send a new activation email valid for <hours>{hours} hours</hours>.",
|
||||
"activation_email_sent": "A new activation email has been sent to <email>{email}</email>. Please check your inbox within <hours>{hours} hours</hours>.",
|
||||
"sending_activation_email": "Sending activation email...",
|
||||
"send_activation_email_failed": "Could not send activation email, please try again.",
|
||||
"activating_account": "Activating your account..."
|
||||
"close": "Close",
|
||||
"show_more": "Show more",
|
||||
"show_less": "Show less",
|
||||
"resize_sidebar": "Resize sidebar",
|
||||
"more": "More",
|
||||
"more_tabs": "More tabs",
|
||||
"more_actions": "More actions",
|
||||
"status.page_not_found": "Page not found",
|
||||
"status.internal_server_error": "Internal server error",
|
||||
"auth.auth_source": "Authentication source",
|
||||
"auth.local": "Local",
|
||||
"auth.forget_password": "Forgot password?",
|
||||
"auth.send_reset_email": "Send password reset email",
|
||||
"auth.reset_password_email_submitting": "Sending password reset email...",
|
||||
"auth.reset_password_email_failed": "Could not send password reset email, please try again.",
|
||||
"auth.reset_password_email_sent": "A password reset email has been sent to <email>{email}</email>, please check your inbox within <hours>{hours} hours</hours>.",
|
||||
"auth.disable_register_mail": "Sorry, email services are disabled. Please contact the site administrator.",
|
||||
"auth.disable_register_prompt": "Sorry, registration has been disabled. Please contact the site administrator.",
|
||||
"auth.reset_password_resend_limited": "You already requested a password reset email recently. Please wait 3 minutes then try again.",
|
||||
"auth.non_local_account": "Non-local accounts cannot change passwords through Gogs.",
|
||||
"auth.create_new_account": "Create new account",
|
||||
"auth.register_hepler_msg": "Already have an account? Sign in now!",
|
||||
"auth.sign_up_now": "Create a new account",
|
||||
"auth.sign_up_submitting": "Creating account...",
|
||||
"auth.sign_up_failed": "Could not create account, please try again.",
|
||||
"auth.sign_in_submitting": "Signing in...",
|
||||
"auth.sign_in_failed": "Could not sign in, please try again.",
|
||||
"auth.show_password": "Show password",
|
||||
"auth.hide_password": "Hide password",
|
||||
"auth.back_to_sign_in": "Back to sign in",
|
||||
"auth.reset_password": "Reset your password",
|
||||
"auth.invalid_code": "The confirmation code has expired or not valid.",
|
||||
"auth.reset_password_submit": "Reset password",
|
||||
"auth.reset_password_submitting": "Resetting password...",
|
||||
"auth.reset_password_failed": "Could not reset password, please try again.",
|
||||
"auth.new_password": "New password",
|
||||
"auth.new_password_placeholder": "Enter your new password",
|
||||
"auth.confirm_password": "Confirm password",
|
||||
"auth.confirm_password_placeholder": "Re-enter your password",
|
||||
"auth.confirm_new_password": "Confirm new password",
|
||||
"auth.confirm_new_password_placeholder": "Re-enter your new password",
|
||||
"auth.password_mismatch": "The two passwords do not match.",
|
||||
"auth.mfa_title": "Multi-factor authentication",
|
||||
"auth.mfa_passcode": "Passcode",
|
||||
"auth.mfa_passcode_placeholder": "Enter the 6-digit code from your authenticator",
|
||||
"auth.mfa_recovery_code": "Recovery code",
|
||||
"auth.mfa_recovery_code_placeholder": "Enter a recovery code",
|
||||
"auth.mfa_use_recovery_code": "Use a recovery code instead",
|
||||
"auth.mfa_use_passcode": "Use a passcode instead",
|
||||
"auth.mfa_verify": "Verify",
|
||||
"auth.mfa_verifying": "Verifying...",
|
||||
"auth.mfa_session_expired": "Your sign-in session has expired. Please sign in again.",
|
||||
"auth.mfa_verify_failed": "Verification failed. Please try again.",
|
||||
"auth.activate_your_account": "Activate your account",
|
||||
"auth.resend_rate_limited": "Sorry, you already requested an activation email recently. Please wait 3 minutes then try again.",
|
||||
"auth.send_activation_email": "Send activation email",
|
||||
"auth.check_activation_email": "Please check your email and click the activation link to finish creating your account.",
|
||||
"auth.activation_email_pending": "Your email address <email>{email}</email> is not yet confirmed. Click below to send a new activation email valid for <hours>{hours} hours</hours>.",
|
||||
"auth.activation_email_sent": "A new activation email has been sent to <email>{email}</email>. Please check your inbox within <hours>{hours} hours</hours>.",
|
||||
"auth.sending_activation_email": "Sending activation email...",
|
||||
"auth.send_activation_email_failed": "Could not send activation email, please try again.",
|
||||
"auth.activating_account": "Activating your account...",
|
||||
"tool.now": "now",
|
||||
"tool.ago": "ago",
|
||||
"tool.from_now": "from now",
|
||||
"tool.1s": "1 second %s",
|
||||
"tool.1m": "1 minute %s",
|
||||
"tool.1h": "1 hour %s",
|
||||
"tool.1d": "1 day %s",
|
||||
"tool.1w": "1 week %s",
|
||||
"tool.1mon": "1 month %s",
|
||||
"tool.1y": "1 year %s",
|
||||
"tool.seconds": "%d seconds %s",
|
||||
"tool.minutes": "%d minutes %s",
|
||||
"tool.hours": "%d hours %s",
|
||||
"tool.days": "%d days %s",
|
||||
"tool.weeks": "%d weeks %s",
|
||||
"tool.months": "%d months %s",
|
||||
"tool.years": "%d years %s",
|
||||
"repo.diff.showing_changed_files": "Showing <count>{count} changed files</count>",
|
||||
"repo.diff.additions": "additions",
|
||||
"repo.diff.deletions": "deletions",
|
||||
"repo.diff.unified": "Unified",
|
||||
"repo.diff.split": "Split",
|
||||
"repo.diff.diff_settings": "Diff settings",
|
||||
"repo.diff.whitespace": "Whitespace",
|
||||
"repo.diff.show_whitespace": "Show whitespace",
|
||||
"repo.diff.ignore_whitespace_changes": "Ignore whitespace changes",
|
||||
"repo.diff.ignore_all_whitespace": "Ignore all whitespace",
|
||||
"repo.diff.display": "Display",
|
||||
"repo.diff.wrap_long_lines": "Wrap long line",
|
||||
"repo.diff.expand_all_files": "Expand all files",
|
||||
"repo.diff.collapse_all_files": "Collapse all files",
|
||||
"repo.show_file_tree": "Show file tree",
|
||||
"repo.hide_file_tree": "Hide file tree",
|
||||
"repo.expand_all_directories": "Expand all directories",
|
||||
"repo.collapse_all_directories": "Collapse all directories",
|
||||
"repo.search_files": "Search files",
|
||||
"repo.search_hide": "Hide search",
|
||||
"repo.search_diff": "Search in diff",
|
||||
"repo.search_previous_match": "Previous match",
|
||||
"repo.search_next_match": "Next match",
|
||||
"repo.diff.expand_file": "Expand file",
|
||||
"repo.diff.collapse_file": "Collapse file",
|
||||
"repo.diff.expand_all_lines": "Expand all lines",
|
||||
"repo.diff.all_lines_expanded": "All lines expanded",
|
||||
"repo.commit_parent": "parent",
|
||||
"repo.commit_label": "commit",
|
||||
"repo.view_file": "View file",
|
||||
"repo.editor.edit_file": "Edit file",
|
||||
"repo.editor.delete_this_file": "Delete this file",
|
||||
"repo.files": "Files",
|
||||
"repo.settings": "Settings",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Watch",
|
||||
"repo.unwatch": "Unwatch",
|
||||
"repo.star": "Star",
|
||||
"repo.starred": "Starred",
|
||||
"repo.fork": "Fork",
|
||||
"repo.mirror_of": "mirror of",
|
||||
"repo.sign_in_to_watch": "Sign in to watch this repository",
|
||||
"repo.sign_in_to_star": "Sign in to star this repository",
|
||||
"repo.sign_in_to_fork": "Sign in to fork this repository",
|
||||
"repo.watch_this_repository": "Watch this repository",
|
||||
"repo.unwatch_this_repository": "Unwatch this repository",
|
||||
"repo.star_this_repository": "Star this repository",
|
||||
"repo.unstar_this_repository": "Unstar this repository",
|
||||
"repo.fork_this_repository": "Fork this repository",
|
||||
"repo.visibility_private": "This repository is private",
|
||||
"repo.visibility_public": "This repository is public",
|
||||
"repo.view_watchers": "View watchers",
|
||||
"repo.view_stargazers": "View stargazers",
|
||||
"repo.view_forks": "View forks",
|
||||
"repo.browse_files": "Browse files",
|
||||
"repo.view_history": "View history",
|
||||
"repo.view_raw": "View raw",
|
||||
"repo.copy_file_path": "Copy file path",
|
||||
"repo.copy_full_sha": "Copy full SHA",
|
||||
"repo.renamed_from": "Renamed from",
|
||||
"repo.authored": "authored",
|
||||
"repo.parents": "parents",
|
||||
"repo.diff_label": "diff",
|
||||
"repo.patch_label": "patch"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Panel de administración",
|
||||
"settings": "Configuraciones",
|
||||
"language": "Idioma",
|
||||
"page_not_found": "Página no encontrada",
|
||||
"internal_server_error": "Error Interno del Servidor",
|
||||
"repository": "Repositorio",
|
||||
"username": "Nombre de usuario",
|
||||
"email": "Correo electrónico",
|
||||
"password": "Contraseña",
|
||||
"captcha": "Captcha",
|
||||
"auth_source": "Authentication Source",
|
||||
"local": "Local",
|
||||
"forget_password": "¿Has olvidado tu contraseña?",
|
||||
"disable_register_mail": "Lo sentimos. Los correos de Confirmación de Registro están deshabilitados.",
|
||||
"disable_register_prompt": "Lo sentimos, el registro está deshabilitado. Por favor, contacta con el administrador del sitio.",
|
||||
"non_local_account": "Cuentas que no son locales no pueden cambiar las contraseñas a través de Gogs.",
|
||||
"create_new_account": "Crear una nueva cuenta",
|
||||
"register_hepler_msg": "¿Ya tienes una cuenta? ¡Inicia sesión!",
|
||||
"sign_up": "Registro",
|
||||
"sign_up_now": "¿Necesitas una cuenta? Regístrate ahora.",
|
||||
"reset_password": "Restablecer su contraseña",
|
||||
"invalid_code": "Lo sentimos, su código de confirmación ha expirado o no es valido.",
|
||||
"new_password": "Nueva contraseña",
|
||||
"confirm_password": "Confirmar Contraseña"
|
||||
"status.page_not_found": "Página no encontrada",
|
||||
"status.internal_server_error": "Error Interno del Servidor",
|
||||
"auth.auth_source": "Authentication Source",
|
||||
"auth.local": "Local",
|
||||
"auth.forget_password": "¿Has olvidado tu contraseña?",
|
||||
"auth.disable_register_mail": "Lo sentimos. Los correos de Confirmación de Registro están deshabilitados.",
|
||||
"auth.disable_register_prompt": "Lo sentimos, el registro está deshabilitado. Por favor, contacta con el administrador del sitio.",
|
||||
"auth.non_local_account": "Cuentas que no son locales no pueden cambiar las contraseñas a través de Gogs.",
|
||||
"auth.create_new_account": "Crear una nueva cuenta",
|
||||
"auth.register_hepler_msg": "¿Ya tienes una cuenta? ¡Inicia sesión!",
|
||||
"auth.sign_up_now": "¿Necesitas una cuenta? Regístrate ahora.",
|
||||
"auth.reset_password": "Restablecer su contraseña",
|
||||
"auth.invalid_code": "Lo sentimos, su código de confirmación ha expirado o no es valido.",
|
||||
"tool.now": "ahora",
|
||||
"tool.ago": "hace",
|
||||
"tool.from_now": "desde ahora",
|
||||
"tool.1s": "%s 1 segundo",
|
||||
"tool.1m": "%s 1 minuto",
|
||||
"tool.1h": "%s 1 hora",
|
||||
"tool.1d": "%s 1 día",
|
||||
"tool.1w": "%s 1 semana",
|
||||
"tool.1mon": "%s 1 mes",
|
||||
"tool.1y": "%s 1 año",
|
||||
"tool.seconds": "%[2]s %[1]d segundos",
|
||||
"tool.minutes": "%[2]s %[1]d minutos",
|
||||
"tool.hours": "%[2]s %[1]d horas",
|
||||
"tool.days": "%[2]s %[1]d días",
|
||||
"tool.weeks": "%[2]s %[1]d semanas",
|
||||
"tool.months": "%[2]s %[1]d meses",
|
||||
"tool.years": "%[2]s %[1]d años",
|
||||
"repo.editor.edit_file": "Editar archivo",
|
||||
"repo.editor.delete_this_file": "Eliminar este archivo",
|
||||
"repo.files": "Archivos",
|
||||
"repo.settings": "Configuración",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Seguir",
|
||||
"repo.unwatch": "Dejar de vigilar",
|
||||
"repo.star": "Destacar",
|
||||
"repo.fork": "Fork"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "پنل مدیریت",
|
||||
"settings": "تنظيمات",
|
||||
"language": "زبان",
|
||||
"page_not_found": "صفحه مورد نظر یافت نشد.",
|
||||
"internal_server_error": "خطای داخلی سرور",
|
||||
"repository": "مخزن",
|
||||
"username": "نام کاربری",
|
||||
"email": "ایمیل",
|
||||
"password": "رمز عبور",
|
||||
"captcha": "تصویر امنیتی",
|
||||
"auth_source": "محل احراز هویت",
|
||||
"local": "محلی",
|
||||
"forget_password": "رمز عبور خود را فراموش کردهاید؟",
|
||||
"disable_register_mail": "با عرض پوزش، تایید ایمیل ثبت نام غیر فعال شده است.",
|
||||
"disable_register_prompt": "با عرض پوزش، ثبت نام غیرفعال شده است. لطفا با مدیر سایت تماس بگیرید.",
|
||||
"non_local_account": "حساب های کاربری غیر محلی قادر به تغییر رمز عبور از طریق Gogs نمی باشند.",
|
||||
"create_new_account": "ایجاد حساب جدید",
|
||||
"register_hepler_msg": "قبلا ثبت نام کردید؟ از اینجا وارد شوید!",
|
||||
"sign_up": "ثبتنام کنید",
|
||||
"sign_up_now": "نیاز به یک حساب دارید؟ هماکنون ثبت نام کنید.",
|
||||
"reset_password": "تنظیم مجدد رمز عبور",
|
||||
"invalid_code": "با عرض پوزش، کد تایید شما منقضی شده است و یا معتبر نیست.",
|
||||
"new_password": "رمز عبور جدید",
|
||||
"confirm_password": "تأیید رمز عبور"
|
||||
"status.page_not_found": "صفحه مورد نظر یافت نشد.",
|
||||
"status.internal_server_error": "خطای داخلی سرور",
|
||||
"auth.auth_source": "محل احراز هویت",
|
||||
"auth.local": "محلی",
|
||||
"auth.forget_password": "رمز عبور خود را فراموش کردهاید؟",
|
||||
"auth.disable_register_mail": "با عرض پوزش، تایید ایمیل ثبت نام غیر فعال شده است.",
|
||||
"auth.disable_register_prompt": "با عرض پوزش، ثبت نام غیرفعال شده است. لطفا با مدیر سایت تماس بگیرید.",
|
||||
"auth.non_local_account": "حساب های کاربری غیر محلی قادر به تغییر رمز عبور از طریق Gogs نمی باشند.",
|
||||
"auth.create_new_account": "ایجاد حساب جدید",
|
||||
"auth.register_hepler_msg": "قبلا ثبت نام کردید؟ از اینجا وارد شوید!",
|
||||
"auth.sign_up_now": "نیاز به یک حساب دارید؟ هماکنون ثبت نام کنید.",
|
||||
"auth.reset_password": "تنظیم مجدد رمز عبور",
|
||||
"auth.invalid_code": "با عرض پوزش، کد تایید شما منقضی شده است و یا معتبر نیست.",
|
||||
"tool.now": "حالا",
|
||||
"tool.ago": "پیش",
|
||||
"tool.from_now": "از هم اکنون",
|
||||
"tool.1s": "1 ثانیه %s",
|
||||
"tool.1m": "1 دقیقه %s",
|
||||
"tool.1h": "1 ساعت %s",
|
||||
"tool.1d": "1 روز %s",
|
||||
"tool.1w": "1 هفته %s",
|
||||
"tool.1mon": "1 ماه %s",
|
||||
"tool.1y": "1 سال %s",
|
||||
"tool.seconds": "%d ثانیه %s",
|
||||
"tool.minutes": "%d دقیقه %s",
|
||||
"tool.hours": "%d ساعت %s",
|
||||
"tool.days": "%d روز %s",
|
||||
"tool.weeks": "%d هفته %s",
|
||||
"tool.months": "%d ماه %s",
|
||||
"tool.years": "%d سال %s",
|
||||
"repo.editor.edit_file": "ویرایش فایل",
|
||||
"repo.editor.delete_this_file": "حذف این پرونده",
|
||||
"repo.files": "پروندهها",
|
||||
"repo.settings": "تنظيمات",
|
||||
"repo.wiki": "ویکی",
|
||||
"repo.watch": "دنبال کردن",
|
||||
"repo.unwatch": "زیر نظر نگرفتن",
|
||||
"repo.star": "ستاره دار",
|
||||
"repo.fork": "انشعاب"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Ylläpito paneeli",
|
||||
"settings": "Asetukset",
|
||||
"language": "Kieli",
|
||||
"page_not_found": "Sivua ei löydy",
|
||||
"internal_server_error": "Sisäinen palvelinvirhe",
|
||||
"repository": "Tietosäilö",
|
||||
"username": "Käyttäjätunnus",
|
||||
"email": "Sähköposti",
|
||||
"password": "Salasana",
|
||||
"captcha": "Captcha",
|
||||
"auth_source": "Todennuslähde",
|
||||
"local": "Paikallinen",
|
||||
"forget_password": "Unohtuiko salasana?",
|
||||
"disable_register_mail": "Valitettavasti sähköpostipalvelut ovat poissa käytöstä. Otathan yhteyttä sivuston ylläpitoon.",
|
||||
"disable_register_prompt": "Valitettavasti rekisteröinti on poistettu käytöstä. Ole hyvä ja ota yhteyttä sivuston ylläpitoon.",
|
||||
"non_local_account": "Vain paikallisten käyttäjätilien salasanan vaihto onnistuu Gogsin kautta.",
|
||||
"create_new_account": "Luo uusi tili",
|
||||
"register_hepler_msg": "Onko sinulla jo tili? Kirjaudu sisään nyt!",
|
||||
"sign_up": "Rekisteröidy",
|
||||
"sign_up_now": "Tarvitsetko tilin? Rekisteröidy nyt.",
|
||||
"reset_password": "Nollaa salasanasi",
|
||||
"invalid_code": "Sori, varmistuskoodisi on vanhentunut tai väärä.",
|
||||
"new_password": "Uusi salasana",
|
||||
"confirm_password": "Varmista salasana"
|
||||
"status.page_not_found": "Sivua ei löydy",
|
||||
"status.internal_server_error": "Sisäinen palvelinvirhe",
|
||||
"auth.auth_source": "Todennuslähde",
|
||||
"auth.local": "Paikallinen",
|
||||
"auth.forget_password": "Unohtuiko salasana?",
|
||||
"auth.disable_register_mail": "Valitettavasti sähköpostipalvelut ovat poissa käytöstä. Otathan yhteyttä sivuston ylläpitoon.",
|
||||
"auth.disable_register_prompt": "Valitettavasti rekisteröinti on poistettu käytöstä. Ole hyvä ja ota yhteyttä sivuston ylläpitoon.",
|
||||
"auth.non_local_account": "Vain paikallisten käyttäjätilien salasanan vaihto onnistuu Gogsin kautta.",
|
||||
"auth.create_new_account": "Luo uusi tili",
|
||||
"auth.register_hepler_msg": "Onko sinulla jo tili? Kirjaudu sisään nyt!",
|
||||
"auth.sign_up_now": "Tarvitsetko tilin? Rekisteröidy nyt.",
|
||||
"auth.reset_password": "Nollaa salasanasi",
|
||||
"auth.invalid_code": "Sori, varmistuskoodisi on vanhentunut tai väärä.",
|
||||
"tool.now": "nyt",
|
||||
"tool.ago": "sitten",
|
||||
"tool.from_now": "alkaen nyt",
|
||||
"tool.1s": "1 sekunti %s",
|
||||
"tool.1m": "1 minuutti %s",
|
||||
"tool.1h": "1 tunti %s",
|
||||
"tool.1d": "1 päivä %s",
|
||||
"tool.1w": "1 viikko %s",
|
||||
"tool.1mon": "1 kuukausi %s",
|
||||
"tool.1y": "1 vuosi %s",
|
||||
"tool.seconds": "%d sekuntia %s",
|
||||
"tool.minutes": "%d minuuttia %s",
|
||||
"tool.hours": "%d tuntia %s",
|
||||
"tool.days": "%d päivää %s",
|
||||
"tool.weeks": "%d viikkoa %s",
|
||||
"tool.months": "%d kuukautta %s",
|
||||
"tool.years": "%d vuotta %s",
|
||||
"repo.editor.edit_file": "Muokkaa tiedostoa",
|
||||
"repo.editor.delete_this_file": "Poista tämä tiedosto",
|
||||
"repo.files": "Tiedostot",
|
||||
"repo.settings": "Asetukset",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Tarkkaile",
|
||||
"repo.unwatch": "Lopeta tarkkailu",
|
||||
"repo.star": "Äänestä",
|
||||
"repo.fork": "Haarauta"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Administration",
|
||||
"settings": "Paramètres",
|
||||
"language": "Langue",
|
||||
"page_not_found": "Page non trouvée",
|
||||
"internal_server_error": "Erreur interne du serveur",
|
||||
"repository": "Dépôt",
|
||||
"username": "Nom d'utilisateur",
|
||||
"email": "E-mail",
|
||||
"password": "Mot de passe",
|
||||
"captcha": "Captcha",
|
||||
"auth_source": "Sources d'authentification",
|
||||
"local": "Locale",
|
||||
"forget_password": "Mot de passe oublié ?",
|
||||
"disable_register_mail": "Désolé, la confirmation par courriel des enregistrements a été désactivée.",
|
||||
"disable_register_prompt": "Désolé, les enregistrements ont été désactivés. Veuillez contacter l'administrateur du site.",
|
||||
"non_local_account": "Les comptes non locaux ne peuvent pas changer leur mot de passe via Gogs.",
|
||||
"create_new_account": "Créer un nouveau compte",
|
||||
"register_hepler_msg": "Déjà enregistré ? Connectez-vous !",
|
||||
"sign_up": "Inscription",
|
||||
"sign_up_now": "Pas de compte ? Inscrivez-vous maintenant.",
|
||||
"reset_password": "Réinitialiser le mot de passe",
|
||||
"invalid_code": "Désolé, votre code de confirmation est invalide ou a expiré.",
|
||||
"new_password": "Nouveau mot de passe",
|
||||
"confirm_password": "Confirmez le mot de passe"
|
||||
"status.page_not_found": "Page non trouvée",
|
||||
"status.internal_server_error": "Erreur interne du serveur",
|
||||
"auth.auth_source": "Sources d'authentification",
|
||||
"auth.local": "Locale",
|
||||
"auth.forget_password": "Mot de passe oublié ?",
|
||||
"auth.disable_register_mail": "Désolé, la confirmation par courriel des enregistrements a été désactivée.",
|
||||
"auth.disable_register_prompt": "Désolé, les enregistrements ont été désactivés. Veuillez contacter l'administrateur du site.",
|
||||
"auth.non_local_account": "Les comptes non locaux ne peuvent pas changer leur mot de passe via Gogs.",
|
||||
"auth.create_new_account": "Créer un nouveau compte",
|
||||
"auth.register_hepler_msg": "Déjà enregistré ? Connectez-vous !",
|
||||
"auth.sign_up_now": "Pas de compte ? Inscrivez-vous maintenant.",
|
||||
"auth.reset_password": "Réinitialiser le mot de passe",
|
||||
"auth.invalid_code": "Désolé, votre code de confirmation est invalide ou a expiré.",
|
||||
"tool.now": "maintenant",
|
||||
"tool.ago": "il y a",
|
||||
"tool.from_now": "dans",
|
||||
"tool.1s": "%s 1 seconde",
|
||||
"tool.1m": "%s 1 minute",
|
||||
"tool.1h": "%s 1 heure",
|
||||
"tool.1d": "%s 1 jour",
|
||||
"tool.1w": "%s 1 semaine",
|
||||
"tool.1mon": "%s 1 mois",
|
||||
"tool.1y": "%s 1 an",
|
||||
"tool.seconds": "%[2]s %[1]d secondes",
|
||||
"tool.minutes": "%[2]s %[1]d minutes",
|
||||
"tool.hours": "%[2]s %[1]d heures",
|
||||
"tool.days": "%[2]s %[1]d jours",
|
||||
"tool.weeks": "%[2]s %[1]d semaines",
|
||||
"tool.months": "%[2]s %[1]d mois",
|
||||
"tool.years": "%[2]s %[1]d ans",
|
||||
"repo.editor.edit_file": "Modifier fichier",
|
||||
"repo.editor.delete_this_file": "Supprimer ce fichier",
|
||||
"repo.files": "Fichiers",
|
||||
"repo.settings": "Paramètres",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Suivre",
|
||||
"repo.unwatch": "Ne plus suivre",
|
||||
"repo.star": "Voter",
|
||||
"repo.fork": "Fork"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Panel de administración",
|
||||
"settings": "Configuracións",
|
||||
"language": "Idioma",
|
||||
"page_not_found": "Page Not Found",
|
||||
"internal_server_error": "Internal Server Error",
|
||||
"repository": "Repositorio",
|
||||
"username": "Nome da persoa usuaria",
|
||||
"email": "Correo electrónico",
|
||||
"password": "Contrasinal",
|
||||
"captcha": "Captcha=Captcha",
|
||||
"auth_source": "Fonte de Autenticación",
|
||||
"local": "Configuración rexional",
|
||||
"forget_password": "Esqueciches o teu contrasinal?",
|
||||
"disable_register_mail": "Sentímolo. Os correos de confirmación de rexistro están deshabilitados.",
|
||||
"disable_register_prompt": "Sentímolo, o rexistro está deshabilitado. Por favor, contacta co administrador do sitio.",
|
||||
"non_local_account": "Contas que non son locais non poden cambiar os contrasinais a través de Gogs.",
|
||||
"create_new_account": "Crear unha nova conta",
|
||||
"register_hepler_msg": "Xa tes unha conta? Inicia sesión!",
|
||||
"sign_up": "Rexistro",
|
||||
"sign_up_now": "Necesitas unha conta? Rexístrate agora.",
|
||||
"reset_password": "Restablecer o teu contrasinal",
|
||||
"invalid_code": "Sentímolo, o teu código de confirmación expirou ou non é válido.",
|
||||
"new_password": "Novo contrasinal",
|
||||
"confirm_password": "Confirmar contrasinal"
|
||||
"status.page_not_found": "Page Not Found",
|
||||
"status.internal_server_error": "Internal Server Error",
|
||||
"auth.auth_source": "Fonte de Autenticación",
|
||||
"auth.local": "Configuración rexional",
|
||||
"auth.forget_password": "Esqueciches o teu contrasinal?",
|
||||
"auth.disable_register_mail": "Sentímolo. Os correos de confirmación de rexistro están deshabilitados.",
|
||||
"auth.disable_register_prompt": "Sentímolo, o rexistro está deshabilitado. Por favor, contacta co administrador do sitio.",
|
||||
"auth.non_local_account": "Contas que non son locais non poden cambiar os contrasinais a través de Gogs.",
|
||||
"auth.create_new_account": "Crear unha nova conta",
|
||||
"auth.register_hepler_msg": "Xa tes unha conta? Inicia sesión!",
|
||||
"auth.sign_up_now": "Necesitas unha conta? Rexístrate agora.",
|
||||
"auth.reset_password": "Restablecer o teu contrasinal",
|
||||
"auth.invalid_code": "Sentímolo, o teu código de confirmación expirou ou non é válido.",
|
||||
"tool.now": "agora",
|
||||
"tool.ago": "hai",
|
||||
"tool.from_now": "dende agora",
|
||||
"tool.1s": "%s 1 segundo",
|
||||
"tool.1m": "%s 1 minuto",
|
||||
"tool.1h": "%s 1 hora",
|
||||
"tool.1d": "%s 1 día",
|
||||
"tool.1w": "%s 1 semana",
|
||||
"tool.1mon": "%s 1 mes",
|
||||
"tool.1y": "%s 1 ano",
|
||||
"tool.seconds": "%[2]s %[1]d segundos",
|
||||
"tool.minutes": "%[2]s %[1]d minutos",
|
||||
"tool.hours": "%[2]s %[1]d horas",
|
||||
"tool.days": "%[2]s %[1]d días",
|
||||
"tool.weeks": "%[2]s %[1]d semanas",
|
||||
"tool.months": "%[2]s %[1]d meses",
|
||||
"tool.years": "%s %d anos",
|
||||
"repo.editor.edit_file": "Editar arquivo",
|
||||
"repo.editor.delete_this_file": "Borrar este arquivo",
|
||||
"repo.files": "Ficheiros",
|
||||
"repo.settings": "Configuración",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Seguir",
|
||||
"repo.unwatch": "Deixar de vixiar",
|
||||
"repo.star": "Destacar",
|
||||
"repo.fork": "Fork"
|
||||
}
|
||||
|
||||
+39
-16
@@ -20,24 +20,47 @@
|
||||
"admin_panel": "Rendszergazdai felület",
|
||||
"settings": "Beállítások",
|
||||
"language": "Nyelv",
|
||||
"page_not_found": "Az oldal nem található",
|
||||
"internal_server_error": "Belső kiszolgálóhiba",
|
||||
"repository": "Tároló",
|
||||
"username": "Felhasználónév",
|
||||
"email": "E-mail",
|
||||
"password": "Jelszó",
|
||||
"captcha": "Ellenőrző kód",
|
||||
"auth_source": "Hitelesítési forrás",
|
||||
"local": "Helyi",
|
||||
"forget_password": "Elfelejtette a jelszavát?",
|
||||
"disable_register_mail": "Elnézést, az email regisztráció megerősítését kikapcsolták.",
|
||||
"disable_register_prompt": "Elnézést, a regisztrációt kikapcsolták. Kérlek szólj az oldal adminisztrátorának.",
|
||||
"non_local_account": "Nem helyi felhasználó nem cserélhet jelszót a Gogsban.",
|
||||
"create_new_account": "Új fiók létrehozása",
|
||||
"register_hepler_msg": "Van már felhasználói fiókja? Jelentkezz be!",
|
||||
"sign_up": "Regisztráció",
|
||||
"sign_up_now": "Szeretne bejelentkezni? Regisztráljon most.",
|
||||
"reset_password": "Jelszó visszaállítása",
|
||||
"invalid_code": "Elnézést, a megerősítő kód lejárt vagy hibás.",
|
||||
"new_password": "Új jelszó",
|
||||
"confirm_password": "Jelszó megerősítése"
|
||||
"status.page_not_found": "Az oldal nem található",
|
||||
"status.internal_server_error": "Belső kiszolgálóhiba",
|
||||
"auth.auth_source": "Hitelesítési forrás",
|
||||
"auth.local": "Helyi",
|
||||
"auth.forget_password": "Elfelejtette a jelszavát?",
|
||||
"auth.disable_register_mail": "Elnézést, az email regisztráció megerősítését kikapcsolták.",
|
||||
"auth.disable_register_prompt": "Elnézést, a regisztrációt kikapcsolták. Kérlek szólj az oldal adminisztrátorának.",
|
||||
"auth.non_local_account": "Nem helyi felhasználó nem cserélhet jelszót a Gogsban.",
|
||||
"auth.create_new_account": "Új fiók létrehozása",
|
||||
"auth.register_hepler_msg": "Van már felhasználói fiókja? Jelentkezz be!",
|
||||
"auth.sign_up_now": "Szeretne bejelentkezni? Regisztráljon most.",
|
||||
"auth.reset_password": "Jelszó visszaállítása",
|
||||
"auth.invalid_code": "Elnézést, a megerősítő kód lejárt vagy hibás.",
|
||||
"tool.now": "most",
|
||||
"tool.from_now": "mostantól",
|
||||
"tool.1s": "1 másodperce %s",
|
||||
"tool.1m": "1 perce %s",
|
||||
"tool.1h": "1 órája %s",
|
||||
"tool.1d": "1 napja %s",
|
||||
"tool.1w": "1 hete %s",
|
||||
"tool.1mon": "1 hónapja %s",
|
||||
"tool.1y": "1 éve %s",
|
||||
"tool.seconds": "%d másodperce %s",
|
||||
"tool.minutes": "%d perce %s",
|
||||
"tool.hours": "%d órája %s",
|
||||
"tool.days": "%d napja %s",
|
||||
"tool.weeks": "%d hete %s",
|
||||
"tool.months": "%d hónapja %s",
|
||||
"tool.years": "%d éve %s",
|
||||
"repo.editor.edit_file": "Fájl szerkesztése",
|
||||
"repo.editor.delete_this_file": "A fájl törlése",
|
||||
"repo.files": "Fájlok",
|
||||
"repo.settings": "Beállítások",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Figyelés",
|
||||
"repo.unwatch": "Figyelés törlése",
|
||||
"repo.star": "Kedvenc",
|
||||
"repo.fork": "Másolás"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Panel Admin",
|
||||
"settings": "Pengaturan",
|
||||
"language": "Bahasa",
|
||||
"page_not_found": "Halaman tidak ditemukan",
|
||||
"internal_server_error": "Kesalahan Server Internal",
|
||||
"repository": "Repositori",
|
||||
"username": "Nama pengguna",
|
||||
"email": "Email",
|
||||
"password": "Sandi",
|
||||
"captcha": "Captcha",
|
||||
"auth_source": "Sumber Autentikasi",
|
||||
"local": "Lokal",
|
||||
"forget_password": "Lupa sandi?",
|
||||
"disable_register_mail": "Maaf, konfirmasi pendaftaran melalui email telah dinonaktifkan.",
|
||||
"disable_register_prompt": "Maaf, pendaftaran telah dinonaktifkan. Hubungi administrator situs.",
|
||||
"non_local_account": "Akun non-lokal tidak dapat mengganti password lewat Gogs.",
|
||||
"create_new_account": "Buat akun baru",
|
||||
"register_hepler_msg": "Sudah memiliki account? Sign in sekarang!",
|
||||
"sign_up": "Daftar",
|
||||
"sign_up_now": "Membutuhkan akun? Daftar sekarang.",
|
||||
"reset_password": "Atur Ulang Sandi",
|
||||
"invalid_code": "Maaf, kode konfirmasi Anda telah kadaluarsa atau tidak valid.",
|
||||
"new_password": "Sandi baru",
|
||||
"confirm_password": "Konfirmasi sandi"
|
||||
"status.page_not_found": "Halaman tidak ditemukan",
|
||||
"status.internal_server_error": "Kesalahan Server Internal",
|
||||
"auth.auth_source": "Sumber Autentikasi",
|
||||
"auth.local": "Lokal",
|
||||
"auth.forget_password": "Lupa sandi?",
|
||||
"auth.disable_register_mail": "Maaf, konfirmasi pendaftaran melalui email telah dinonaktifkan.",
|
||||
"auth.disable_register_prompt": "Maaf, pendaftaran telah dinonaktifkan. Hubungi administrator situs.",
|
||||
"auth.non_local_account": "Akun non-lokal tidak dapat mengganti password lewat Gogs.",
|
||||
"auth.create_new_account": "Buat akun baru",
|
||||
"auth.register_hepler_msg": "Sudah memiliki account? Sign in sekarang!",
|
||||
"auth.sign_up_now": "Membutuhkan akun? Daftar sekarang.",
|
||||
"auth.reset_password": "Atur Ulang Sandi",
|
||||
"auth.invalid_code": "Maaf, kode konfirmasi Anda telah kadaluarsa atau tidak valid.",
|
||||
"tool.now": "sekarang",
|
||||
"tool.ago": "lalu",
|
||||
"tool.from_now": "dari sekarang",
|
||||
"tool.1s": "1 detik %s",
|
||||
"tool.1m": "1 menit %s",
|
||||
"tool.1h": "1 jam %s",
|
||||
"tool.1d": "1 hari %s",
|
||||
"tool.1w": "1 Minggu %s",
|
||||
"tool.1mon": "1 bulan %s",
|
||||
"tool.1y": "1 tahun %s",
|
||||
"tool.seconds": "%d detik %s",
|
||||
"tool.minutes": "%d menit %s",
|
||||
"tool.hours": "%d jam %s",
|
||||
"tool.days": "%d hari %s",
|
||||
"tool.weeks": "%d minggu %s",
|
||||
"tool.months": "%d bulan %s",
|
||||
"tool.years": "%d tahun %s",
|
||||
"repo.editor.edit_file": "Edit berkas",
|
||||
"repo.editor.delete_this_file": "Hapus berkas ini",
|
||||
"repo.files": "Berkas",
|
||||
"repo.settings": "Pengaturan",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Liatin",
|
||||
"repo.unwatch": "Batal liatin",
|
||||
"repo.star": "Bintangi",
|
||||
"repo.fork": "Fork"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Pannello di amministrazione",
|
||||
"settings": "Impostazioni",
|
||||
"language": "Lingua",
|
||||
"page_not_found": "Pagina Non Trovata",
|
||||
"internal_server_error": "Errore Interno del Server",
|
||||
"repository": "Repository",
|
||||
"username": "Nome utente",
|
||||
"email": "E-mail",
|
||||
"password": "Password",
|
||||
"captcha": "Captcha",
|
||||
"auth_source": "Fonte di autenticazione",
|
||||
"local": "Locale",
|
||||
"forget_password": "Password dimenticata?",
|
||||
"disable_register_mail": "Siamo spiacenti, la conferma di registrazione via Mail è stata disattivata.",
|
||||
"disable_register_prompt": "Siamo spiacenti, registrazione è stata disabilitata. Si prega di contattare l'amministratore del sito.",
|
||||
"non_local_account": "Gli account non locali non possono modificare le password tramite Gogs.",
|
||||
"create_new_account": "Crea un nuovo Account",
|
||||
"register_hepler_msg": "Hai già un account? Accedi ora!",
|
||||
"sign_up": "Registrati",
|
||||
"sign_up_now": "Bisogno di un account? Iscriviti ora.",
|
||||
"reset_password": "Reimposta la tua Password",
|
||||
"invalid_code": "Siamo spiacenti, il codice di conferma è scaduto o non valido.",
|
||||
"new_password": "Nuova Password",
|
||||
"confirm_password": "Conferma Password"
|
||||
"status.page_not_found": "Pagina Non Trovata",
|
||||
"status.internal_server_error": "Errore Interno del Server",
|
||||
"auth.auth_source": "Fonte di autenticazione",
|
||||
"auth.local": "Locale",
|
||||
"auth.forget_password": "Password dimenticata?",
|
||||
"auth.disable_register_mail": "Siamo spiacenti, la conferma di registrazione via Mail è stata disattivata.",
|
||||
"auth.disable_register_prompt": "Siamo spiacenti, registrazione è stata disabilitata. Si prega di contattare l'amministratore del sito.",
|
||||
"auth.non_local_account": "Gli account non locali non possono modificare le password tramite Gogs.",
|
||||
"auth.create_new_account": "Crea un nuovo Account",
|
||||
"auth.register_hepler_msg": "Hai già un account? Accedi ora!",
|
||||
"auth.sign_up_now": "Bisogno di un account? Iscriviti ora.",
|
||||
"auth.reset_password": "Reimposta la tua Password",
|
||||
"auth.invalid_code": "Siamo spiacenti, il codice di conferma è scaduto o non valido.",
|
||||
"tool.now": "ora",
|
||||
"tool.ago": "fa",
|
||||
"tool.from_now": "da adesso",
|
||||
"tool.1s": "1 secondo %s",
|
||||
"tool.1m": "1 minuto %s",
|
||||
"tool.1h": "1 ora %s",
|
||||
"tool.1d": "1 giorno %s",
|
||||
"tool.1w": "1 settimana %s",
|
||||
"tool.1mon": "1 mese %s",
|
||||
"tool.1y": "1 anno %s",
|
||||
"tool.seconds": "%d secondi %s",
|
||||
"tool.minutes": "%d minuti %s",
|
||||
"tool.hours": "%d ore %s",
|
||||
"tool.days": "%d giorni %s",
|
||||
"tool.weeks": "%d settimane %s",
|
||||
"tool.months": "%d mesi %s",
|
||||
"tool.years": "%d anni %s",
|
||||
"repo.editor.edit_file": "Modifica file",
|
||||
"repo.editor.delete_this_file": "Elimina questo file",
|
||||
"repo.files": "File",
|
||||
"repo.settings": "Impostazioni",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Segui",
|
||||
"repo.unwatch": "Non seguire più",
|
||||
"repo.star": "Vota",
|
||||
"repo.fork": "Forka"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "管理者パネル",
|
||||
"settings": "設定",
|
||||
"language": "言語",
|
||||
"page_not_found": "ページが見つかりません",
|
||||
"internal_server_error": "サーバ内部エラー",
|
||||
"repository": "リポジトリ",
|
||||
"username": "ユーザー名",
|
||||
"email": "メールアドレス",
|
||||
"password": "パスワード",
|
||||
"captcha": "CAPTCHA",
|
||||
"auth_source": "認証ソース",
|
||||
"local": "ローカル",
|
||||
"forget_password": "パスワードを忘れましたか?",
|
||||
"disable_register_mail": "申し訳ありませんが、登録メールの確認機能が無効になっています。",
|
||||
"disable_register_prompt": "申し訳ありませんが、現在登録は受け付けておりません。サイトの管理者にお問い合わせください。",
|
||||
"non_local_account": "非ローカルアカウントではGogs経由でのパスワード変更はできません。",
|
||||
"create_new_account": "新規アカウントを作成",
|
||||
"register_hepler_msg": "既にアカウントをお持ちですか?今すぐログインしましょう!",
|
||||
"sign_up": "サインアップ",
|
||||
"sign_up_now": "アカウントが必要ですか?今すぐ登録しましょう!",
|
||||
"reset_password": "パスワードリセット",
|
||||
"invalid_code": "申し訳ありませんが、確認用コードが期限切れまたは無効です。",
|
||||
"new_password": "新しいパスワード",
|
||||
"confirm_password": "パスワード確認"
|
||||
"status.page_not_found": "ページが見つかりません",
|
||||
"status.internal_server_error": "サーバ内部エラー",
|
||||
"auth.auth_source": "認証ソース",
|
||||
"auth.local": "ローカル",
|
||||
"auth.forget_password": "パスワードを忘れましたか?",
|
||||
"auth.disable_register_mail": "申し訳ありませんが、登録メールの確認機能が無効になっています。",
|
||||
"auth.disable_register_prompt": "申し訳ありませんが、現在登録は受け付けておりません。サイトの管理者にお問い合わせください。",
|
||||
"auth.non_local_account": "非ローカルアカウントではGogs経由でのパスワード変更はできません。",
|
||||
"auth.create_new_account": "新規アカウントを作成",
|
||||
"auth.register_hepler_msg": "既にアカウントをお持ちですか?今すぐログインしましょう!",
|
||||
"auth.sign_up_now": "アカウントが必要ですか?今すぐ登録しましょう!",
|
||||
"auth.reset_password": "パスワードリセット",
|
||||
"auth.invalid_code": "申し訳ありませんが、確認用コードが期限切れまたは無効です。",
|
||||
"tool.now": "今",
|
||||
"tool.ago": "前",
|
||||
"tool.from_now": "今から",
|
||||
"tool.1s": "1 秒 %s",
|
||||
"tool.1m": "1 分 %s",
|
||||
"tool.1h": "1 時間 %s",
|
||||
"tool.1d": "1 日 %s",
|
||||
"tool.1w": "1 週間 %s",
|
||||
"tool.1mon": "1 ヶ月 %s",
|
||||
"tool.1y": "1 年間 %s",
|
||||
"tool.seconds": "%d 秒 %s",
|
||||
"tool.minutes": "%d分%s",
|
||||
"tool.hours": "%d 時間 %s",
|
||||
"tool.days": "%d 日 %s",
|
||||
"tool.weeks": "%d 週間 %s",
|
||||
"tool.months": "%d ヶ月 %s",
|
||||
"tool.years": "%d 年 %s",
|
||||
"repo.editor.edit_file": "ファイルを編集",
|
||||
"repo.editor.delete_this_file": "このファイルを削除",
|
||||
"repo.files": "ファイル",
|
||||
"repo.settings": "設定",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "ウォッチ",
|
||||
"repo.unwatch": "ウォッチ解除",
|
||||
"repo.star": "スター",
|
||||
"repo.fork": "フォーク"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "관리자 패널",
|
||||
"settings": "설정",
|
||||
"language": "언어",
|
||||
"page_not_found": "페이지를 찾을 수 없음",
|
||||
"internal_server_error": "내부 서버 오류",
|
||||
"repository": "저장소",
|
||||
"username": "사용자명",
|
||||
"email": "이메일",
|
||||
"password": "비밀번호",
|
||||
"captcha": "보안 문자",
|
||||
"auth_source": "인증 소스 편집",
|
||||
"local": "로컬",
|
||||
"forget_password": "비밀번호를 잊으셨습니까?",
|
||||
"disable_register_mail": "죄송합니다. 메일 등록이 비활성화 되었습니다.",
|
||||
"disable_register_prompt": "죄송합니다, 가입이 비활성화 되어있습니다. 사이트 관리자에게 문의 해주세요.",
|
||||
"non_local_account": "Gogs 계정이 아니면 암호를 변경할 수 없습니다.",
|
||||
"create_new_account": "새 계정 생성",
|
||||
"register_hepler_msg": "이미 계정을 가지고 계신가요? 로그인하세요!",
|
||||
"sign_up": "가입하기",
|
||||
"sign_up_now": "계정이 필요하신가요? 지금 가입하세요.",
|
||||
"reset_password": "비밀번호 초기화",
|
||||
"invalid_code": "죄송합니다. 확인 코드가 만료되었거나 유효하지 않습니다.",
|
||||
"new_password": "새 비밀번호",
|
||||
"confirm_password": "비밀번호 확인"
|
||||
"status.page_not_found": "페이지를 찾을 수 없음",
|
||||
"status.internal_server_error": "내부 서버 오류",
|
||||
"auth.auth_source": "인증 소스 편집",
|
||||
"auth.local": "로컬",
|
||||
"auth.forget_password": "비밀번호를 잊으셨습니까?",
|
||||
"auth.disable_register_mail": "죄송합니다. 메일 등록이 비활성화 되었습니다.",
|
||||
"auth.disable_register_prompt": "죄송합니다, 가입이 비활성화 되어있습니다. 사이트 관리자에게 문의 해주세요.",
|
||||
"auth.non_local_account": "Gogs 계정이 아니면 암호를 변경할 수 없습니다.",
|
||||
"auth.create_new_account": "새 계정 생성",
|
||||
"auth.register_hepler_msg": "이미 계정을 가지고 계신가요? 로그인하세요!",
|
||||
"auth.sign_up_now": "계정이 필요하신가요? 지금 가입하세요.",
|
||||
"auth.reset_password": "비밀번호 초기화",
|
||||
"auth.invalid_code": "죄송합니다. 확인 코드가 만료되었거나 유효하지 않습니다.",
|
||||
"tool.now": "현재",
|
||||
"tool.ago": "전",
|
||||
"tool.from_now": "지금부터",
|
||||
"tool.1s": "1 초 %s",
|
||||
"tool.1m": "1 분 %s",
|
||||
"tool.1h": "1 시간 %s",
|
||||
"tool.1d": "1 일 %s",
|
||||
"tool.1w": "1 주 %s",
|
||||
"tool.1mon": "1 개월 %s",
|
||||
"tool.1y": "1 년 %s",
|
||||
"tool.seconds": "%d 초 %s",
|
||||
"tool.minutes": "%d 분 %s",
|
||||
"tool.hours": "%d 시간 %s",
|
||||
"tool.days": "%d 일 %s",
|
||||
"tool.weeks": "%d 주 %s",
|
||||
"tool.months": "%d 달 %s",
|
||||
"tool.years": "%d 년 %s",
|
||||
"repo.editor.edit_file": "파일 수정",
|
||||
"repo.editor.delete_this_file": "이 파일을 삭제",
|
||||
"repo.files": "파일",
|
||||
"repo.settings": "설정",
|
||||
"repo.wiki": "위키",
|
||||
"repo.watch": "Watch",
|
||||
"repo.unwatch": "Unwatch",
|
||||
"repo.star": "Star",
|
||||
"repo.fork": "포크"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Admin panelis",
|
||||
"settings": "Iestatījumi",
|
||||
"language": "Valoda",
|
||||
"page_not_found": "Page Not Found",
|
||||
"internal_server_error": "Internal Server Error",
|
||||
"repository": "Repozitorijs",
|
||||
"username": "Lietotājvārds",
|
||||
"email": "E-pasts",
|
||||
"password": "Parole",
|
||||
"captcha": "Pārbaudes kods",
|
||||
"auth_source": "Autentificēšanas avots",
|
||||
"local": "Local",
|
||||
"forget_password": "Aizmirsi paroli?",
|
||||
"disable_register_mail": "Atvainojiet, reģistrācijas e-pasta apstiprināšana ir atspējota.",
|
||||
"disable_register_prompt": "Atvainojiet, reģistrācija ir atspējota. Lūdzu, sazinieties ar vietnes administratoru.",
|
||||
"non_local_account": "Tikai lokālie konti var nomainīt savu paroli Gogs.",
|
||||
"create_new_account": "Izveidot jaunu kontu",
|
||||
"register_hepler_msg": "Jau ir konts? Pieraksties tagad!",
|
||||
"sign_up": "Reģistrēties",
|
||||
"sign_up_now": "Nepieciešams konts? Reģistrējies tagad.",
|
||||
"reset_password": "Atjaunot savu paroli",
|
||||
"invalid_code": "Atvainojiet, Jūsu apstiprināšanas kodam ir beidzies derīguma termiņš vai arī tas ir nepareizs.",
|
||||
"new_password": "Jauna parole",
|
||||
"confirm_password": "Apstipriniet paroli"
|
||||
"status.page_not_found": "Page Not Found",
|
||||
"status.internal_server_error": "Internal Server Error",
|
||||
"auth.auth_source": "Autentificēšanas avots",
|
||||
"auth.local": "Local",
|
||||
"auth.forget_password": "Aizmirsi paroli?",
|
||||
"auth.disable_register_mail": "Atvainojiet, reģistrācijas e-pasta apstiprināšana ir atspējota.",
|
||||
"auth.disable_register_prompt": "Atvainojiet, reģistrācija ir atspējota. Lūdzu, sazinieties ar vietnes administratoru.",
|
||||
"auth.non_local_account": "Tikai lokālie konti var nomainīt savu paroli Gogs.",
|
||||
"auth.create_new_account": "Izveidot jaunu kontu",
|
||||
"auth.register_hepler_msg": "Jau ir konts? Pieraksties tagad!",
|
||||
"auth.sign_up_now": "Nepieciešams konts? Reģistrējies tagad.",
|
||||
"auth.reset_password": "Atjaunot savu paroli",
|
||||
"auth.invalid_code": "Atvainojiet, Jūsu apstiprināšanas kodam ir beidzies derīguma termiņš vai arī tas ir nepareizs.",
|
||||
"tool.now": "tagad",
|
||||
"tool.ago": "atpakaļ",
|
||||
"tool.from_now": "no šī brīža",
|
||||
"tool.1s": "1 sekundi %s",
|
||||
"tool.1m": "1 minūti %s",
|
||||
"tool.1h": "1 stundu %s",
|
||||
"tool.1d": "1 dienu %s",
|
||||
"tool.1w": "1 nedēļu %s",
|
||||
"tool.1mon": "1 mēnesi %s",
|
||||
"tool.1y": "1 gadu %s",
|
||||
"tool.seconds": "%d sekundes %s",
|
||||
"tool.minutes": "%d minūtes %s",
|
||||
"tool.hours": "%d stundas %s",
|
||||
"tool.days": "%d dienas %s",
|
||||
"tool.weeks": "%d nedēļas %s",
|
||||
"tool.months": "%d mēneši %s",
|
||||
"tool.years": "%d gadi %s",
|
||||
"repo.editor.edit_file": "Labot failu",
|
||||
"repo.editor.delete_this_file": "Dzēst šo failu",
|
||||
"repo.files": "Faili",
|
||||
"repo.settings": "Iestatījumi",
|
||||
"repo.wiki": "Vikivietne",
|
||||
"repo.watch": "Vērot",
|
||||
"repo.unwatch": "Nevērot",
|
||||
"repo.star": "Pievienot zvaigznīti",
|
||||
"repo.fork": "Atdalīts"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Админ удирдлага",
|
||||
"settings": "Тохиргоо",
|
||||
"language": "Хэл",
|
||||
"page_not_found": "Хуудас олдсонгүй",
|
||||
"internal_server_error": "Сервертэй холбогдоход алдаа гарлаа.",
|
||||
"repository": "Репо",
|
||||
"username": "Нэвтрэх нэр",
|
||||
"email": "Имэйл",
|
||||
"password": "Нууц үг",
|
||||
"captcha": "Батлах тэмдэгт",
|
||||
"auth_source": "Баталгаажуулалтын эх сурвалж",
|
||||
"local": "Локал",
|
||||
"forget_password": "Нууц үг сэргээх?",
|
||||
"disable_register_mail": "Уучлаарай, имэйлийн үйлчилгээ идэвхгүй байна. Сайтын админтай холбоо барина уу.",
|
||||
"disable_register_prompt": "Уучлаарай, бүртгэл идэвхгүй байна. Сайтын админтай холбоо барина уу.",
|
||||
"non_local_account": "Гадаад хэрэглэгчид нууц үгээ солих боломжгүй.",
|
||||
"create_new_account": "Шинэ данс үүсгэх",
|
||||
"register_hepler_msg": "Та хэрэглэгчийн эрхээ үүсгэсэн бол Нэвтрэх хуудас руу шилжих!",
|
||||
"sign_up": "Бүртгүүлэх",
|
||||
"sign_up_now": "Данс үүсгэх бол? Одоо бүртгүүлнэ үү.",
|
||||
"reset_password": "Нууц үгээ сэргээх",
|
||||
"invalid_code": "Уучлаарай, таны баталгаажуулах кодын хугацаа дууссан эсвэл хүчин төгөлдөр бус байна.",
|
||||
"new_password": "Шинэ нууц үг",
|
||||
"confirm_password": "Confirm Password"
|
||||
"status.page_not_found": "Хуудас олдсонгүй",
|
||||
"status.internal_server_error": "Сервертэй холбогдоход алдаа гарлаа.",
|
||||
"auth.auth_source": "Баталгаажуулалтын эх сурвалж",
|
||||
"auth.local": "Локал",
|
||||
"auth.forget_password": "Нууц үг сэргээх?",
|
||||
"auth.disable_register_mail": "Уучлаарай, имэйлийн үйлчилгээ идэвхгүй байна. Сайтын админтай холбоо барина уу.",
|
||||
"auth.disable_register_prompt": "Уучлаарай, бүртгэл идэвхгүй байна. Сайтын админтай холбоо барина уу.",
|
||||
"auth.non_local_account": "Гадаад хэрэглэгчид нууц үгээ солих боломжгүй.",
|
||||
"auth.create_new_account": "Шинэ данс үүсгэх",
|
||||
"auth.register_hepler_msg": "Та хэрэглэгчийн эрхээ үүсгэсэн бол Нэвтрэх хуудас руу шилжих!",
|
||||
"auth.sign_up_now": "Данс үүсгэх бол? Одоо бүртгүүлнэ үү.",
|
||||
"auth.reset_password": "Нууц үгээ сэргээх",
|
||||
"auth.invalid_code": "Уучлаарай, таны баталгаажуулах кодын хугацаа дууссан эсвэл хүчин төгөлдөр бус байна.",
|
||||
"tool.now": "одоо",
|
||||
"tool.ago": "өмнө",
|
||||
"tool.from_now": "одооноос",
|
||||
"tool.1s": "1 секунд %s",
|
||||
"tool.1m": "1 минут %s",
|
||||
"tool.1h": "1 цаг %s",
|
||||
"tool.1d": "1 өдөр %s",
|
||||
"tool.1w": "1 долоо хоног %s",
|
||||
"tool.1mon": "1 сар %s",
|
||||
"tool.1y": "1 жил %s",
|
||||
"tool.seconds": "%d секунд %s",
|
||||
"tool.minutes": "%d минут %s",
|
||||
"tool.hours": "%d цаг %s",
|
||||
"tool.days": "%d өдөр %s",
|
||||
"tool.weeks": "%d долоо хоног %s",
|
||||
"tool.months": "%d сар %s",
|
||||
"tool.years": "%d жил %s",
|
||||
"repo.editor.edit_file": "Файл засах",
|
||||
"repo.editor.delete_this_file": "Энэ файлыг устгах",
|
||||
"repo.files": "Файлууд",
|
||||
"repo.settings": "Тохиргоо",
|
||||
"repo.wiki": "Мэдлэгийн сан",
|
||||
"repo.watch": "Үзэх жагсаалтад нэмэх",
|
||||
"repo.unwatch": "Үзэх жагсаалтаас хасах",
|
||||
"repo.star": "Онцлох жагсаалтад нэмэх",
|
||||
"repo.fork": "Салаа"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Adminpaneel",
|
||||
"settings": "Instellingen",
|
||||
"language": "Taal",
|
||||
"page_not_found": "Pagina niet gevonden",
|
||||
"internal_server_error": "Interne Server Fout",
|
||||
"repository": "Repository",
|
||||
"username": "Gebruikersnaam",
|
||||
"email": "E-mail",
|
||||
"password": "Wachtwoord",
|
||||
"captcha": "CAPTCHA",
|
||||
"auth_source": "Authenticatiebron",
|
||||
"local": "Lokaal",
|
||||
"forget_password": "Wachtwoord vergeten?",
|
||||
"disable_register_mail": "Sorry, bevestiging van registratie per e-mail is uitgeschakeld.",
|
||||
"disable_register_prompt": "Sorry, registratie is uitgeschakeld. Neem contact op met de beheerder van deze site.",
|
||||
"non_local_account": "Niet lokale accounts mogen hun wachtwoord niet veranderen via Gogs.",
|
||||
"create_new_account": "Maak nieuw account aan",
|
||||
"register_hepler_msg": "Heeft u al een account? Meld u nu aan!",
|
||||
"sign_up": "Aanmelden",
|
||||
"sign_up_now": "Een account nodig? Meld u nu aan.",
|
||||
"reset_password": "Reset uw wachtwoord",
|
||||
"invalid_code": "Sorry, uw bevestigingscode is verlopen of niet meer geldig.",
|
||||
"new_password": "Nieuw wachtwoord",
|
||||
"confirm_password": "Verifieer wachtwoord"
|
||||
"status.page_not_found": "Pagina niet gevonden",
|
||||
"status.internal_server_error": "Interne Server Fout",
|
||||
"auth.auth_source": "Authenticatiebron",
|
||||
"auth.local": "Lokaal",
|
||||
"auth.forget_password": "Wachtwoord vergeten?",
|
||||
"auth.disable_register_mail": "Sorry, bevestiging van registratie per e-mail is uitgeschakeld.",
|
||||
"auth.disable_register_prompt": "Sorry, registratie is uitgeschakeld. Neem contact op met de beheerder van deze site.",
|
||||
"auth.non_local_account": "Niet lokale accounts mogen hun wachtwoord niet veranderen via Gogs.",
|
||||
"auth.create_new_account": "Maak nieuw account aan",
|
||||
"auth.register_hepler_msg": "Heeft u al een account? Meld u nu aan!",
|
||||
"auth.sign_up_now": "Een account nodig? Meld u nu aan.",
|
||||
"auth.reset_password": "Reset uw wachtwoord",
|
||||
"auth.invalid_code": "Sorry, uw bevestigingscode is verlopen of niet meer geldig.",
|
||||
"tool.now": "nu",
|
||||
"tool.ago": "geleden",
|
||||
"tool.from_now": "vanaf nu",
|
||||
"tool.1s": "1 seconde %s",
|
||||
"tool.1m": "1 minuut %s",
|
||||
"tool.1h": "1 uur %s",
|
||||
"tool.1d": "1 dag %s",
|
||||
"tool.1w": "1 week %s",
|
||||
"tool.1mon": "1 maand %s",
|
||||
"tool.1y": "1 jaar %s",
|
||||
"tool.seconds": "%d seconden %s",
|
||||
"tool.minutes": "%d minuten %s",
|
||||
"tool.hours": "%d uur %s",
|
||||
"tool.days": "%d dagen %s",
|
||||
"tool.weeks": "%d weken %s",
|
||||
"tool.months": "%d maanden %s",
|
||||
"tool.years": "%d jaren %s",
|
||||
"repo.editor.edit_file": "Bewerk bestand",
|
||||
"repo.editor.delete_this_file": "Verwijder dit bestand",
|
||||
"repo.files": "Bestanden",
|
||||
"repo.settings": "Instellingen",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Volgen",
|
||||
"repo.unwatch": "Negeren",
|
||||
"repo.star": "Ster",
|
||||
"repo.fork": "Vork"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Panel admina",
|
||||
"settings": "Ustawienia",
|
||||
"language": "Język",
|
||||
"page_not_found": "Strona nie została znaleziona",
|
||||
"internal_server_error": "Wewnętrzny błąd serwera",
|
||||
"repository": "Repozytorium",
|
||||
"username": "Nazwa użytkownika",
|
||||
"email": "E-mail",
|
||||
"password": "Hasło",
|
||||
"captcha": "Captcha",
|
||||
"auth_source": "Źródło uwierzytelniania",
|
||||
"local": "Lokalne",
|
||||
"forget_password": "Zapomniałeś hasła?",
|
||||
"disable_register_mail": "Przepraszamy, potwierdzenia rejestracji zostały wyłączone przez administratora.",
|
||||
"disable_register_prompt": "Przepraszamy rejestracja została wyłączona. Prosimy o kontakt z administratorem serwisu.",
|
||||
"non_local_account": "Nie lokalne konta nie mogą zmieniać haseł przez Gogs.",
|
||||
"create_new_account": "Załóż nowe konto",
|
||||
"register_hepler_msg": "Masz już konto? Zaloguj się teraz!",
|
||||
"sign_up": "Zarejestruj się",
|
||||
"sign_up_now": "Potrzebujesz konta? Zarejestruj się teraz.",
|
||||
"reset_password": "Resetowanie hasła",
|
||||
"invalid_code": "Niestety, Twój kod potwierdzający wygasł lub jest nieprawidłowy.",
|
||||
"new_password": "Nowe hasło",
|
||||
"confirm_password": "Potwierdź hasło"
|
||||
"status.page_not_found": "Strona nie została znaleziona",
|
||||
"status.internal_server_error": "Wewnętrzny błąd serwera",
|
||||
"auth.auth_source": "Źródło uwierzytelniania",
|
||||
"auth.local": "Lokalne",
|
||||
"auth.forget_password": "Zapomniałeś hasła?",
|
||||
"auth.disable_register_mail": "Przepraszamy, potwierdzenia rejestracji zostały wyłączone przez administratora.",
|
||||
"auth.disable_register_prompt": "Przepraszamy rejestracja została wyłączona. Prosimy o kontakt z administratorem serwisu.",
|
||||
"auth.non_local_account": "Nie lokalne konta nie mogą zmieniać haseł przez Gogs.",
|
||||
"auth.create_new_account": "Załóż nowe konto",
|
||||
"auth.register_hepler_msg": "Masz już konto? Zaloguj się teraz!",
|
||||
"auth.sign_up_now": "Potrzebujesz konta? Zarejestruj się teraz.",
|
||||
"auth.reset_password": "Resetowanie hasła",
|
||||
"auth.invalid_code": "Niestety, Twój kod potwierdzający wygasł lub jest nieprawidłowy.",
|
||||
"tool.now": "teraz",
|
||||
"tool.ago": "temu",
|
||||
"tool.from_now": "od teraz",
|
||||
"tool.1s": "1 sekundę %s",
|
||||
"tool.1m": "1 minutę %s",
|
||||
"tool.1h": "1 godzinę %s",
|
||||
"tool.1d": "1 dzień %s",
|
||||
"tool.1w": "1 tydzień %s",
|
||||
"tool.1mon": "1 miesiąc %s",
|
||||
"tool.1y": "1 rok %s",
|
||||
"tool.seconds": "%d sekund %s",
|
||||
"tool.minutes": "%d minut %s",
|
||||
"tool.hours": "%d godzin %s",
|
||||
"tool.days": "%d dni %s",
|
||||
"tool.weeks": "%d tygodni %s",
|
||||
"tool.months": "%d miesięcy %s",
|
||||
"tool.years": "%d lat %s",
|
||||
"repo.editor.edit_file": "Edytuj plik",
|
||||
"repo.editor.delete_this_file": "Usuń ten plik",
|
||||
"repo.files": "Pliki",
|
||||
"repo.settings": "Ustawienia",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Obserwuj",
|
||||
"repo.unwatch": "Przestań obserwować",
|
||||
"repo.star": "Polub",
|
||||
"repo.fork": "Forkuj"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Painel do administrador",
|
||||
"settings": "Configurações",
|
||||
"language": "Idioma",
|
||||
"page_not_found": "Página Não Encontrada",
|
||||
"internal_server_error": "Erro interno do servidor",
|
||||
"repository": "Repositório",
|
||||
"username": "Usuário",
|
||||
"email": "E-mail",
|
||||
"password": "Senha",
|
||||
"captcha": "Captcha",
|
||||
"auth_source": "Fonte de autenticação",
|
||||
"local": "Local",
|
||||
"forget_password": "Esqueceu a senha?",
|
||||
"disable_register_mail": "Desculpe, a confirmação de registro por e-mail foi desabilitada.",
|
||||
"disable_register_prompt": "Desculpe, novos registros estão desabilitados. Por favor entre em contato com o administrador do site.",
|
||||
"non_local_account": "Não é possível mudar a senha de contas remotas pelo Gogs.",
|
||||
"create_new_account": "Criar nova conta",
|
||||
"register_hepler_msg": "Já tem uma conta? Entre agora!",
|
||||
"sign_up": "Cadastrar",
|
||||
"sign_up_now": "Precisa de uma conta? Cadastre-se agora.",
|
||||
"reset_password": "Redefinir sua senha",
|
||||
"invalid_code": "Desculpe, seu código de confirmação expirou ou não é válido.",
|
||||
"new_password": "Nova senha",
|
||||
"confirm_password": "Confirmar senha"
|
||||
"status.page_not_found": "Página Não Encontrada",
|
||||
"status.internal_server_error": "Erro interno do servidor",
|
||||
"auth.auth_source": "Fonte de autenticação",
|
||||
"auth.local": "Local",
|
||||
"auth.forget_password": "Esqueceu a senha?",
|
||||
"auth.disable_register_mail": "Desculpe, a confirmação de registro por e-mail foi desabilitada.",
|
||||
"auth.disable_register_prompt": "Desculpe, novos registros estão desabilitados. Por favor entre em contato com o administrador do site.",
|
||||
"auth.non_local_account": "Não é possível mudar a senha de contas remotas pelo Gogs.",
|
||||
"auth.create_new_account": "Criar nova conta",
|
||||
"auth.register_hepler_msg": "Já tem uma conta? Entre agora!",
|
||||
"auth.sign_up_now": "Precisa de uma conta? Cadastre-se agora.",
|
||||
"auth.reset_password": "Redefinir sua senha",
|
||||
"auth.invalid_code": "Desculpe, seu código de confirmação expirou ou não é válido.",
|
||||
"tool.now": "agora",
|
||||
"tool.ago": "atrás",
|
||||
"tool.from_now": "a partir de agora",
|
||||
"tool.1s": "1 segundo %s",
|
||||
"tool.1m": "1 minuto %s",
|
||||
"tool.1h": "1 hora %s",
|
||||
"tool.1d": "1 dia %s",
|
||||
"tool.1w": "1 semana %s",
|
||||
"tool.1mon": "1 mês %s",
|
||||
"tool.1y": "1 ano %s",
|
||||
"tool.seconds": "%d segundos %s",
|
||||
"tool.minutes": "%d minutos %s",
|
||||
"tool.hours": "%d horas %s",
|
||||
"tool.days": "%d dias %s",
|
||||
"tool.weeks": "%d semanas %s",
|
||||
"tool.months": "%d meses %s",
|
||||
"tool.years": "%d anos %s",
|
||||
"repo.editor.edit_file": "Editar arquivo",
|
||||
"repo.editor.delete_this_file": "Excluir este arquivo",
|
||||
"repo.files": "Arquivos",
|
||||
"repo.settings": "Configurações",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Observar",
|
||||
"repo.unwatch": "Deixar de observar",
|
||||
"repo.star": "Favorito",
|
||||
"repo.fork": "Fork"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Painel de Administração",
|
||||
"settings": "Definições",
|
||||
"language": "Língua",
|
||||
"page_not_found": "Página Não Encontrada",
|
||||
"internal_server_error": "Erro do servidor interno",
|
||||
"repository": "Repositório",
|
||||
"username": "Nome de utilizador",
|
||||
"email": "Endereço de email",
|
||||
"password": "Palavra-chave",
|
||||
"captcha": "Captcha",
|
||||
"auth_source": "Tipo de autenticação",
|
||||
"local": "Local",
|
||||
"forget_password": "Esqueceu a sua senha?",
|
||||
"disable_register_mail": "Desculpe, os serviços de email estão desativados. Por favor contacte o administrador.",
|
||||
"disable_register_prompt": "Desculpe, o registo de novos utilizadores está desativado. Por favor contacte o administrador.",
|
||||
"non_local_account": "Contas não-locais não podem mudar a palavra-passe através do Gogs.",
|
||||
"create_new_account": "Criar Nova Conta",
|
||||
"register_hepler_msg": "Já tem uma conta? Inicie sessão!",
|
||||
"sign_up": "Criar conta",
|
||||
"sign_up_now": "Precisa de uma conta? Inscreva-se agora.",
|
||||
"reset_password": "Restaurar a sua senha",
|
||||
"invalid_code": "Desculpe, o seu código de confirmação expirou ou é inválido.",
|
||||
"new_password": "Nova senha",
|
||||
"confirm_password": "Confirmar senha"
|
||||
"status.page_not_found": "Página Não Encontrada",
|
||||
"status.internal_server_error": "Erro do servidor interno",
|
||||
"auth.auth_source": "Tipo de autenticação",
|
||||
"auth.local": "Local",
|
||||
"auth.forget_password": "Esqueceu a sua senha?",
|
||||
"auth.disable_register_mail": "Desculpe, os serviços de email estão desativados. Por favor contacte o administrador.",
|
||||
"auth.disable_register_prompt": "Desculpe, o registo de novos utilizadores está desativado. Por favor contacte o administrador.",
|
||||
"auth.non_local_account": "Contas não-locais não podem mudar a palavra-passe através do Gogs.",
|
||||
"auth.create_new_account": "Criar Nova Conta",
|
||||
"auth.register_hepler_msg": "Já tem uma conta? Inicie sessão!",
|
||||
"auth.sign_up_now": "Precisa de uma conta? Inscreva-se agora.",
|
||||
"auth.reset_password": "Restaurar a sua senha",
|
||||
"auth.invalid_code": "Desculpe, o seu código de confirmação expirou ou é inválido.",
|
||||
"tool.now": "agora",
|
||||
"tool.ago": "atrás",
|
||||
"tool.from_now": "a partir de agora",
|
||||
"tool.1s": "1 segundo %s",
|
||||
"tool.1m": "há 1 minuto %s",
|
||||
"tool.1h": "há 1 hora %s",
|
||||
"tool.1d": "há 1 dia %s",
|
||||
"tool.1w": "há 1 semana %s",
|
||||
"tool.1mon": "há 1 mês %s",
|
||||
"tool.1y": "há 1 ano %s",
|
||||
"tool.seconds": "há %d segundos %s",
|
||||
"tool.minutes": "há %d minutos %s",
|
||||
"tool.hours": "há %d horas %s",
|
||||
"tool.days": "há %d dias %s",
|
||||
"tool.weeks": "há %d semanas %s",
|
||||
"tool.months": "há %d meses %s",
|
||||
"tool.years": "há %d anos %s",
|
||||
"repo.editor.edit_file": "Edita ficheiro",
|
||||
"repo.editor.delete_this_file": "Apagar este ficheiro",
|
||||
"repo.files": "Ficheiros",
|
||||
"repo.settings": "Definições",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Vigiar",
|
||||
"repo.unwatch": "Deixar de vigiar",
|
||||
"repo.star": "Colocar Estrela",
|
||||
"repo.fork": "Fork"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Panou Administrator",
|
||||
"settings": "Setări",
|
||||
"language": "Limba",
|
||||
"page_not_found": "Pagina nu a fost găsită",
|
||||
"internal_server_error": "Eroare internă de server",
|
||||
"repository": "Repositoriu",
|
||||
"username": "Numele de utilizator",
|
||||
"email": "E-mail",
|
||||
"password": "Parolă",
|
||||
"captcha": "Captcha",
|
||||
"auth_source": "Sursa de autentificare",
|
||||
"local": "Local",
|
||||
"forget_password": "Ați uitat parola?",
|
||||
"disable_register_mail": "Ne pare rău, serviciile de e-mail sunt dezactivate. Vă rugăm să contactați administratorul site-ului.",
|
||||
"disable_register_prompt": "Ne pare rău, înregistrarea a fost dezactivată. Vă rugăm să contactați administratorul site-ului.",
|
||||
"non_local_account": "Conturile non-locale nu pot schimba parolele prin Gogs.",
|
||||
"create_new_account": "Creați un cont nou",
|
||||
"register_hepler_msg": "Aveți deja un cont? Conectați-vă acum!",
|
||||
"sign_up": "Înregistrare",
|
||||
"sign_up_now": "Nevoie de un cont? Inscrie-te acum.",
|
||||
"reset_password": "Resetați-vă parola",
|
||||
"invalid_code": "Ne pare rău, codul dvs. de confirmare a expirat sau nu este valabil.",
|
||||
"new_password": "Parolă nouă",
|
||||
"confirm_password": "Confirmați Parola"
|
||||
"status.page_not_found": "Pagina nu a fost găsită",
|
||||
"status.internal_server_error": "Eroare internă de server",
|
||||
"auth.auth_source": "Sursa de autentificare",
|
||||
"auth.local": "Local",
|
||||
"auth.forget_password": "Ați uitat parola?",
|
||||
"auth.disable_register_mail": "Ne pare rău, serviciile de e-mail sunt dezactivate. Vă rugăm să contactați administratorul site-ului.",
|
||||
"auth.disable_register_prompt": "Ne pare rău, înregistrarea a fost dezactivată. Vă rugăm să contactați administratorul site-ului.",
|
||||
"auth.non_local_account": "Conturile non-locale nu pot schimba parolele prin Gogs.",
|
||||
"auth.create_new_account": "Creați un cont nou",
|
||||
"auth.register_hepler_msg": "Aveți deja un cont? Conectați-vă acum!",
|
||||
"auth.sign_up_now": "Nevoie de un cont? Inscrie-te acum.",
|
||||
"auth.reset_password": "Resetați-vă parola",
|
||||
"auth.invalid_code": "Ne pare rău, codul dvs. de confirmare a expirat sau nu este valabil.",
|
||||
"tool.now": "acum",
|
||||
"tool.ago": "în urmă",
|
||||
"tool.from_now": "de acum",
|
||||
"tool.1s": "1 secundă %s",
|
||||
"tool.1m": "1 minut %s",
|
||||
"tool.1h": "1 oră %s",
|
||||
"tool.1d": "1 zi %s",
|
||||
"tool.1w": "1 săptămână %s",
|
||||
"tool.1mon": "1 lună %s",
|
||||
"tool.1y": "1 an %s",
|
||||
"tool.seconds": "%d secunde %s",
|
||||
"tool.minutes": "%d minute %s",
|
||||
"tool.hours": "%d ore %s",
|
||||
"tool.days": "%d zile %s",
|
||||
"tool.weeks": "%d săptămâni %s",
|
||||
"tool.months": "%d luni %s",
|
||||
"tool.years": "%d ani %s",
|
||||
"repo.editor.edit_file": "Modifica fisier",
|
||||
"repo.editor.delete_this_file": "Șterge acest fișier",
|
||||
"repo.files": "Fisiere",
|
||||
"repo.settings": "Setări",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Urmărește",
|
||||
"repo.unwatch": "Nevizionat",
|
||||
"repo.star": "Stea",
|
||||
"repo.fork": "Bifurcare"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Панель администратора",
|
||||
"settings": "Настройки",
|
||||
"language": "Язык",
|
||||
"page_not_found": "Страница не найдена",
|
||||
"internal_server_error": "Внутренняя ошибка сервера",
|
||||
"repository": "Репозиторий",
|
||||
"username": "Имя пользователя",
|
||||
"email": "Эл. почта",
|
||||
"password": "Пароль",
|
||||
"captcha": "Капча",
|
||||
"auth_source": "Тип аутентификации",
|
||||
"local": "Локальный",
|
||||
"forget_password": "Забыли пароль?",
|
||||
"disable_register_mail": "К сожалению подтверждение регистрации по почте отключено.",
|
||||
"disable_register_prompt": "Извините, возможность регистрации отключена. Пожалуйста, свяжитесь с администратором сайта.",
|
||||
"non_local_account": "Нелокальные аккаунты не могут изменить пароль через Gogs.",
|
||||
"create_new_account": "Создать новый аккаунт",
|
||||
"register_hepler_msg": "Уже есть аккаунт? Авторизуйтесь!",
|
||||
"sign_up": "Регистрация",
|
||||
"sign_up_now": "Нужен аккаунт? Зарегистрируйтесь.",
|
||||
"reset_password": "Сброс пароля",
|
||||
"invalid_code": "Извините, ваш код подтверждения истек или не является допустимым.",
|
||||
"new_password": "Новый пароль",
|
||||
"confirm_password": "Подтвердить пароль"
|
||||
"status.page_not_found": "Страница не найдена",
|
||||
"status.internal_server_error": "Внутренняя ошибка сервера",
|
||||
"auth.auth_source": "Тип аутентификации",
|
||||
"auth.local": "Локальный",
|
||||
"auth.forget_password": "Забыли пароль?",
|
||||
"auth.disable_register_mail": "К сожалению подтверждение регистрации по почте отключено.",
|
||||
"auth.disable_register_prompt": "Извините, возможность регистрации отключена. Пожалуйста, свяжитесь с администратором сайта.",
|
||||
"auth.non_local_account": "Нелокальные аккаунты не могут изменить пароль через Gogs.",
|
||||
"auth.create_new_account": "Создать новый аккаунт",
|
||||
"auth.register_hepler_msg": "Уже есть аккаунт? Авторизуйтесь!",
|
||||
"auth.sign_up_now": "Нужен аккаунт? Зарегистрируйтесь.",
|
||||
"auth.reset_password": "Сброс пароля",
|
||||
"auth.invalid_code": "Извините, ваш код подтверждения истек или не является допустимым.",
|
||||
"tool.now": "сейчас",
|
||||
"tool.ago": "назад",
|
||||
"tool.from_now": "с этого момента",
|
||||
"tool.1s": "1 секунду %s",
|
||||
"tool.1m": "1 минуту %s",
|
||||
"tool.1h": "1 час %s",
|
||||
"tool.1d": "1 день %s",
|
||||
"tool.1w": "1 неделя %s",
|
||||
"tool.1mon": "1 месяц %s",
|
||||
"tool.1y": "1 год %s",
|
||||
"tool.seconds": "%d секунд %s",
|
||||
"tool.minutes": "%d минут %s",
|
||||
"tool.hours": "%d часов %s",
|
||||
"tool.days": "%d дней %s",
|
||||
"tool.weeks": "%d недель %s",
|
||||
"tool.months": "%d месяцев %s",
|
||||
"tool.years": "%d лет %s",
|
||||
"repo.editor.edit_file": "Редактировать файл",
|
||||
"repo.editor.delete_this_file": "Удалить файл",
|
||||
"repo.files": "Файлы",
|
||||
"repo.settings": "Настройки",
|
||||
"repo.wiki": "Вики",
|
||||
"repo.watch": "Следить",
|
||||
"repo.unwatch": "Перестать следить",
|
||||
"repo.star": "В избранное",
|
||||
"repo.fork": "Ответвить"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Admin Panel",
|
||||
"settings": "Nastavenia",
|
||||
"language": "Jazyk",
|
||||
"page_not_found": "Page Not Found",
|
||||
"internal_server_error": "Internal Server Error",
|
||||
"repository": "Repozitár",
|
||||
"username": "Používateľské meno",
|
||||
"email": "E-mail",
|
||||
"password": "Heslo",
|
||||
"captcha": "Kontrolný kód",
|
||||
"auth_source": "Zdroj overovania",
|
||||
"local": "Lokálny",
|
||||
"forget_password": "Zabudli ste heslo?",
|
||||
"disable_register_mail": "Ospravedlňujeme sa, potvrdenie registračného e-mailu bolo vypnuté.",
|
||||
"disable_register_prompt": "Ospravedlňujeme sa, ale registrácia bola vypnutá. Obráťte sa na administrátora stránky.",
|
||||
"non_local_account": "Miestne účty nemôžu meniť heslá cez Gogs.",
|
||||
"create_new_account": "Vytvoriť nový účet",
|
||||
"register_hepler_msg": "Máte už účet? Prihláste sa teraz!",
|
||||
"sign_up": "Zaregistrovať sa",
|
||||
"sign_up_now": "Potrebujete účet? Zaregistrujte sa teraz.",
|
||||
"reset_password": "Obnovenie hesla",
|
||||
"invalid_code": "Ospravedlňujeme sa, váš potvrdzovací kód vypršal alebo nie je platný.",
|
||||
"new_password": "Nové heslo",
|
||||
"confirm_password": "Potvrdiť heslo"
|
||||
"status.page_not_found": "Page Not Found",
|
||||
"status.internal_server_error": "Internal Server Error",
|
||||
"auth.auth_source": "Zdroj overovania",
|
||||
"auth.local": "Lokálny",
|
||||
"auth.forget_password": "Zabudli ste heslo?",
|
||||
"auth.disable_register_mail": "Ospravedlňujeme sa, potvrdenie registračného e-mailu bolo vypnuté.",
|
||||
"auth.disable_register_prompt": "Ospravedlňujeme sa, ale registrácia bola vypnutá. Obráťte sa na administrátora stránky.",
|
||||
"auth.non_local_account": "Miestne účty nemôžu meniť heslá cez Gogs.",
|
||||
"auth.create_new_account": "Vytvoriť nový účet",
|
||||
"auth.register_hepler_msg": "Máte už účet? Prihláste sa teraz!",
|
||||
"auth.sign_up_now": "Potrebujete účet? Zaregistrujte sa teraz.",
|
||||
"auth.reset_password": "Obnovenie hesla",
|
||||
"auth.invalid_code": "Ospravedlňujeme sa, váš potvrdzovací kód vypršal alebo nie je platný.",
|
||||
"tool.now": "teraz",
|
||||
"tool.ago": "pred",
|
||||
"tool.from_now": "od tejto chvíle",
|
||||
"tool.1s": "1 sekunda %s",
|
||||
"tool.1m": "1 minúta %s",
|
||||
"tool.1h": "1 hodinu %s",
|
||||
"tool.1d": "1 deň %s",
|
||||
"tool.1w": "1 týždeň %s",
|
||||
"tool.1mon": "1 mesiac %s",
|
||||
"tool.1y": "1 rok %s",
|
||||
"tool.seconds": "%d sekúnd %s",
|
||||
"tool.minutes": "%d minút %s",
|
||||
"tool.hours": "%d hodín %s",
|
||||
"tool.days": "%d dní %s",
|
||||
"tool.weeks": "%d týždňov %s",
|
||||
"tool.months": "%d mesiacov %s",
|
||||
"tool.years": "%d rokov %s",
|
||||
"repo.editor.edit_file": "Upraviť súbor",
|
||||
"repo.editor.delete_this_file": "Vymazať tento súbor",
|
||||
"repo.files": "Súbory",
|
||||
"repo.settings": "Nastavenia",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Pridať medzi pozorované",
|
||||
"repo.unwatch": "Odobrať z pozorovaných",
|
||||
"repo.star": "Hviezda",
|
||||
"repo.fork": "Fork"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Админ панела",
|
||||
"settings": "Подешавања",
|
||||
"language": "Језик",
|
||||
"page_not_found": "Page Not Found",
|
||||
"internal_server_error": "Internal Server Error",
|
||||
"repository": "Спремиште",
|
||||
"username": "Корисничко име",
|
||||
"email": "E-пошта",
|
||||
"password": "Лозинка",
|
||||
"captcha": "Captcha",
|
||||
"auth_source": "Извор аутентикације",
|
||||
"local": "Локално",
|
||||
"forget_password": "Заборавили сте лозинку?",
|
||||
"disable_register_mail": "Извините, потврда путем поште је онемогућено.",
|
||||
"disable_register_prompt": "Извините регистрација је онемогућено. Молимо вас, контактирајте администратора.",
|
||||
"non_local_account": "Нелокални налози не могу да промените лозинку преко Gogs.",
|
||||
"create_new_account": "Креирате нови налог",
|
||||
"register_hepler_msg": "Већ имате налог? Пријавите се!",
|
||||
"sign_up": "Регистрација",
|
||||
"sign_up_now": "Немате налог? Пријавите се.",
|
||||
"reset_password": "Ресет лозинке",
|
||||
"invalid_code": "Извините, ваш код за потврду је истекао или није валидан.",
|
||||
"new_password": "Нова лозинка",
|
||||
"confirm_password": "Потврдите лозинку"
|
||||
"status.page_not_found": "Page Not Found",
|
||||
"status.internal_server_error": "Internal Server Error",
|
||||
"auth.auth_source": "Извор аутентикације",
|
||||
"auth.local": "Локално",
|
||||
"auth.forget_password": "Заборавили сте лозинку?",
|
||||
"auth.disable_register_mail": "Извините, потврда путем поште је онемогућено.",
|
||||
"auth.disable_register_prompt": "Извините регистрација је онемогућено. Молимо вас, контактирајте администратора.",
|
||||
"auth.non_local_account": "Нелокални налози не могу да промените лозинку преко Gogs.",
|
||||
"auth.create_new_account": "Креирате нови налог",
|
||||
"auth.register_hepler_msg": "Већ имате налог? Пријавите се!",
|
||||
"auth.sign_up_now": "Немате налог? Пријавите се.",
|
||||
"auth.reset_password": "Ресет лозинке",
|
||||
"auth.invalid_code": "Извините, ваш код за потврду је истекао или није валидан.",
|
||||
"tool.now": "сада",
|
||||
"tool.ago": "пре",
|
||||
"tool.from_now": "од сада",
|
||||
"tool.1s": "%s 1 секунд",
|
||||
"tool.1m": "%s 1 минут",
|
||||
"tool.1h": "%s 1 час",
|
||||
"tool.1d": "%s 1 дан",
|
||||
"tool.1w": "%s 1 недеља",
|
||||
"tool.1mon": "%s 1 месец",
|
||||
"tool.1y": "%s 1 година",
|
||||
"tool.seconds": "%[2]s %[1]d секунди",
|
||||
"tool.minutes": "%[2]s %[1]d минута",
|
||||
"tool.hours": "%[2]s %[1]d часа",
|
||||
"tool.days": "%[2]s %[1]d дана",
|
||||
"tool.weeks": "%[2]s %[1]d недеља",
|
||||
"tool.months": "%[2]s %[1]d месеци",
|
||||
"tool.years": "%[2]s %[1]d година",
|
||||
"repo.editor.edit_file": "Ажурирај датотеку",
|
||||
"repo.editor.delete_this_file": "Уклони ову датотеку",
|
||||
"repo.files": "Датотеке",
|
||||
"repo.settings": "Подешавања",
|
||||
"repo.wiki": "Вики",
|
||||
"repo.watch": "Прати",
|
||||
"repo.unwatch": "Престани пратити",
|
||||
"repo.star": "Волим",
|
||||
"repo.fork": "Креирај огранак"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Admin-panel",
|
||||
"settings": "inställningar",
|
||||
"language": "Språk",
|
||||
"page_not_found": "Sidan hittades inte",
|
||||
"internal_server_error": "Internt serverfel",
|
||||
"repository": "Utvecklingskatalog",
|
||||
"username": "Användarnamn",
|
||||
"email": "E-post",
|
||||
"password": "Lösenord",
|
||||
"captcha": "Captcha",
|
||||
"auth_source": "Autentiseringskälla",
|
||||
"local": "Lokal",
|
||||
"forget_password": "Glömt lösenordet?",
|
||||
"disable_register_mail": "Tyvärr så är registreringsbekräftelemailutskick inaktiverat.",
|
||||
"disable_register_prompt": "Tyvärr är användarregistreringen inaktiverad. Vänligen kontakta din administratör.",
|
||||
"non_local_account": "Icke-lokala konton får inte ändra lösenord genom Gogs.",
|
||||
"create_new_account": "Skapa nytt konto",
|
||||
"register_hepler_msg": "Har du redan ett konto? Logga in nu!",
|
||||
"sign_up": "Registrera dig",
|
||||
"sign_up_now": "Behöver du ett konto? Registrera dig nu.",
|
||||
"reset_password": "Återställ ditt lösenord",
|
||||
"invalid_code": "Tyvärr, din bekräftelsekod har antingen upphört att gälla eller är ogiltig.",
|
||||
"new_password": "Nytt lösenord",
|
||||
"confirm_password": "Bekräfta lösenord"
|
||||
"status.page_not_found": "Sidan hittades inte",
|
||||
"status.internal_server_error": "Internt serverfel",
|
||||
"auth.auth_source": "Autentiseringskälla",
|
||||
"auth.local": "Lokal",
|
||||
"auth.forget_password": "Glömt lösenordet?",
|
||||
"auth.disable_register_mail": "Tyvärr så är registreringsbekräftelemailutskick inaktiverat.",
|
||||
"auth.disable_register_prompt": "Tyvärr är användarregistreringen inaktiverad. Vänligen kontakta din administratör.",
|
||||
"auth.non_local_account": "Icke-lokala konton får inte ändra lösenord genom Gogs.",
|
||||
"auth.create_new_account": "Skapa nytt konto",
|
||||
"auth.register_hepler_msg": "Har du redan ett konto? Logga in nu!",
|
||||
"auth.sign_up_now": "Behöver du ett konto? Registrera dig nu.",
|
||||
"auth.reset_password": "Återställ ditt lösenord",
|
||||
"auth.invalid_code": "Tyvärr, din bekräftelsekod har antingen upphört att gälla eller är ogiltig.",
|
||||
"tool.now": "nu",
|
||||
"tool.ago": "sedan",
|
||||
"tool.from_now": "från och med nu",
|
||||
"tool.1s": "1 sekund %s",
|
||||
"tool.1m": "1 minut %s",
|
||||
"tool.1h": "1 timme %s",
|
||||
"tool.1d": "1 dag %s",
|
||||
"tool.1w": "1 vecka %s",
|
||||
"tool.1mon": "1 månad %s",
|
||||
"tool.1y": "1 år %s",
|
||||
"tool.seconds": "%d sekunder %s",
|
||||
"tool.minutes": "%d minuter %s",
|
||||
"tool.hours": "%d timmar %s",
|
||||
"tool.days": "%d dagar %s",
|
||||
"tool.weeks": "%d veckor %s",
|
||||
"tool.months": "%d månader %s",
|
||||
"tool.years": "%d år %s",
|
||||
"repo.editor.edit_file": "Redigera fil",
|
||||
"repo.editor.delete_this_file": "Tag bort denna fil",
|
||||
"repo.files": "Filer",
|
||||
"repo.settings": "Inställningar",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Bevaka",
|
||||
"repo.unwatch": "Avsluta bevakning",
|
||||
"repo.star": "Stjärnmärk",
|
||||
"repo.fork": "Fork"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Yönetim Paneli",
|
||||
"settings": "Ayarlar",
|
||||
"language": "Dil",
|
||||
"page_not_found": "Sayfa Bulunamadı",
|
||||
"internal_server_error": "İç Sunucu Hatası.",
|
||||
"repository": "Depo",
|
||||
"username": "Kullanıcı Adı",
|
||||
"email": "E-Posta",
|
||||
"password": "Parola",
|
||||
"captcha": "Captcha",
|
||||
"auth_source": "Yetkilendirme Kaynağı",
|
||||
"local": "Yerel",
|
||||
"forget_password": "Parolanızı mı unuttunuz?",
|
||||
"disable_register_mail": "Üzgünüz, kayıt doğrulama e-postası devre dışı bırakıldı.",
|
||||
"disable_register_prompt": "Üzgünüz, kaydolma devre dışı bırakıldı. Lütfen site yöneticisiyle irtibata geçin.",
|
||||
"non_local_account": "Yerel olmayan hesapların şifrelerini Gogs aracılığıyla değiştiremezsiniz.",
|
||||
"create_new_account": "Yeni Hesap Oluştur",
|
||||
"register_hepler_msg": "Bir hesabınız var mı? Şimdi giriş yapın!",
|
||||
"sign_up": "Kaydol",
|
||||
"sign_up_now": "Bir hesaba mı ihtiyacınız var? Şimdi kaydolun.",
|
||||
"reset_password": "Parolanızı Sıfırlayın",
|
||||
"invalid_code": "Üzgünüz, doğrulama kodunuz geçersiz veya süresi dolmuş.",
|
||||
"new_password": "Yeni Parola",
|
||||
"confirm_password": "Parolayı Doğrula"
|
||||
"status.page_not_found": "Sayfa Bulunamadı",
|
||||
"status.internal_server_error": "İç Sunucu Hatası.",
|
||||
"auth.auth_source": "Yetkilendirme Kaynağı",
|
||||
"auth.local": "Yerel",
|
||||
"auth.forget_password": "Parolanızı mı unuttunuz?",
|
||||
"auth.disable_register_mail": "Üzgünüz, kayıt doğrulama e-postası devre dışı bırakıldı.",
|
||||
"auth.disable_register_prompt": "Üzgünüz, kaydolma devre dışı bırakıldı. Lütfen site yöneticisiyle irtibata geçin.",
|
||||
"auth.non_local_account": "Yerel olmayan hesapların şifrelerini Gogs aracılığıyla değiştiremezsiniz.",
|
||||
"auth.create_new_account": "Yeni Hesap Oluştur",
|
||||
"auth.register_hepler_msg": "Bir hesabınız var mı? Şimdi giriş yapın!",
|
||||
"auth.sign_up_now": "Bir hesaba mı ihtiyacınız var? Şimdi kaydolun.",
|
||||
"auth.reset_password": "Parolanızı Sıfırlayın",
|
||||
"auth.invalid_code": "Üzgünüz, doğrulama kodunuz geçersiz veya süresi dolmuş.",
|
||||
"tool.now": "şimdi",
|
||||
"tool.ago": "önce",
|
||||
"tool.from_now": "şu andan",
|
||||
"tool.1s": "1 saniye %s",
|
||||
"tool.1m": "1 dakika %s",
|
||||
"tool.1h": "1 saat %s",
|
||||
"tool.1d": "1 gün %s",
|
||||
"tool.1w": "1 hafta %s",
|
||||
"tool.1mon": "1 ay %s",
|
||||
"tool.1y": "1 yıl %s",
|
||||
"tool.seconds": "%d saniye %s",
|
||||
"tool.minutes": "%d dakika %s",
|
||||
"tool.hours": "%d saat %s",
|
||||
"tool.days": "%d gün %s",
|
||||
"tool.weeks": "%d hafta %s",
|
||||
"tool.months": "%d ay %s",
|
||||
"tool.years": "%d yıl %s",
|
||||
"repo.editor.edit_file": "Dosya düzenle",
|
||||
"repo.editor.delete_this_file": "Bu dosyayı sil",
|
||||
"repo.files": "Dosyalar",
|
||||
"repo.settings": "Ayarlar",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "İzle",
|
||||
"repo.unwatch": "İzlemeyi Bırak",
|
||||
"repo.star": "Yıldızla",
|
||||
"repo.fork": "Çatalla"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Панель адміністратора",
|
||||
"settings": "Налаштування",
|
||||
"language": "Мова",
|
||||
"page_not_found": "Сторінку не знайдено",
|
||||
"internal_server_error": "Внутрішня помилка серверу",
|
||||
"repository": "Репозиторій",
|
||||
"username": "Ім'я користувача",
|
||||
"email": "Електронна пошта",
|
||||
"password": "Пароль",
|
||||
"captcha": "CAPTCHA",
|
||||
"auth_source": "Джерело автентифікації",
|
||||
"local": "Локальний",
|
||||
"forget_password": "Забули пароль?",
|
||||
"disable_register_mail": "На жаль, підтвердження реєстрації на електрону пошту вимкнено адміністратором.",
|
||||
"disable_register_prompt": "Вибачте, реєстрація відключена. Будь ласка, зв'яжіться з адміністратором сайту.",
|
||||
"non_local_account": "Нелокальні облікові записи не можуть змінити пароль через Gogs.",
|
||||
"create_new_account": "Створити новий обліковий запис",
|
||||
"register_hepler_msg": "Вже зареєстровані? Увійдіть зараз!",
|
||||
"sign_up": "Реєстрація",
|
||||
"sign_up_now": "Потрібен обліковий запис? Зареєструватися зараз.",
|
||||
"reset_password": "Скинути пароль",
|
||||
"invalid_code": "На жаль, код підтвердження, закінчився або помилковий.",
|
||||
"new_password": "Новий пароль",
|
||||
"confirm_password": "Підтвердження паролю"
|
||||
"status.page_not_found": "Сторінку не знайдено",
|
||||
"status.internal_server_error": "Внутрішня помилка серверу",
|
||||
"auth.auth_source": "Джерело автентифікації",
|
||||
"auth.local": "Локальний",
|
||||
"auth.forget_password": "Забули пароль?",
|
||||
"auth.disable_register_mail": "На жаль, підтвердження реєстрації на електрону пошту вимкнено адміністратором.",
|
||||
"auth.disable_register_prompt": "Вибачте, реєстрація відключена. Будь ласка, зв'яжіться з адміністратором сайту.",
|
||||
"auth.non_local_account": "Нелокальні облікові записи не можуть змінити пароль через Gogs.",
|
||||
"auth.create_new_account": "Створити новий обліковий запис",
|
||||
"auth.register_hepler_msg": "Вже зареєстровані? Увійдіть зараз!",
|
||||
"auth.sign_up_now": "Потрібен обліковий запис? Зареєструватися зараз.",
|
||||
"auth.reset_password": "Скинути пароль",
|
||||
"auth.invalid_code": "На жаль, код підтвердження, закінчився або помилковий.",
|
||||
"tool.now": "зараз",
|
||||
"tool.ago": "тому",
|
||||
"tool.from_now": "віднині",
|
||||
"tool.1s": "1 секунду %s",
|
||||
"tool.1m": "1 хвилину %s",
|
||||
"tool.1h": "1 годину %s",
|
||||
"tool.1d": "1 день %s",
|
||||
"tool.1w": "1 тиждень %s",
|
||||
"tool.1mon": "1 місяць %s",
|
||||
"tool.1y": "1 рік %s",
|
||||
"tool.seconds": "%d секунд %s",
|
||||
"tool.minutes": "%d хвилин %s",
|
||||
"tool.hours": "%d годин %s",
|
||||
"tool.days": "%d днів %s",
|
||||
"tool.weeks": "%d тижнів %s",
|
||||
"tool.months": "%d місяців %s",
|
||||
"tool.years": "%d роки %s",
|
||||
"repo.editor.edit_file": "Редагування файла",
|
||||
"repo.editor.delete_this_file": "Видалити цей файл",
|
||||
"repo.files": "Файли",
|
||||
"repo.settings": "Налаштування",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Слідкувати",
|
||||
"repo.unwatch": "Не стежити",
|
||||
"repo.star": "Зірка",
|
||||
"repo.fork": "Відгалуження"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "Bảng quản trị",
|
||||
"settings": "Cài đặt",
|
||||
"language": "Ngôn ngữ",
|
||||
"page_not_found": "Không tìm thấy trang này!",
|
||||
"internal_server_error": "Lỗi nội bộ máy chủ.",
|
||||
"repository": "Kho",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Mật khẩu",
|
||||
"captcha": "Mã xác minh",
|
||||
"auth_source": "Authentication Source",
|
||||
"local": "Local",
|
||||
"forget_password": "Quên mật khẩu?",
|
||||
"disable_register_mail": "Xin lỗi, đăng ký đã bị vô hiệu. Xin vui lòng liên hệ với người quản trị trang web.",
|
||||
"disable_register_prompt": "Xin lỗi, đăng ký đã bị vô hiệu. Xin vui lòng liên hệ với người quản trị trang web.",
|
||||
"non_local_account": "Tài khoản Non-local không thể thay đổi mật khẩu thông qua Gogs.",
|
||||
"create_new_account": "Tạo một Tài khoản mới",
|
||||
"register_hepler_msg": "Đã có tài khoản? Đăng nhập bây giờ!",
|
||||
"sign_up": "Đăng ký",
|
||||
"sign_up_now": "Cần một tài khoản? Đăng ký bây giờ.",
|
||||
"reset_password": "Đặt lại mật khẩu của bạn",
|
||||
"invalid_code": "Xin lỗi, mã số xác nhận của bạn đã hết hạn hoặc không hợp lệ.",
|
||||
"new_password": "Mật khẩu mới",
|
||||
"confirm_password": "Xác nhận mật khẩu"
|
||||
"status.page_not_found": "Không tìm thấy trang này!",
|
||||
"status.internal_server_error": "Lỗi nội bộ máy chủ.",
|
||||
"auth.auth_source": "Authentication Source",
|
||||
"auth.local": "Local",
|
||||
"auth.forget_password": "Quên mật khẩu?",
|
||||
"auth.disable_register_mail": "Xin lỗi, đăng ký đã bị vô hiệu. Xin vui lòng liên hệ với người quản trị trang web.",
|
||||
"auth.disable_register_prompt": "Xin lỗi, đăng ký đã bị vô hiệu. Xin vui lòng liên hệ với người quản trị trang web.",
|
||||
"auth.non_local_account": "Tài khoản Non-local không thể thay đổi mật khẩu thông qua Gogs.",
|
||||
"auth.create_new_account": "Tạo một Tài khoản mới",
|
||||
"auth.register_hepler_msg": "Đã có tài khoản? Đăng nhập bây giờ!",
|
||||
"auth.sign_up_now": "Cần một tài khoản? Đăng ký bây giờ.",
|
||||
"auth.reset_password": "Đặt lại mật khẩu của bạn",
|
||||
"auth.invalid_code": "Xin lỗi, mã số xác nhận của bạn đã hết hạn hoặc không hợp lệ.",
|
||||
"tool.now": "bây giờ",
|
||||
"tool.ago": "cách đây",
|
||||
"tool.from_now": "từ bây giờ",
|
||||
"tool.1s": "1 giây trước %s",
|
||||
"tool.1m": "1 phút trước %s",
|
||||
"tool.1h": "1 giờ trước %s",
|
||||
"tool.1d": "1 ngày trước %s",
|
||||
"tool.1w": "1 tuần trước %s",
|
||||
"tool.1mon": "1 tháng trước %s",
|
||||
"tool.1y": "1 năm trước %s",
|
||||
"tool.seconds": "%d giây trước %s",
|
||||
"tool.minutes": "%d phút trước %s",
|
||||
"tool.hours": "%d giờ trước %s",
|
||||
"tool.days": "%d ngày trước %s",
|
||||
"tool.weeks": "%d tuần trước %s",
|
||||
"tool.months": "%d tháng trước %s",
|
||||
"tool.years": "%d năm trước %s",
|
||||
"repo.editor.edit_file": "Sửa tập tin",
|
||||
"repo.editor.delete_this_file": "Xóa tập tin này",
|
||||
"repo.files": "Các tập tin",
|
||||
"repo.settings": "Cài đặt",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "Xem",
|
||||
"repo.unwatch": "Ngừng theo dõi",
|
||||
"repo.star": "Star",
|
||||
"repo.fork": "Fork"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "管理面板",
|
||||
"settings": "帐户设置",
|
||||
"language": "语言选项",
|
||||
"page_not_found": "页面未找到",
|
||||
"internal_server_error": "内部服务器错误",
|
||||
"repository": "仓库",
|
||||
"username": "用户名",
|
||||
"email": "邮箱",
|
||||
"password": "密码",
|
||||
"captcha": "验证码",
|
||||
"auth_source": "认证源",
|
||||
"local": "本地",
|
||||
"forget_password": "忘记密码?",
|
||||
"disable_register_mail": "对不起,注册邮箱确认功能已被关闭。",
|
||||
"disable_register_prompt": "对不起,注册功能已被关闭。请联系网站管理员。",
|
||||
"non_local_account": "非本地类型的帐户无法通过 Gogs 修改密码。",
|
||||
"create_new_account": "创建帐户",
|
||||
"register_hepler_msg": "已经注册?立即登录!",
|
||||
"sign_up": "注册",
|
||||
"sign_up_now": "还没帐户?马上注册。",
|
||||
"reset_password": "重置密码",
|
||||
"invalid_code": "对不起,您的确认代码已过期或已失效。",
|
||||
"new_password": "新的密码",
|
||||
"confirm_password": "确认密码"
|
||||
"status.page_not_found": "页面未找到",
|
||||
"status.internal_server_error": "内部服务器错误",
|
||||
"auth.auth_source": "认证源",
|
||||
"auth.local": "本地",
|
||||
"auth.forget_password": "忘记密码?",
|
||||
"auth.disable_register_mail": "对不起,注册邮箱确认功能已被关闭。",
|
||||
"auth.disable_register_prompt": "对不起,注册功能已被关闭。请联系网站管理员。",
|
||||
"auth.non_local_account": "非本地类型的帐户无法通过 Gogs 修改密码。",
|
||||
"auth.create_new_account": "创建帐户",
|
||||
"auth.register_hepler_msg": "已经注册?立即登录!",
|
||||
"auth.sign_up_now": "还没帐户?马上注册。",
|
||||
"auth.reset_password": "重置密码",
|
||||
"auth.invalid_code": "对不起,您的确认代码已过期或已失效。",
|
||||
"tool.now": "刚刚",
|
||||
"tool.ago": "之前",
|
||||
"tool.from_now": "之后",
|
||||
"tool.1s": "1 秒%s",
|
||||
"tool.1m": "1 分钟%s",
|
||||
"tool.1h": "1 小时%s",
|
||||
"tool.1d": "1 天%s",
|
||||
"tool.1w": "1 周%s",
|
||||
"tool.1mon": "1 月%s",
|
||||
"tool.1y": "1 年%s",
|
||||
"tool.seconds": "%d 秒%s",
|
||||
"tool.minutes": "%d 分钟%s",
|
||||
"tool.hours": "%d 小时%s",
|
||||
"tool.days": "%d 天%s",
|
||||
"tool.weeks": "%d 周%s",
|
||||
"tool.months": "%d 月%s",
|
||||
"tool.years": "%d 年%s",
|
||||
"repo.editor.edit_file": "编辑文件",
|
||||
"repo.editor.delete_this_file": "删除此文件",
|
||||
"repo.files": "文件",
|
||||
"repo.settings": "仓库设置",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "关注",
|
||||
"repo.unwatch": "取消关注",
|
||||
"repo.star": "点赞",
|
||||
"repo.fork": "派生"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "管理面板",
|
||||
"settings": "設定",
|
||||
"language": "語言",
|
||||
"page_not_found": "Page Not Found",
|
||||
"internal_server_error": "Internal Server Error",
|
||||
"repository": "儲存庫",
|
||||
"username": "用戶名稱",
|
||||
"email": "電子郵件",
|
||||
"password": "密碼",
|
||||
"captcha": "驗證碼",
|
||||
"auth_source": "Authentication Source",
|
||||
"local": "Local",
|
||||
"forget_password": "忘記密碼?",
|
||||
"disable_register_mail": "對不起,註冊郵箱確認功能已被關閉。",
|
||||
"disable_register_prompt": "對不起,註冊功能已被關閉。請聯系網站管理員。",
|
||||
"non_local_account": "Non-local accounts cannot change passwords through Gogs.",
|
||||
"create_new_account": "創建帳戶",
|
||||
"register_hepler_msg": "已經註冊?立即登錄!",
|
||||
"sign_up": "註冊",
|
||||
"sign_up_now": "還沒帳戶?馬上註冊。",
|
||||
"reset_password": "重置密碼",
|
||||
"invalid_code": "對不起,您的確認代碼已過期或已失效。",
|
||||
"new_password": "新的密碼",
|
||||
"confirm_password": "確認密碼"
|
||||
"status.page_not_found": "Page Not Found",
|
||||
"status.internal_server_error": "Internal Server Error",
|
||||
"auth.auth_source": "Authentication Source",
|
||||
"auth.local": "Local",
|
||||
"auth.forget_password": "忘記密碼?",
|
||||
"auth.disable_register_mail": "對不起,註冊郵箱確認功能已被關閉。",
|
||||
"auth.disable_register_prompt": "對不起,註冊功能已被關閉。請聯系網站管理員。",
|
||||
"auth.non_local_account": "Non-local accounts cannot change passwords through Gogs.",
|
||||
"auth.create_new_account": "創建帳戶",
|
||||
"auth.register_hepler_msg": "已經註冊?立即登錄!",
|
||||
"auth.sign_up_now": "還沒帳戶?馬上註冊。",
|
||||
"auth.reset_password": "重置密碼",
|
||||
"auth.invalid_code": "對不起,您的確認代碼已過期或已失效。",
|
||||
"tool.now": "現在",
|
||||
"tool.ago": "之前",
|
||||
"tool.from_now": "之後",
|
||||
"tool.1s": "1 秒%s",
|
||||
"tool.1m": "1 分鐘%s",
|
||||
"tool.1h": "1 小時%s",
|
||||
"tool.1d": "1 天%s",
|
||||
"tool.1w": "1 周%s",
|
||||
"tool.1mon": "1 月%s",
|
||||
"tool.1y": "1 年%s",
|
||||
"tool.seconds": "%d 秒%s",
|
||||
"tool.minutes": "%d 分鐘%s",
|
||||
"tool.hours": "%d 小時%s",
|
||||
"tool.days": "%d 天%s",
|
||||
"tool.weeks": "%d 周%s",
|
||||
"tool.months": "%d 月%s",
|
||||
"tool.years": "%d 年%s",
|
||||
"repo.editor.edit_file": "Edit file",
|
||||
"repo.editor.delete_this_file": "Delete this file",
|
||||
"repo.files": "Files",
|
||||
"repo.settings": "倉庫設置",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "關註",
|
||||
"repo.unwatch": "取消關注",
|
||||
"repo.star": "讚好",
|
||||
"repo.fork": "複刻"
|
||||
}
|
||||
|
||||
+40
-16
@@ -20,24 +20,48 @@
|
||||
"admin_panel": "管理面板",
|
||||
"settings": "設定",
|
||||
"language": "語言",
|
||||
"page_not_found": "找不到頁面",
|
||||
"internal_server_error": "內部伺服器錯誤",
|
||||
"repository": "儲存庫",
|
||||
"username": "用戶名稱",
|
||||
"email": "電子郵件",
|
||||
"password": "密碼",
|
||||
"captcha": "驗證碼",
|
||||
"auth_source": "認證來源",
|
||||
"local": "本地",
|
||||
"forget_password": "忘記密碼?",
|
||||
"disable_register_mail": "對不起,註冊郵箱確認功能已被關閉。",
|
||||
"disable_register_prompt": "對不起,註冊功能已被關閉。請聯系網站管理員。",
|
||||
"non_local_account": "非本地帳戶無法通過 Gogs 修改密碼。",
|
||||
"create_new_account": "創建帳戶",
|
||||
"register_hepler_msg": "已經註冊?立即登錄!",
|
||||
"sign_up": "註冊",
|
||||
"sign_up_now": "還沒帳戶?馬上註冊。",
|
||||
"reset_password": "重置密碼",
|
||||
"invalid_code": "對不起,您的確認代碼已過期或已失效。",
|
||||
"new_password": "新的密碼",
|
||||
"confirm_password": "確認密碼"
|
||||
"status.page_not_found": "找不到頁面",
|
||||
"status.internal_server_error": "內部伺服器錯誤",
|
||||
"auth.auth_source": "認證來源",
|
||||
"auth.local": "本地",
|
||||
"auth.forget_password": "忘記密碼?",
|
||||
"auth.disable_register_mail": "對不起,註冊郵箱確認功能已被關閉。",
|
||||
"auth.disable_register_prompt": "對不起,註冊功能已被關閉。請聯系網站管理員。",
|
||||
"auth.non_local_account": "非本地帳戶無法通過 Gogs 修改密碼。",
|
||||
"auth.create_new_account": "創建帳戶",
|
||||
"auth.register_hepler_msg": "已經註冊?立即登錄!",
|
||||
"auth.sign_up_now": "還沒帳戶?馬上註冊。",
|
||||
"auth.reset_password": "重置密碼",
|
||||
"auth.invalid_code": "對不起,您的確認代碼已過期或已失效。",
|
||||
"tool.now": "現在",
|
||||
"tool.ago": "之前",
|
||||
"tool.from_now": "之後",
|
||||
"tool.1s": "1 秒%s",
|
||||
"tool.1m": "1 分鐘%s",
|
||||
"tool.1h": "1 小時%s",
|
||||
"tool.1d": "1 天%s",
|
||||
"tool.1w": "1 周%s",
|
||||
"tool.1mon": "1 月%s",
|
||||
"tool.1y": "1 年%s",
|
||||
"tool.seconds": "%d 秒%s",
|
||||
"tool.minutes": "%d 分鐘%s",
|
||||
"tool.hours": "%d 小時%s",
|
||||
"tool.days": "%d 天%s",
|
||||
"tool.weeks": "%d 周%s",
|
||||
"tool.months": "%d 月%s",
|
||||
"tool.years": "%d 年%s",
|
||||
"repo.editor.edit_file": "編輯文件",
|
||||
"repo.editor.delete_this_file": "刪除此文件",
|
||||
"repo.files": "檔案",
|
||||
"repo.settings": "倉庫設置",
|
||||
"repo.wiki": "Wiki",
|
||||
"repo.watch": "關注",
|
||||
"repo.unwatch": "取消關注",
|
||||
"repo.star": "讚好",
|
||||
"repo.fork": "複刻"
|
||||
}
|
||||
|
||||
+6
-3
@@ -1,6 +1,7 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import { ThemeProvider } from "./components/ThemeProvider";
|
||||
import { UserInfoProvider } from "./components/UserInfoProvider";
|
||||
import "./index.css";
|
||||
import "./lib/i18n";
|
||||
@@ -11,8 +12,10 @@ const userInfo = await fetchUserInfo();
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<UserInfoProvider value={userInfo}>
|
||||
<App user={userInfo} />
|
||||
</UserInfoProvider>,
|
||||
<ThemeProvider>
|
||||
<UserInfoProvider value={userInfo}>
|
||||
<App user={userInfo} />
|
||||
</UserInfoProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { usePageTitle } from "@/lib/page-title";
|
||||
|
||||
export function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("page_not_found"));
|
||||
usePageTitle(t("status.page_not_found"));
|
||||
const path = typeof window === "undefined" ? "/" : window.location.pathname;
|
||||
return (
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-10 sm:px-6 sm:py-16">
|
||||
|
||||
@@ -6,14 +6,14 @@ import { usePageTitle } from "@/lib/page-title";
|
||||
|
||||
export function ServerError({ error }: ErrorComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("internal_server_error"));
|
||||
usePageTitle(t("status.internal_server_error"));
|
||||
const path = typeof window === "undefined" ? "/" : window.location.pathname;
|
||||
|
||||
// Prefer the structured `error` field from the webapi JSON response; fall
|
||||
// back to the raw body when the upstream returned non-JSON (e.g. a proxy
|
||||
// error page); fall back again to the generic message when nothing useful
|
||||
// was carried over.
|
||||
let detail = t("internal_server_error");
|
||||
let detail = t("status.internal_server_error");
|
||||
if (error instanceof LoaderResponseError) {
|
||||
if (error.errorField) {
|
||||
detail = error.errorField;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Search-param schema for the commit diff route. Defined here (and not in
|
||||
// router.tsx) so both the route definition and the page component can import
|
||||
// from it without forming a circular dependency through router.tsx.
|
||||
//
|
||||
// All diff page toggles serialize through the URL so the view is shareable
|
||||
// and survives reload. Defaults are implicit (omitted from the URL).
|
||||
//
|
||||
// `w` rides with the loader because whitespace handling lives in `git diff`,
|
||||
// not in the parsed-patch client code. Keep `WhitespaceMode` in sync with
|
||||
// `whitespaceFlag` in `internal/route/repo/commit.go`.
|
||||
|
||||
// URL-side whitespace value: the page treats `undefined` as "show", so it
|
||||
// never appears in the URL. The toolbar uses a richer enum (see DiffToolbar)
|
||||
// that includes "show" as an explicit option for the radio UI.
|
||||
export type WhitespaceUrlValue = "ignore-all" | "ignore-change";
|
||||
// `unified` is the default and stays implicit. Only `split` ever appears.
|
||||
export type DiffStyleUrlValue = "split";
|
||||
|
||||
export interface RepoCommitSearch {
|
||||
whitespace?: WhitespaceUrlValue;
|
||||
style?: DiffStyleUrlValue;
|
||||
wrap?: true;
|
||||
}
|
||||
|
||||
export function validateRepoCommitSearch(search: Record<string, unknown>): RepoCommitSearch {
|
||||
const out: RepoCommitSearch = {};
|
||||
if (search.whitespace === "ignore-all" || search.whitespace === "ignore-change") {
|
||||
out.whitespace = search.whitespace;
|
||||
}
|
||||
if (search.style === "split") out.style = "split";
|
||||
if (search.wrap === true || search.wrap === "true") out.wrap = true;
|
||||
return out;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ export function Activate() {
|
||||
const { t } = useTranslation();
|
||||
const { code, email, codeLifetimeHours } = route.useLoaderData();
|
||||
const authenticated = useUserInfo() !== null;
|
||||
usePageTitle(t("activate_your_account"));
|
||||
usePageTitle(t("auth.activate_your_account"));
|
||||
|
||||
const isVerifying = code !== "";
|
||||
const [verifyFailed, setVerifyFailed] = useState(false);
|
||||
@@ -72,14 +72,14 @@ export function Activate() {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as ActivateErrorResponse;
|
||||
setFormError(body.error ?? t("send_activation_email_failed"));
|
||||
setFormError(body.error ?? t("auth.send_activation_email_failed"));
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
setResent((await res.json()) as ActivateResponse);
|
||||
setSubmitting(false);
|
||||
} catch {
|
||||
setFormError(t("send_activation_email_failed"));
|
||||
setFormError(t("auth.send_activation_email_failed"));
|
||||
setSubmitting(false);
|
||||
}
|
||||
})();
|
||||
@@ -89,7 +89,7 @@ export function Activate() {
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-10 sm:px-6 sm:py-16">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="items-center text-center">
|
||||
<CardTitle>{t("activate_your_account")}</CardTitle>
|
||||
<CardTitle>{t("auth.activate_your_account")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">{renderContent()}</CardContent>
|
||||
</Card>
|
||||
@@ -102,17 +102,17 @@ export function Activate() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 text-center">
|
||||
<p role="alert" className="text-sm text-(--color-destructive)">
|
||||
{t("invalid_code")}
|
||||
{t("auth.invalid_code")}
|
||||
</p>
|
||||
<Button variant="link" size="inline" asChild className="self-center">
|
||||
<a href={subUrl("/user/sign-in")}>{t("back_to_sign_in")}</a>
|
||||
<a href={subUrl("/user/sign-in")}>{t("auth.back_to_sign_in")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p role="status" className="text-center text-sm text-(--color-foreground)">
|
||||
{t("activating_account")}
|
||||
{t("auth.activating_account")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -120,9 +120,9 @@ export function Activate() {
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 text-center">
|
||||
<p className="text-sm text-(--color-foreground)">{t("check_activation_email")}</p>
|
||||
<p className="text-sm text-(--color-foreground)">{t("auth.check_activation_email")}</p>
|
||||
<Button variant="link" size="inline" asChild className="self-center">
|
||||
<a href={subUrl("/user/sign-in")}>{t("back_to_sign_in")}</a>
|
||||
<a href={subUrl("/user/sign-in")}>{t("auth.back_to_sign_in")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -132,10 +132,10 @@ export function Activate() {
|
||||
return (
|
||||
<p role="status" className="text-center text-sm text-(--color-foreground)">
|
||||
{resent.rateLimited ? (
|
||||
t("resend_rate_limited")
|
||||
t("auth.resend_rate_limited")
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="activation_email_sent"
|
||||
i18nKey="auth.activation_email_sent"
|
||||
values={{ email, hours: resent.codeLifetimeHours }}
|
||||
components={{ email: <b />, hours: <b /> }}
|
||||
/>
|
||||
@@ -158,13 +158,13 @@ export function Activate() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-(--color-foreground)">
|
||||
<Trans
|
||||
i18nKey="activation_email_pending"
|
||||
i18nKey="auth.activation_email_pending"
|
||||
values={{ email, hours: codeLifetimeHours }}
|
||||
components={{ email: <b />, hours: <b /> }}
|
||||
/>
|
||||
</p>
|
||||
<Button type="submit" disabled={submitting} className="w-full">
|
||||
{submitting ? t("sending_activation_email") : t("send_activation_email")}
|
||||
{submitting ? t("auth.sending_activation_email") : t("auth.send_activation_email")}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
+11
-11
@@ -20,7 +20,7 @@ const route = getRouteApi("/user/mfa");
|
||||
|
||||
export function MFA() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("mfa_title"));
|
||||
usePageTitle(t("auth.mfa_title"));
|
||||
const navigate = useNavigate();
|
||||
// When no challenge is pending the loader has already kicked off a full
|
||||
// navigation away; the early return keeps this page from flashing.
|
||||
@@ -80,7 +80,7 @@ export function MFA() {
|
||||
if (errBody.error) setFormError(errBody.error);
|
||||
if (errBody.fields) setFieldErrors(errBody.fields);
|
||||
if (!errBody.error && !errBody.fields) {
|
||||
setFormError(t("mfa_verify_failed"));
|
||||
setFormError(t("auth.mfa_verify_failed"));
|
||||
}
|
||||
setSubmitting(false);
|
||||
// Focus after React re-enables the fieldset; .focus() is a no-op
|
||||
@@ -95,7 +95,7 @@ export function MFA() {
|
||||
const to = new URLSearchParams(window.location.search).get("redirect_to") ?? "";
|
||||
window.location.assign(subUrl("/redirect") + "?to=" + encodeURIComponent(to));
|
||||
} catch {
|
||||
setFormError(t("mfa_verify_failed"));
|
||||
setFormError(t("auth.mfa_verify_failed"));
|
||||
setSubmitting(false);
|
||||
}
|
||||
})();
|
||||
@@ -109,7 +109,7 @@ export function MFA() {
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-10 sm:px-6 sm:py-16">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="items-center text-center">
|
||||
<CardTitle>{t("mfa_title")}</CardTitle>
|
||||
<CardTitle>{t("auth.mfa_title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
@@ -126,7 +126,7 @@ export function MFA() {
|
||||
<div className="flex flex-col gap-4">
|
||||
{isPasscode ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={inputId}>{t("mfa_passcode")}</Label>
|
||||
<Label htmlFor={inputId}>{t("auth.mfa_passcode")}</Label>
|
||||
<Input
|
||||
ref={passcodeRef}
|
||||
id={inputId}
|
||||
@@ -137,7 +137,7 @@ export function MFA() {
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
placeholder={t("mfa_passcode_placeholder")}
|
||||
placeholder={t("auth.mfa_passcode_placeholder")}
|
||||
value={passcode}
|
||||
onChange={(e) => setPasscode(e.target.value)}
|
||||
aria-invalid={inputErrorKey in fieldErrors ? true : undefined}
|
||||
@@ -151,7 +151,7 @@ export function MFA() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={inputId}>{t("mfa_recovery_code")}</Label>
|
||||
<Label htmlFor={inputId}>{t("auth.mfa_recovery_code")}</Label>
|
||||
<Input
|
||||
ref={recoveryRef}
|
||||
id={inputId}
|
||||
@@ -161,7 +161,7 @@ export function MFA() {
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
placeholder={t("mfa_recovery_code_placeholder")}
|
||||
placeholder={t("auth.mfa_recovery_code_placeholder")}
|
||||
value={recoveryCode}
|
||||
onChange={(e) => setRecoveryCode(e.target.value)}
|
||||
aria-invalid={inputErrorKey in fieldErrors ? true : undefined}
|
||||
@@ -177,7 +177,7 @@ export function MFA() {
|
||||
|
||||
<div className="mt-2 flex flex-col gap-3">
|
||||
<Button type="submit" disabled={submitting} tabIndex={2} className="w-full">
|
||||
{submitting ? t("mfa_verifying") : t("mfa_verify")}
|
||||
{submitting ? t("auth.mfa_verifying") : t("auth.mfa_verify")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -187,7 +187,7 @@ export function MFA() {
|
||||
className="self-center"
|
||||
onClick={() => switchMode(isPasscode ? "recovery" : "passcode")}
|
||||
>
|
||||
{isPasscode ? t("mfa_use_recovery_code") : t("mfa_use_passcode")}
|
||||
{isPasscode ? t("auth.mfa_use_recovery_code") : t("auth.mfa_use_passcode")}
|
||||
</Button>
|
||||
<Button variant="link" size="inline" asChild className="self-center">
|
||||
<Link
|
||||
@@ -199,7 +199,7 @@ export function MFA() {
|
||||
if (submitting) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t("back_to_sign_in")}
|
||||
{t("auth.back_to_sign_in")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function ResetPassword() {
|
||||
const navigate = useNavigate();
|
||||
const { code, emailEnabled, valid } = route.useLoaderData();
|
||||
const isResetForm = code !== "";
|
||||
usePageTitle(t("reset_password"));
|
||||
usePageTitle(t("auth.reset_password"));
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -55,7 +55,7 @@ export function ResetPassword() {
|
||||
|
||||
if (isResetForm && password !== confirmPassword) {
|
||||
setFormError(null);
|
||||
setFieldErrors({ password: null, confirmPassword: t("password_mismatch") });
|
||||
setFieldErrors({ password: null, confirmPassword: t("auth.password_mismatch") });
|
||||
requestAnimationFrame(() => confirmPasswordRef.current?.focus());
|
||||
return;
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export function ResetPassword() {
|
||||
})();
|
||||
}
|
||||
|
||||
const title = t("reset_password");
|
||||
const title = t("auth.reset_password");
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-10 sm:px-6 sm:py-16">
|
||||
@@ -119,7 +119,7 @@ export function ResetPassword() {
|
||||
if (!emailEnabled) {
|
||||
return (
|
||||
<p role="alert" className="text-center text-sm text-(--color-destructive)">
|
||||
{t("disable_register_mail")}
|
||||
{t("auth.disable_register_mail")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -128,17 +128,17 @@ export function ResetPassword() {
|
||||
<div className="flex flex-col gap-4 text-center">
|
||||
<p role="status" className="text-sm text-(--color-foreground)">
|
||||
{sent.resendLimited ? (
|
||||
t("reset_password_resend_limited")
|
||||
t("auth.reset_password_resend_limited")
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="reset_password_email_sent"
|
||||
i18nKey="auth.reset_password_email_sent"
|
||||
values={{ email, hours: sent.hours }}
|
||||
components={{ email: <b />, hours: <b /> }}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
<Button variant="link" size="inline" asChild className="self-center">
|
||||
<a href={subUrl("/user/sign-in")}>{t("back_to_sign_in")}</a>
|
||||
<a href={subUrl("/user/sign-in")}>{t("auth.back_to_sign_in")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -173,7 +173,7 @@ export function ResetPassword() {
|
||||
)}
|
||||
</div>
|
||||
<FormActions
|
||||
submitLabel={submitting ? t("reset_password_email_submitting") : t("send_reset_email")}
|
||||
submitLabel={submitting ? t("auth.reset_password_email_submitting") : t("auth.send_reset_email")}
|
||||
submitTabIndex={3}
|
||||
/>
|
||||
</div>
|
||||
@@ -187,10 +187,10 @@ export function ResetPassword() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 text-center">
|
||||
<p role="alert" className="text-sm text-(--color-destructive)">
|
||||
{t("invalid_code")}
|
||||
{t("auth.invalid_code")}
|
||||
</p>
|
||||
<Button variant="link" size="inline" asChild className="self-center">
|
||||
<a href={subUrl("/user/sign-in")}>{t("back_to_sign_in")}</a>
|
||||
<a href={subUrl("/user/sign-in")}>{t("auth.back_to_sign_in")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -202,13 +202,13 @@ export function ResetPassword() {
|
||||
{renderFormError()}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password">{t("new_password")}</Label>
|
||||
<Label htmlFor="password">{t("auth.new_password")}</Label>
|
||||
<PasswordInput
|
||||
inputRef={passwordRef}
|
||||
id="password"
|
||||
value={password}
|
||||
tabIndex={1}
|
||||
placeholder={t("new_password_placeholder")}
|
||||
placeholder={t("auth.new_password_placeholder")}
|
||||
show={showPassword}
|
||||
onToggleShow={() => setShowPassword((v) => !v)}
|
||||
disabled={submitting}
|
||||
@@ -224,13 +224,13 @@ export function ResetPassword() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="confirmPassword">{t("confirm_new_password")}</Label>
|
||||
<Label htmlFor="confirmPassword">{t("auth.confirm_new_password")}</Label>
|
||||
<PasswordInput
|
||||
inputRef={confirmPasswordRef}
|
||||
id="confirmPassword"
|
||||
value={confirmPassword}
|
||||
tabIndex={3}
|
||||
placeholder={t("confirm_new_password_placeholder")}
|
||||
placeholder={t("auth.confirm_new_password_placeholder")}
|
||||
show={showConfirmPassword}
|
||||
onToggleShow={() => setShowConfirmPassword((v) => !v)}
|
||||
disabled={submitting}
|
||||
@@ -245,7 +245,7 @@ export function ResetPassword() {
|
||||
)}
|
||||
</div>
|
||||
<FormActions
|
||||
submitLabel={submitting ? t("reset_password_submitting") : t("reset_password_submit")}
|
||||
submitLabel={submitting ? t("auth.reset_password_submitting") : t("auth.reset_password_submit")}
|
||||
submitTabIndex={5}
|
||||
/>
|
||||
</div>
|
||||
@@ -282,7 +282,7 @@ export function ResetPassword() {
|
||||
if (submitting) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t("back_to_sign_in")}
|
||||
{t("auth.back_to_sign_in")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ export function SignIn() {
|
||||
focusField = FIELD_ORDER.find((f) => f in (body.fields ?? {}));
|
||||
}
|
||||
if (!body.error && !body.fields) {
|
||||
setFormError(t("sign_in_failed"));
|
||||
setFormError(t("auth.sign_in_failed"));
|
||||
}
|
||||
setSubmitting(false);
|
||||
// Defer focus past the React commit so the fieldset is re-enabled
|
||||
@@ -101,7 +101,7 @@ export function SignIn() {
|
||||
// /redirect is a server endpoint (303), must be a full navigation.
|
||||
window.location.assign(subUrl("/redirect") + "?to=" + encodeURIComponent(to));
|
||||
} catch {
|
||||
setFormError(t("sign_in_failed"));
|
||||
setFormError(t("auth.sign_in_failed"));
|
||||
setSubmitting(false);
|
||||
}
|
||||
})();
|
||||
@@ -163,7 +163,7 @@ export function SignIn() {
|
||||
if (submitting) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t("forget_password")}
|
||||
{t("auth.forget_password")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -188,7 +188,7 @@ export function SignIn() {
|
||||
tabIndex={3}
|
||||
disabled={submitting}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t("hide_password") : t("show_password")}
|
||||
aria-label={showPassword ? t("auth.hide_password") : t("auth.show_password")}
|
||||
aria-pressed={showPassword}
|
||||
className="absolute inset-y-0 right-0 flex w-10 cursor-pointer items-center justify-center rounded-r-md text-(--color-muted-foreground) outline-none hover:text-(--color-foreground) focus-visible:text-(--color-foreground) focus-visible:ring-1 focus-visible:ring-(--color-ring) disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
@@ -208,7 +208,7 @@ export function SignIn() {
|
||||
|
||||
{loginSources.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="login_source">{t("auth_source")}</Label>
|
||||
<Label htmlFor="login_source">{t("auth.auth_source")}</Label>
|
||||
<Select
|
||||
value={String(loginSource)}
|
||||
onValueChange={(v) => setLoginSource(Number(v))}
|
||||
@@ -218,7 +218,7 @@ export function SignIn() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">{t("local")}</SelectItem>
|
||||
<SelectItem value="0">{t("auth.local")}</SelectItem>
|
||||
{loginSources.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
@@ -231,7 +231,7 @@ export function SignIn() {
|
||||
|
||||
<div className="mt-2 flex flex-col gap-3">
|
||||
<Button type="submit" disabled={submitting} tabIndex={5} className="w-full">
|
||||
{submitting ? t("sign_in_submitting") : t("sign_in")}
|
||||
{submitting ? t("auth.sign_in_submitting") : t("sign_in")}
|
||||
</Button>
|
||||
<Button variant="link" size="inline" asChild className="self-center">
|
||||
<a
|
||||
@@ -243,7 +243,7 @@ export function SignIn() {
|
||||
if (submitting) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t("sign_up_now")}
|
||||
{t("auth.sign_up_now")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@ export function SignUp() {
|
||||
|
||||
setFormError(null);
|
||||
if (password !== confirmPassword) {
|
||||
setFieldErrors({ password: null, confirmPassword: t("password_mismatch") });
|
||||
setFieldErrors({ password: null, confirmPassword: t("auth.password_mismatch") });
|
||||
requestAnimationFrame(() => confirmPasswordRef.current?.focus());
|
||||
return;
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export function SignUp() {
|
||||
focusField = FIELD_ORDER.find((f) => f in (body.fields ?? {}));
|
||||
}
|
||||
if (!body.error && !body.fields) {
|
||||
setFormError(t("sign_up_failed"));
|
||||
setFormError(t("auth.sign_up_failed"));
|
||||
}
|
||||
setSubmitting(false);
|
||||
if (captchaEnabled) refreshCaptcha();
|
||||
@@ -110,7 +110,7 @@ export function SignUp() {
|
||||
}
|
||||
await navigate({ to: "/user/sign-in" });
|
||||
} catch {
|
||||
setFormError(t("sign_up_failed"));
|
||||
setFormError(t("auth.sign_up_failed"));
|
||||
setSubmitting(false);
|
||||
if (captchaEnabled) refreshCaptcha();
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export function SignUp() {
|
||||
if (registrationDisabled) {
|
||||
return (
|
||||
<p role="alert" className="text-center text-sm text-(--color-destructive)">
|
||||
{t("disable_register_prompt")}
|
||||
{t("auth.disable_register_prompt")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -141,13 +141,13 @@ export function SignUp() {
|
||||
<div className="flex flex-col gap-4 text-center">
|
||||
<p role="status" className="text-sm text-(--color-foreground)">
|
||||
<Trans
|
||||
i18nKey="activation_email_sent"
|
||||
i18nKey="auth.activation_email_sent"
|
||||
values={{ email: sent.email, hours: sent.hours }}
|
||||
components={{ email: <b />, hours: <b /> }}
|
||||
/>
|
||||
</p>
|
||||
<Button variant="link" size="inline" asChild className="self-center">
|
||||
<a href={subUrl("/user/sign-in")}>{t("back_to_sign_in")}</a>
|
||||
<a href={subUrl("/user/sign-in")}>{t("auth.back_to_sign_in")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -236,13 +236,13 @@ export function SignUp() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="confirmPassword">{t("confirm_password")}</Label>
|
||||
<Label htmlFor="confirmPassword">{t("auth.confirm_password")}</Label>
|
||||
<PasswordInput
|
||||
inputRef={confirmPasswordRef}
|
||||
id="confirmPassword"
|
||||
value={confirmPassword}
|
||||
tabIndex={5}
|
||||
placeholder={t("confirm_password_placeholder")}
|
||||
placeholder={t("auth.confirm_password_placeholder")}
|
||||
show={showConfirmPassword}
|
||||
onToggleShow={() => setShowConfirmPassword((v) => !v)}
|
||||
disabled={submitting}
|
||||
@@ -306,7 +306,7 @@ export function SignUp() {
|
||||
|
||||
<div className="mt-2 flex flex-col gap-3">
|
||||
<Button type="submit" disabled={submitting} tabIndex={9} className="w-full">
|
||||
{submitting ? t("sign_up_submitting") : t("create_new_account")}
|
||||
{submitting ? t("auth.sign_up_submitting") : t("auth.create_new_account")}
|
||||
</Button>
|
||||
<Button variant="link" size="inline" asChild className="self-center">
|
||||
<a
|
||||
@@ -318,7 +318,7 @@ export function SignUp() {
|
||||
if (submitting) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t("register_hepler_msg")}
|
||||
{t("auth.register_hepler_msg")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+17
-3
@@ -1,16 +1,21 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Outlet, RouterProvider, createRootRouteWithContext, createRoute, createRouter } from "@tanstack/react-router";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import { Footer } from "@/components/Footer";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { webContext } from "@/lib/context";
|
||||
import type { UserInfo } from "@/lib/user-info";
|
||||
import { Landing } from "@/pages/Landing";
|
||||
import { NotFound } from "@/pages/NotFound";
|
||||
import { ServerError } from "@/pages/ServerError";
|
||||
import { createRepoRoutes } from "@/routes/repo";
|
||||
import { createUserRoutes } from "@/routes/user";
|
||||
|
||||
interface RouterContext {
|
||||
user: UserInfo | null;
|
||||
queryClient: QueryClient;
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
@@ -34,7 +39,7 @@ const landingRoute = createRoute({
|
||||
component: Landing,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([landingRoute, ...createUserRoutes(rootRoute)]);
|
||||
const routeTree = rootRoute.addChildren([landingRoute, ...createUserRoutes(rootRoute), ...createRepoRoutes(rootRoute)]);
|
||||
|
||||
function makeRouter(context: RouterContext) {
|
||||
return createRouter({
|
||||
@@ -54,7 +59,16 @@ declare module "@tanstack/react-router" {
|
||||
}
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function AppRouter({ user }: { user: UserInfo | null }) {
|
||||
const router = makeRouter({ user });
|
||||
return <RouterProvider router={router} />;
|
||||
const router = makeRouter({ user, queryClient });
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="bottom-right" closeButton richColors />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { type AnyRoute, createRoute, notFound } from "@tanstack/react-router";
|
||||
|
||||
import { LoaderResponseError, loaderResponseError } from "@/lib/loader-error";
|
||||
import { repoHeaderQuery } from "@/lib/queries/repo";
|
||||
import { subUrl } from "@/lib/url";
|
||||
import { RepoCommit, type RepoCommitPage } from "@/pages/repo/Commit";
|
||||
import { type RepoCommitSearch, validateRepoCommitSearch } from "@/pages/repo/Commit.search";
|
||||
|
||||
// Match the legacy server-side route constraint (see `web.go` near the
|
||||
// `/commit/:sha([a-f0-9]{7,40})$` declaration). The server enforces the same
|
||||
// shape for SEO and to skip the SPA shell for malformed paths; this client
|
||||
// check short-circuits the loader so we render 404 without a wasted fetch.
|
||||
const SHA_RE = /^[a-f0-9]{7,40}$/;
|
||||
|
||||
interface RouterContext {
|
||||
queryClient: QueryClient;
|
||||
}
|
||||
|
||||
export function createRepoRoutes(rootRoute: AnyRoute) {
|
||||
const repoCommitRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/$owner/$repo/commit/$sha",
|
||||
validateSearch: validateRepoCommitSearch,
|
||||
// Reject malformed SHA at parse time so the route doesn't match for paths
|
||||
// like `/owner/repo/commit/garbage`. The thrown `notFound()` bubbles to the
|
||||
// root route's NotFound component.
|
||||
params: {
|
||||
parse: (raw: { owner: string; repo: string; sha: string }) => {
|
||||
if (!SHA_RE.test(raw.sha)) {
|
||||
// eslint-disable-next-line @typescript-eslint/only-throw-error -- `notFound()` is the documented TanStack Router signal for 404, not an Error subclass.
|
||||
throw notFound();
|
||||
}
|
||||
return raw;
|
||||
},
|
||||
stringify: (params: { owner: string; repo: string; sha: string }) => params,
|
||||
},
|
||||
loaderDeps: ({ search }: { search: RepoCommitSearch }) => ({ whitespace: search.whitespace }),
|
||||
loader: async ({ params, deps, context }): Promise<RepoCommitPage> => {
|
||||
const metaURL = subUrl(`/api/web/${params.owner}/${params.repo}/commit/${params.sha}`);
|
||||
const rawBase = subUrl(`/${params.owner}/${params.repo}/commit/${params.sha}.diff`);
|
||||
const rawURL = deps.whitespace ? `${rawBase}?whitespace=${encodeURIComponent(deps.whitespace)}` : rawBase;
|
||||
const routerContext = context as RouterContext;
|
||||
// Three requests in parallel: repo header (via Query cache for cross-page
|
||||
// reuse), commit metadata JSON, raw patch text. Splitting the patch out
|
||||
// skips JSON-string escaping and lets the browser cache the (often large)
|
||||
// patch separately from the metadata.
|
||||
try {
|
||||
const [, meta, patch] = await Promise.all([
|
||||
routerContext.queryClient.ensureQueryData(repoHeaderQuery(params.owner, params.repo)),
|
||||
fetch(metaURL, { credentials: "same-origin" }).then(async (res) => {
|
||||
if (!res.ok) throw await loaderResponseError(res);
|
||||
return (await res.json()) as Omit<RepoCommitPage, "patch">;
|
||||
}),
|
||||
fetch(rawURL, { credentials: "same-origin" }).then(async (res) => {
|
||||
if (!res.ok) throw await loaderResponseError(res);
|
||||
return res.text();
|
||||
}),
|
||||
]);
|
||||
return { ...meta, patch };
|
||||
} catch (err) {
|
||||
if (err instanceof LoaderResponseError && err.status === 404) {
|
||||
// eslint-disable-next-line @typescript-eslint/only-throw-error -- `notFound()` is the documented TanStack Router signal for 404, not an Error subclass.
|
||||
throw notFound();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
component: RepoCommit,
|
||||
});
|
||||
|
||||
return [repoCommitRoute];
|
||||
}
|
||||
@@ -13,6 +13,15 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
// The dev page is served by the Go server (e.g., https://gogs.localhost)
|
||||
// which reverse-proxies HTTP to this Vite dev server. That proxy is
|
||||
// HTTP-only, so the HMR client's WebSocket can't tunnel through it. Point
|
||||
// HMR's WS directly at the Vite dev port instead, bypassing gogs entirely.
|
||||
hmr: {
|
||||
protocol: "ws",
|
||||
host: "localhost",
|
||||
port: 5173,
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "../public/dist",
|
||||
|
||||
Reference in New Issue
Block a user