Compare commits

..

34 Commits

Author SHA1 Message Date
Joe Chen 0114e18bd3 31231 2026-05-28 17:20:19 -04:00
Joe Chen c4821cfe31 31231 2026-05-28 17:10:08 -04:00
Joe Chen 9327909209 1231 2026-05-28 16:53:38 -04:00
Joe Chen 8f9a1cf2c1 312 2026-05-28 16:40:47 -04:00
Joe Chen bc6c1ddf07 231 2026-05-28 16:31:27 -04:00
Joe Chen 65cce4eafe 312 2026-05-28 16:22:27 -04:00
Joe Chen 7102695655 213 2026-05-28 16:04:53 -04:00
Joe Chen 32573b844c 213 2026-05-28 16:00:32 -04:00
Joe Chen 98cb5a3db4 1231 2026-05-28 15:54:47 -04:00
Joe Chen a5d2c0ee36 111 2026-05-28 14:09:49 -04:00
Joe Chen 2d78fd22dc 213 2026-05-28 12:47:43 -04:00
Joe Chen c81f418856 1231 2026-05-28 12:25:48 -04:00
Joe Chen d259b87cfd 2131 2026-05-28 12:06:54 -04:00
Joe Chen f7449a8c85 21312 2026-05-28 12:00:53 -04:00
Joe Chen 9e1525bdf8 12312 2026-05-27 23:55:24 -04:00
unknwon c604d23be5 web(api): standardize repo handlers on flamego.Context
Drop `*http.Request` from handlers that only needed it for the request
context or `URL.Query()`, and replace `params := c.Params()` indexing
with `c.Param(...)` lookups. Every handler in webapi_repo.go now takes
just `(c flamego.Context, user *database.User)`.
2026-05-27 22:28:55 -04:00
Joe Chen 22f84f7ee9 111 2026-05-27 22:19:26 -04:00
unknwon c6b90ea5ab web(api): rename repo header fields to match GitHub-style naming
Apply review feedback on PR #8295. Flatten `repoHeaderCounts` into
`repoHeader` and rename fields to be more descriptive on the JSON wire
(`isViewerAdmin`, `issuesEnabled`, `pullRequestsEnabled`, `wikiEnabled`,
`watches`, `openPullRequests`, `isViewerWatching`, `hasViewerStarred`).
Apply the same naming to `repoActionResponse`. Rename `getRepoRaw` ->
`getRepoRawFile` with `{file}` param. Update the TS types and consumers
in `RepoHeader.tsx` to match.
2026-05-27 21:39:04 -04:00
Joe Chen 552fde5e8c 231 2026-05-27 21:25:27 -04:00
Joe Chen 87a7e5a80b 111 2026-05-27 16:48:31 -04:00
Joe Chen 6188e01752 111 2026-05-27 16:15:11 -04:00
Joe Chen 8c17948ddf 1231 2026-05-27 15:31:21 -04:00
Joe Chen a91b0551bd 111 2026-05-27 15:26:56 -04:00
Joe Chen 91e8a36578 1211 2026-05-27 14:20:54 -04:00
Joe Chen 5e191aa5c8 2131 2026-05-27 13:13:27 -04:00
unknwon c2a424a678 web(diff): tighten commit metadata row on commit page
Merge the author and parent/commit/buttons rows into a single
wrap-friendly flex line on desktop. Drop the committer line, since
showing it twice (author + committer) is rarely useful for the
common case where they match. Render each parent SHA as its own
clickable chip so multi-parent merge commits link to every parent.
Align View patch and Browse files to the left on mobile.
2026-05-27 09:47:15 -04:00
unknwon 10c829f798 web(diff): add per-file "Expand all lines" via raw file fetch
The commit diff page only ships the patch hunks, so unmodified context
between hunks is invisible. Add a per-file "Expand all lines" toggle so
the reader can pull in the full file when the surrounding code matters.

Backend:
- Migrate `repo.SingleDownload` to a new Flamego `getRepoRaw` handler.
  Same URL shape (`/{owner}/{name}/raw/{ref}/{path}`) so external
  consumers (`curl`, scripts) keep working. Bridged from the Macaron
  router via `flamegoBridger` so the legacy path doesn't double-route
  through `RepoRef` middleware. The ref segment accepts a branch, tag,
  or commit SHA; commit SHAs match first (the common case from the
  React diff page).
