Fix theme discovery and Vite dev server in dev mode (#37033)
1. In dev mode, discover themes from source files in `web_src/css/themes/` instead of AssetFS. In prod, use AssetFS only. Extract shared `collectThemeFiles` helper to deduplicate theme file handling. 2. Implement `fs.ReadDirFS` on `LayeredFS` to support theme file discovery. 3. `IsViteDevMode` now performs an HTTP health check against the vite dev server instead of only checking the port file exists. Result is cached with a 1-second TTL. 4. Refactor theme caching from mutex to atomic pointer with time-based invalidation, allowing themes to refresh when vite dev mode state changes. 5. Move `ViteDevMiddleware` into `ProtocolMiddlewares` so it applies to both install and web routes. 6. Show a `ViteDevMode` label in the page footer when vite dev server is active. 7. Add `/__vite_dev_server_check` endpoint to vite dev server for the health check. 8. Ensure `.vite` directory exists before writing the dev-port file. 9. Minor CSS fixes: footer gap, navbar mobile alignment. --- This PR was written with the help of Claude Opus 4.6 --------- Signed-off-by: silverwind <me@silverwind.io> Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -9,7 +9,9 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/container"
|
"code.gitea.io/gitea/modules/container"
|
||||||
@@ -61,6 +63,8 @@ type LayeredFS struct {
|
|||||||
layers []*Layer
|
layers []*Layer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ fs.ReadDirFS = (*LayeredFS)(nil)
|
||||||
|
|
||||||
// Layered returns a new LayeredFS with the given layers. The first layer is the top layer.
|
// Layered returns a new LayeredFS with the given layers. The first layer is the top layer.
|
||||||
func Layered(layers ...*Layer) *LayeredFS {
|
func Layered(layers ...*Layer) *LayeredFS {
|
||||||
return &LayeredFS{layers: layers}
|
return &LayeredFS{layers: layers}
|
||||||
@@ -83,6 +87,27 @@ func (l *LayeredFS) ReadFile(elems ...string) ([]byte, error) {
|
|||||||
return bs, err
|
return bs, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *LayeredFS) ReadDir(name string) (files []fs.DirEntry, _ error) {
|
||||||
|
filesMap := map[string]fs.DirEntry{}
|
||||||
|
for _, layer := range l.layers {
|
||||||
|
entries, err := readDirOptional(layer, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
entryName := entry.Name()
|
||||||
|
if _, exist := filesMap[entryName]; !exist && shouldInclude(entry) {
|
||||||
|
filesMap[entryName] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, file := range filesMap {
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
slices.SortFunc(files, func(a, b fs.DirEntry) int { return strings.Compare(a.Name(), b.Name()) })
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ReadLayeredFile reads the named file, and returns the layer name.
|
// ReadLayeredFile reads the named file, and returns the layer name.
|
||||||
func (l *LayeredFS) ReadLayeredFile(elems ...string) ([]byte, string, error) {
|
func (l *LayeredFS) ReadLayeredFile(elems ...string) ([]byte, string, error) {
|
||||||
name := util.PathJoinRel(elems...)
|
name := util.PathJoinRel(elems...)
|
||||||
|
|||||||
+50
-17
@@ -13,6 +13,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/httplib"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/web/routing"
|
"code.gitea.io/gitea/modules/web/routing"
|
||||||
@@ -22,24 +23,29 @@ const viteDevPortFile = "public/assets/.vite/dev-port"
|
|||||||
|
|
||||||
var viteDevProxy atomic.Pointer[httputil.ReverseProxy]
|
var viteDevProxy atomic.Pointer[httputil.ReverseProxy]
|
||||||
|
|
||||||
|
func getViteDevServerBaseURL() string {
|
||||||
|
portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile)
|
||||||
|
portContent, _ := os.ReadFile(portFile)
|
||||||
|
port := strings.TrimSpace(string(portContent))
|
||||||
|
if port == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "http://localhost:" + port
|
||||||
|
}
|
||||||
|
|
||||||
func getViteDevProxy() *httputil.ReverseProxy {
|
func getViteDevProxy() *httputil.ReverseProxy {
|
||||||
if proxy := viteDevProxy.Load(); proxy != nil {
|
if proxy := viteDevProxy.Load(); proxy != nil {
|
||||||
return proxy
|
return proxy
|
||||||
}
|
}
|
||||||
|
|
||||||
portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile)
|
viteDevServerBaseURL := getViteDevServerBaseURL()
|
||||||
data, err := os.ReadFile(portFile)
|
if viteDevServerBaseURL == "" {
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
port := strings.TrimSpace(string(data))
|
|
||||||
if port == "" {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
target, err := url.Parse("http://localhost:" + port)
|
target, err := url.Parse(viteDevServerBaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to parse Vite dev server URL: %v", err)
|
log.Error("Failed to parse Vite dev server base URL %s, err: %v", viteDevServerBaseURL, err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +66,7 @@ func getViteDevProxy() *httputil.ReverseProxy {
|
|||||||
ModifyResponse: func(resp *http.Response) error {
|
ModifyResponse: func(resp *http.Response) error {
|
||||||
// add a header to indicate the Vite dev server port,
|
// add a header to indicate the Vite dev server port,
|
||||||
// make developers know that this request is proxied to Vite dev server and which port it is
|
// make developers know that this request is proxied to Vite dev server and which port it is
|
||||||
resp.Header.Add("X-Gitea-Vite-Port", port)
|
resp.Header.Add("X-Gitea-Vite-Dev-Server", viteDevServerBaseURL)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
@@ -92,19 +98,46 @@ func ViteDevMiddleware(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// isViteDevMode returns true if the Vite dev server port file exists.
|
var viteDevModeCheck atomic.Pointer[struct {
|
||||||
// In production mode, the result is cached after the first check.
|
isDev bool
|
||||||
func isViteDevMode() bool {
|
time time.Time
|
||||||
|
}]
|
||||||
|
|
||||||
|
// IsViteDevMode returns true if the Vite dev server port file exists and the server is alive
|
||||||
|
func IsViteDevMode() bool {
|
||||||
if setting.IsProd {
|
if setting.IsProd {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile)
|
|
||||||
_, err := os.Stat(portFile)
|
now := time.Now()
|
||||||
return err == nil
|
lastCheck := viteDevModeCheck.Load()
|
||||||
|
if lastCheck != nil && time.Now().Sub(lastCheck.time) < time.Second {
|
||||||
|
return lastCheck.isDev
|
||||||
|
}
|
||||||
|
|
||||||
|
viteDevServerBaseURL := getViteDevServerBaseURL()
|
||||||
|
if viteDevServerBaseURL == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httplib.NewRequest(viteDevServerBaseURL+"/web_src/js/__vite_dev_server_check", "GET")
|
||||||
|
resp, _ := req.Response()
|
||||||
|
if resp != nil {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
isDev := resp != nil && resp.StatusCode == http.StatusOK
|
||||||
|
viteDevModeCheck.Store(&struct {
|
||||||
|
isDev bool
|
||||||
|
time time.Time
|
||||||
|
}{
|
||||||
|
isDev: isDev,
|
||||||
|
time: now,
|
||||||
|
})
|
||||||
|
return isDev
|
||||||
}
|
}
|
||||||
|
|
||||||
func viteDevSourceURL(name string) string {
|
func viteDevSourceURL(name string) string {
|
||||||
if !isViteDevMode() {
|
if !IsViteDevMode() {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(name, "css/theme-") {
|
if strings.HasPrefix(name, "css/theme-") {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/public"
|
||||||
"code.gitea.io/gitea/modules/reqctx"
|
"code.gitea.io/gitea/modules/reqctx"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
)
|
)
|
||||||
@@ -36,5 +37,6 @@ func CommonTemplateContextData() reqctx.ContextData {
|
|||||||
"PageStartTime": time.Now(),
|
"PageStartTime": time.Now(),
|
||||||
|
|
||||||
"RunModeIsProd": setting.IsProd,
|
"RunModeIsProd": setting.IsProd,
|
||||||
|
"ViteModeIsDev": public.IsViteDevMode(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/gtprof"
|
"code.gitea.io/gitea/modules/gtprof"
|
||||||
"code.gitea.io/gitea/modules/httplib"
|
"code.gitea.io/gitea/modules/httplib"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
"code.gitea.io/gitea/modules/public"
|
||||||
"code.gitea.io/gitea/modules/reqctx"
|
"code.gitea.io/gitea/modules/reqctx"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/web/routing"
|
"code.gitea.io/gitea/modules/web/routing"
|
||||||
@@ -40,6 +41,10 @@ func ProtocolMiddlewares() (handlers []any) {
|
|||||||
handlers = append(handlers, context.AccessLogger())
|
handlers = append(handlers, context.AccessLogger())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !setting.IsProd {
|
||||||
|
handlers = append(handlers, public.ViteDevMiddleware)
|
||||||
|
}
|
||||||
|
|
||||||
return handlers
|
return handlers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -259,10 +259,6 @@ func Routes() *web.Router {
|
|||||||
// GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route
|
// GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route
|
||||||
routes.BeforeRouting(chi_middleware.GetHead)
|
routes.BeforeRouting(chi_middleware.GetHead)
|
||||||
|
|
||||||
if !setting.IsProd {
|
|
||||||
routes.BeforeRouting(public.ViteDevMiddleware)
|
|
||||||
}
|
|
||||||
|
|
||||||
routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler
|
routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler
|
||||||
routes.Methods("GET, HEAD, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, optionsCorsHandler(), public.FileHandlerFunc())
|
routes.Methods("GET, HEAD, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, optionsCorsHandler(), public.FileHandlerFunc())
|
||||||
routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
|
routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
|
||||||
|
|||||||
@@ -4,10 +4,14 @@
|
|||||||
package webtheme
|
package webtheme
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/container"
|
"code.gitea.io/gitea/modules/container"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
@@ -16,15 +20,15 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type themeCollection struct {
|
type themeCollectionStruct struct {
|
||||||
|
lastCheckTime time.Time
|
||||||
|
usingViteDevMode bool
|
||||||
|
|
||||||
themeList []*ThemeMetaInfo
|
themeList []*ThemeMetaInfo
|
||||||
themeMap map[string]*ThemeMetaInfo
|
themeMap map[string]*ThemeMetaInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var themeCollection atomic.Pointer[themeCollectionStruct]
|
||||||
themeMu sync.RWMutex
|
|
||||||
availableThemes *themeCollection
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
fileNamePrefix = "theme-"
|
fileNamePrefix = "theme-"
|
||||||
@@ -140,23 +144,42 @@ func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo {
|
|||||||
return themeInfo
|
return themeInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) {
|
func collectThemeFiles(dirFS fs.ReadDirFS, fsPath string) (themes []*ThemeMetaInfo, _ error) {
|
||||||
cssFiles, err := public.AssetFS().ListFiles("assets/css")
|
files, err := dirFS.ReadDir(fsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to list themes: %v", err)
|
return nil, err
|
||||||
return nil, nil
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
fileName := file.Name()
|
||||||
|
if !strings.HasPrefix(fileName, fileNamePrefix) || !strings.HasSuffix(fileName, fileNameSuffix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, err := fs.ReadFile(dirFS, path.Join(fsPath, file.Name()))
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to read theme file %q: %v", fileName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
themes = append(themes, parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content)))
|
||||||
|
}
|
||||||
|
return themes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadThemesFromAssets(isViteDevMode bool) (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) {
|
||||||
|
var themeDir fs.ReadDirFS
|
||||||
|
var themePath string
|
||||||
|
|
||||||
|
if isViteDevMode {
|
||||||
|
// In vite dev mode, Vite serves themes directly from source files.
|
||||||
|
themeDir, themePath = os.DirFS(setting.StaticRootPath).(fs.ReadDirFS), "web_src/css/themes"
|
||||||
|
} else {
|
||||||
|
// Without vite dev server, use built assets from AssetFS.
|
||||||
|
themeDir, themePath = public.AssetFS(), "assets/css"
|
||||||
}
|
}
|
||||||
|
|
||||||
var foundThemes []*ThemeMetaInfo
|
foundThemes, err := collectThemeFiles(themeDir, themePath)
|
||||||
for _, fileName := range cssFiles {
|
if err != nil {
|
||||||
if strings.HasPrefix(fileName, fileNamePrefix) && strings.HasSuffix(fileName, fileNameSuffix) {
|
log.Error("Failed to load theme files: %v", err)
|
||||||
content, err := public.AssetFS().ReadFile("/assets/css/" + fileName)
|
return themeList, themeMap
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to read theme file %q: %v", fileName, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
foundThemes = append(foundThemes, parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
themeList = foundThemes
|
themeList = foundThemes
|
||||||
@@ -187,20 +210,21 @@ func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*Th
|
|||||||
return themeList, themeMap
|
return themeList, themeMap
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAvailableThemes() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) {
|
func getAvailableThemes() *themeCollectionStruct {
|
||||||
themeMu.RLock()
|
themes := themeCollection.Load()
|
||||||
if availableThemes != nil {
|
|
||||||
themeList, themeMap = availableThemes.themeList, availableThemes.themeMap
|
now := time.Now()
|
||||||
}
|
if themes != nil && now.Sub(themes.lastCheckTime) < time.Second {
|
||||||
themeMu.RUnlock()
|
return themes
|
||||||
if len(themeList) != 0 {
|
|
||||||
return themeList, themeMap
|
|
||||||
}
|
}
|
||||||
|
|
||||||
themeMu.Lock()
|
isViteDevMode := public.IsViteDevMode()
|
||||||
defer themeMu.Unlock()
|
useLoadedThemes := themes != nil && (setting.IsProd || themes.usingViteDevMode == isViteDevMode)
|
||||||
// no need to double-check "availableThemes.themeList" since the loading isn't really slow, to keep code simple
|
if useLoadedThemes && len(themes.themeList) > 0 {
|
||||||
themeList, themeMap = loadThemesFromAssets()
|
return themes
|
||||||
|
}
|
||||||
|
|
||||||
|
themeList, themeMap := loadThemesFromAssets(isViteDevMode)
|
||||||
hasAvailableThemes := len(themeList) > 0
|
hasAvailableThemes := len(themeList) > 0
|
||||||
if !hasAvailableThemes {
|
if !hasAvailableThemes {
|
||||||
defaultTheme := defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)
|
defaultTheme := defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)
|
||||||
@@ -215,27 +239,19 @@ func getAvailableThemes() (themeList []*ThemeMetaInfo, themeMap map[string]*Them
|
|||||||
if themeMap[setting.UI.DefaultTheme] == nil {
|
if themeMap[setting.UI.DefaultTheme] == nil {
|
||||||
setting.LogStartupProblem(1, log.ERROR, "Default theme %q is not available, please correct the '[ui].DEFAULT_THEME' setting in the config file", setting.UI.DefaultTheme)
|
setting.LogStartupProblem(1, log.ERROR, "Default theme %q is not available, please correct the '[ui].DEFAULT_THEME' setting in the config file", setting.UI.DefaultTheme)
|
||||||
}
|
}
|
||||||
availableThemes = &themeCollection{themeList, themeMap}
|
|
||||||
return themeList, themeMap
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// In dev mode, only store the loaded themes if the list is not empty, in case the frontend is still being built.
|
themes = &themeCollectionStruct{now, isViteDevMode, themeList, themeMap}
|
||||||
// TBH, there still could be a data-race that the themes are only partially built then the list is incomplete for first time loading.
|
themeCollection.Store(themes)
|
||||||
// Such edge case can be handled by checking whether the loaded themes are the same in a period or there is a flag file, but it is an over-kill, so, no.
|
|
||||||
if hasAvailableThemes {
|
|
||||||
availableThemes = &themeCollection{themeList, themeMap}
|
|
||||||
}
|
|
||||||
return themeList, themeMap
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetAvailableThemes() []*ThemeMetaInfo {
|
|
||||||
themes, _ := getAvailableThemes()
|
|
||||||
return themes
|
return themes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetAvailableThemes() []*ThemeMetaInfo {
|
||||||
|
return getAvailableThemes().themeList
|
||||||
|
}
|
||||||
|
|
||||||
func GetThemeMetaInfo(internalName string) *ThemeMetaInfo {
|
func GetThemeMetaInfo(internalName string) *ThemeMetaInfo {
|
||||||
_, themeMap := getAvailableThemes()
|
return getAvailableThemes().themeMap[internalName]
|
||||||
return themeMap[internalName]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GuaranteeGetThemeMetaInfo guarantees to return a non-nil ThemeMetaInfo,
|
// GuaranteeGetThemeMetaInfo guarantees to return a non-nil ThemeMetaInfo,
|
||||||
|
|||||||
@@ -4,17 +4,22 @@
|
|||||||
<a target="_blank" href="https://about.gitea.com">{{ctx.Locale.Tr "powered_by" "Gitea"}}</a>
|
<a target="_blank" href="https://about.gitea.com">{{ctx.Locale.Tr "powered_by" "Gitea"}}</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if (or .ShowFooterVersion .PageIsAdmin)}}
|
{{if (or .ShowFooterVersion .PageIsAdmin)}}
|
||||||
|
<span>
|
||||||
{{ctx.Locale.Tr "version"}}:
|
{{ctx.Locale.Tr "version"}}:
|
||||||
{{if .IsAdmin}}
|
{{if .IsAdmin}}
|
||||||
<a href="{{AppSubUrl}}/-/admin/config">{{AppVer}}</a>
|
<a href="{{AppSubUrl}}/-/admin/config">{{AppVer}}</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{AppVer}}
|
{{AppVer}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if and .TemplateLoadTimes ShowFooterTemplateLoadTime}}
|
{{if and .TemplateLoadTimes ShowFooterTemplateLoadTime}}
|
||||||
{{ctx.Locale.Tr "page"}}: <strong>{{LoadTimes .PageStartTime}}</strong>
|
<span>
|
||||||
{{ctx.Locale.Tr "template"}}{{if .TemplateName}} {{.TemplateName}}{{end}}: <strong>{{call .TemplateLoadTimes}}</strong>
|
{{ctx.Locale.Tr "page"}}: <strong>{{LoadTimes .PageStartTime}}</strong>
|
||||||
|
{{ctx.Locale.Tr "template"}}{{if .TemplateName}} {{.TemplateName}}{{end}}: <strong>{{call .TemplateLoadTimes}}</strong>
|
||||||
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if $.ViteModeIsDev}}<span class="ui basic label primary">ViteDevMode</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="right-links" role="group" aria-label="{{ctx.Locale.Tr "aria.footer.links"}}">
|
<div class="right-links" role="group" aria-label="{{ctx.Locale.Tr "aria.footer.links"}}">
|
||||||
<div class="ui dropdown custom" id="footer-theme-selector">
|
<div class="ui dropdown custom" id="footer-theme-selector">
|
||||||
|
|||||||
+6
-3
@@ -1,8 +1,8 @@
|
|||||||
import {build, defineConfig} from 'vite';
|
import {build, defineConfig} from 'vite';
|
||||||
import vuePlugin from '@vitejs/plugin-vue';
|
import vuePlugin from '@vitejs/plugin-vue';
|
||||||
import {stringPlugin} from 'vite-string-plugin';
|
import {stringPlugin} from 'vite-string-plugin';
|
||||||
import {readFileSync, writeFileSync, unlinkSync, globSync} from 'node:fs';
|
import {readFileSync, writeFileSync, mkdirSync, unlinkSync, globSync} from 'node:fs';
|
||||||
import {join, parse} from 'node:path';
|
import path, {join, parse} from 'node:path';
|
||||||
import {env} from 'node:process';
|
import {env} from 'node:process';
|
||||||
import tailwindcss from 'tailwindcss';
|
import tailwindcss from 'tailwindcss';
|
||||||
import tailwindConfig from './tailwind.config.ts';
|
import tailwindConfig from './tailwind.config.ts';
|
||||||
@@ -132,7 +132,9 @@ function iifePlugin(): Plugin {
|
|||||||
server.middlewares.use((req, res, next) => {
|
server.middlewares.use((req, res, next) => {
|
||||||
// "__vite_iife" is a virtual file in memory, serve it directly
|
// "__vite_iife" is a virtual file in memory, serve it directly
|
||||||
const pathname = req.url!.split('?')[0];
|
const pathname = req.url!.split('?')[0];
|
||||||
if (pathname === '/web_src/js/__vite_iife.js') {
|
if (pathname === '/web_src/js/__vite_dev_server_check') {
|
||||||
|
res.end('ok');
|
||||||
|
} else if (pathname === '/web_src/js/__vite_iife.js') {
|
||||||
res.setHeader('Content-Type', 'application/javascript');
|
res.setHeader('Content-Type', 'application/javascript');
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
res.end(iifeCode);
|
res.end(iifeCode);
|
||||||
@@ -198,6 +200,7 @@ function viteDevServerPortPlugin(): Plugin {
|
|||||||
server.httpServer!.once('listening', () => {
|
server.httpServer!.once('listening', () => {
|
||||||
const addr = server.httpServer!.address();
|
const addr = server.httpServer!.address();
|
||||||
if (typeof addr === 'object' && addr) {
|
if (typeof addr === 'object' && addr) {
|
||||||
|
mkdirSync(path.dirname(viteDevPortFilePath), {recursive: true});
|
||||||
writeFileSync(viteDevPortFilePath, String(addr.port));
|
writeFileSync(viteDevPortFilePath, String(addr.port));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.25em;
|
gap: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-footer .right-links {
|
.page-footer .right-links {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
}
|
}
|
||||||
#navbar .navbar-mobile-right {
|
#navbar .navbar-mobile-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
margin: 0 0 0 auto;
|
margin: 0 0 0 auto;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
@@ -97,7 +98,6 @@
|
|||||||
}
|
}
|
||||||
#navbar.navbar-menu-open .navbar-left .navbar-mobile-right {
|
#navbar.navbar-menu-open .navbar-left .navbar-mobile-right {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
width: 50%;
|
|
||||||
min-height: 49px;
|
min-height: 49px;
|
||||||
}
|
}
|
||||||
#navbar #mobile-stopwatch-icon,
|
#navbar #mobile-stopwatch-icon,
|
||||||
|
|||||||
Reference in New Issue
Block a user