- Delete `repo.SingleDownload` and the legacy `m.Get("/raw/*", ...)`
  Macaron handler. `repo.ServeBlob` stays because `internal/route/api/v1`
  still uses it for the public REST API.

Frontend:
- Add an `UnfoldVertical` icon button to each file header. Click fetches
  the pre + post file contents in parallel via the legacy raw URL,
  calls `parseDiffFromFile` to upgrade the `FileDiffMetadata` to
  `isPartial: false`, and stores the result keyed by item id.
- The `items` useMemo swaps in the upgraded `fileDiff` when present and
  bumps the item `version` so Pierre's `CodeView` re-renders that file.
  Set `expandUnchanged: true` globally so non-partial files immediately
  render all context lines.
- Show a spinner during fetch, hide the button once expansion succeeds.
  Skip the button for added/deleted files (no opposite side to expand).
- Added/deleted files preserve the old behaviour (no expansion).
2026-05-27 03:51:16 -04:00
unknwon c535b64bb9 web: wire commit diff page to real data with TanStack Query
Migrate the React commit diff page off of mocked repo metadata and onto
live web API endpoints, and take over the legacy `/owner/repo/commit/{sha}`
URL so the React page is the canonical commit view.

Backend:
- Split `webapi.go` into `webapi.go` (shared infra), `webapi_user.go`
  (user handlers), and `webapi_repo.go` (repo handlers).
- Add `GET /api/web/{owner}/{name}/info` returning repo header data
  (avatar, visibility, counts, mirror, viewer state). Mirrors legacy
  `RepoAssignment` access logic: admin shortcut + partial-public masking.
- Add `GET /api/web/{owner}/{name}/commit/{sha}` returning commit
  metadata only. Patch text lives on the existing `.diff` URL so it
  avoids JSON-string escaping and caches independently.
- Migrate `repo.RawDiff` to Flamego `getRepoCommitRawDiff`. Now supports
  `?whitespace=` for the React diff toggle. Public URL unchanged.
- Add `POST/DELETE /api/web/{owner}/{name}/watch` and `.../star` returning
  the new viewer state + count so the client can update without refetch.
- Delete legacy `repo.Diff` and `repo.DiffJSON`. Add a SPA pass-through
  Macaron route at `/owner/repo/commit/{sha}` with the legacy
  `[a-f0-9]{7,40}` SHA regex.

Frontend:
- Install `@tanstack/react-query` and wire `QueryClientProvider` in
  `router.tsx`. Pass `queryClient` through router context so loaders can
  prefetch via `ensureQueryData`.
- Add `lib/queries/repo.ts` with `repoInfoQuery` + watch/star mutations.
- Move `CommitDiff.tsx` → `pages/repo/Commit.tsx` and `CommitDiff.search.ts`
  → `pages/repo/Commit.search.ts`. Rename `CommitDiff` → `RepoCommit`,
  `CommitDiffPage` → `RepoCommitPage`, etc.
- Change route from `/$owner/$repo/_diff/$sha` to
  `/$owner/$repo/commit/$sha`. Enforce SHA regex via TanStack `params.parse`
  and convert API 404s to router `notFound()` so they render the NotFound
  page instead of ServerError.
- Loader fetches metadata + raw diff in parallel (plus repo info via
  Query cache), assembles them into `RepoCommitPage`.
- Replace `RepoHeader`'s `RepoHeaderRepo` interface with the live
  `RepoInfo` type. Watch/Star buttons fire `useMutation` with optimistic
  cache updates via `setQueryData`. Anonymous users see sign-in links.
- Swap the "Public"/"Private" pill for a Globe/Lock icon with tooltip.
- Add a collapsible desktop file tree. The toolbar's "Showing N changed
  files" row owns a single toggle icon that opens the Sheet on mobile
  and toggles the persistent sidebar on desktop. State persists to
  localStorage.
- Hide the always-on "Verified" badge until commit signature
  verification lands.
2026-05-27 02:57:35 -04:00
Joe Chen c3577dc6fa 213 2026-05-26 13:18:29 -04:00
unknwon d7c3f16f7a web(diff): use PanelLeftOpen icon for mobile file-tree trigger 2026-05-26 12:02:25 -04:00
unknwon eb24142b83 web: polish commit diff page chrome and search UX
- DiffSearch: walk hunks by addition/deletionCount so matches on context
  lines and pure-deletion hunks are no longer dropped.
- DiffSearch: nudge popup up to top-1 so it sits closer to the toolbar.
- RepoHeader: add per-repo avatar slot (mocked to favicon for now),
  fold mobile tabs past the third into a hamburger overflow, swap
  Issues icon from Clock to CircleDot, nudge avatar down 2px to
  optically center the off-center favicon glyph.
- CommitDiff: render the authored timestamp as a relative string with
  RFC1123 tooltip (matches Gogs's TimeSince template helper); helper
  lives in web/src/lib/relative-time.ts.
- CommitDiff: inject GitHub-style yellow into Pierre's selected-line
  background overrides so search matches read clearly in both themes.
- AGENTS.md: note that chrome-devtools MCP should run headless.
2026-05-26 12:02:25 -04:00
unknwon dcad796c73 web: build out commit diff page on @pierre/diffs
Adds the full commit diff experience around the @pierre/diffs CodeView
and @pierre/trees FileTree spike from the prior commit:

- RepoHeader, DiffToolbar, FileHeaderMenu, ResizableSidebar components
  for the page chrome and per-file actions
- Sheet and Tooltip shadcn primitives
- CommitDiff.search.ts encodes diff toggles in the URL via TanStack
  Router validation so the view is shareable
- Sticky workspace lock that pins the toolbar plus tree plus diff to
  the viewport once the user scrolls past the commit metadata
- Whitespace mode wired through to git via the diff API's new
  whitespace query (ignore-all, ignore-change)
- Per-file collapse, status filter, unified/split toggle, wrap, expand
  all and collapse all
- New --color-success, --color-diff-added, --color-diff-removed
  tokens documented in DESIGN.md, replacing ad-hoc Tailwind palette
  references
2026-05-26 12:02:25 -04:00
Joe Chen 9b7d8ebd9d WIP: Pierre diff 2026-05-26 12:01:58 -04:00
ᴊᴏᴇ ᴄʜᴇɴ 878caa7378 ci: notarize macOS release archives (#8297) 2026-05-24 23:08:45 -04:00
80 changed files with 6433 additions and 1331 deletions
+2
View File
@@ -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
+88
View File
@@ -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,
})
}
+35 -15
View File
@@ -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/`,
+182
View File
@@ -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)
}
+8 -521
View File
@@ -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
}
+248
View File
@@ -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)
}
+514
View File
@@ -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
}
+64 -2
View File
@@ -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
+1 -1
View File
@@ -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
+11 -5
View File
@@ -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
}
+10
View File
@@ -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
}
+35
View File
@@ -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"))
})
}
-70
View File
@@ -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
-14
View File
@@ -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
}
}
+497
View File
@@ -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
View File
@@ -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 (48px) 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
View File
@@ -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>
+6
View File
@@ -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
View File
@@ -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");
+306
View File
@@ -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>
);
});
+204
View File
@@ -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>
);
}
+304
View File
@@ -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>
);
}
+164
View File
@@ -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>
);
});
+1 -1
View File
@@ -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"
>
+484
View File
@@ -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>
);
}
+119
View File
@@ -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>
);
}
+32 -2
View File
@@ -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>;
}
+97
View File
@@ -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 };
+29
View File
@@ -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 };
+29
View File
@@ -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;
}
+15
View File
@@ -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;
+71
View File
@@ -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");
}
+74
View File
@@ -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);
}
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>,
);
}
+1 -1
View File
@@ -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">
+2 -2
View File
@@ -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;
+33
View File
@@ -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
+13 -13
View File
@@ -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
View File
@@ -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>
+16 -16
View File
@@ -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>
+8 -8
View File
@@ -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>
+10 -10
View File
@@ -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
View File
@@ -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>
);
}
+73
View File
@@ -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];
}
+9
View File
@@ -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",