diff --git a/modules/setting/config.go b/modules/setting/config.go index fb99325a95..59f046e88d 100644 --- a/modules/setting/config.go +++ b/modules/setting/config.go @@ -4,6 +4,7 @@ package setting import ( + "context" "strings" "sync" @@ -53,9 +54,72 @@ type RepositoryStruct struct { GitGuideRemoteName *config.Value[string] } +const ( + InstanceNoticeLevelInfo = "info" + InstanceNoticeLevelSuccess = "success" + InstanceNoticeLevelWarning = "warning" + InstanceNoticeLevelDanger = "danger" +) + +type InstanceNotice struct { + Enabled bool + Message string + Level string + ShowIcon bool + + StartTime int64 + EndTime int64 +} + +func DefaultInstanceNotice() InstanceNotice { + return InstanceNotice{ + Level: InstanceNoticeLevelInfo, + ShowIcon: true, + } +} + +func IsValidInstanceNoticeLevel(level string) bool { + switch level { + case InstanceNoticeLevelInfo, InstanceNoticeLevelSuccess, InstanceNoticeLevelWarning, InstanceNoticeLevelDanger: + return true + default: + return false + } +} + +func (n *InstanceNotice) Normalize() { + if !IsValidInstanceNoticeLevel(n.Level) { + n.Level = InstanceNoticeLevelInfo + } +} + +func (n *InstanceNotice) IsActive(now int64) bool { + if !n.Enabled || n.Message == "" { + return false + } + if n.StartTime > 0 && now < n.StartTime { + return false + } + if n.EndTime > 0 && now > n.EndTime { + return false + } + return true +} + +func GetInstanceNotice(ctx context.Context) InstanceNotice { + notice := Config().InstanceNotice.Banner.Value(ctx) + notice.Normalize() + return notice +} + +type InstanceNoticeStruct struct { + Banner *config.Value[InstanceNotice] +} + type ConfigStruct struct { - Picture *PictureStruct - Repository *RepositoryStruct + Picture *PictureStruct + Repository *RepositoryStruct + InstanceNotice *InstanceNoticeStruct } var ( @@ -74,6 +138,9 @@ func initDefaultConfig() { OpenWithEditorApps: config.ValueJSON[OpenWithEditorAppsType]("repository.open-with.editor-apps"), GitGuideRemoteName: config.ValueJSON[string]("repository.git-guide-remote-name").WithDefault("origin"), }, + InstanceNotice: &InstanceNoticeStruct{ + Banner: config.ValueJSON[InstanceNotice]("instance.notice").WithDefault(DefaultInstanceNotice()), + }, } } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 93ac046612..01be25b2fa 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3277,6 +3277,29 @@ "admin.config.cache_test_failed": "Failed to probe the cache: %v.", "admin.config.cache_test_slow": "Cache test successful, but response is slow: %s.", "admin.config.cache_test_succeeded": "Cache test successful, got a response in %s.", + "admin.config.instance_notice": "Instance Banner", + "admin.config.instance_notice.settings": "Settings", + "admin.config.instance_notice.enabled": "Enable banner", + "admin.config.instance_notice.message": "Message", + "admin.config.instance_notice.message_placeholder": "This message supports Markdown.", + "admin.config.instance_notice.message_required": "Message is required when the banner is enabled.", + "admin.config.instance_notice.level": "Level", + "admin.config.instance_notice.level.info": "Info", + "admin.config.instance_notice.level.success": "Success", + "admin.config.instance_notice.level.warning": "Warning", + "admin.config.instance_notice.level.danger": "Danger", + "admin.config.instance_notice.show_icon": "Show icon", + "admin.config.instance_notice.start_time": "Start time", + "admin.config.instance_notice.end_time": "End time", + "admin.config.instance_notice.invalid_time": "Invalid date-time value. Use the picker format.", + "admin.config.instance_notice.invalid_time_range": "End time must be after start time.", + "admin.config.instance_notice.markdown_hint": "This banner is informational only and does not block access.", + "admin.config.instance_notice.preview": "Preview", + "admin.config.instance_notice.save": "Save banner", + "admin.config.instance_notice.save_success": "Instance banner settings updated.", + "admin.config.instance_notice.delete": "Delete banner", + "admin.config.instance_notice.delete_success": "Instance banner deleted.", + "admin.config.instance_notice.edit_hint": "Edit this banner", "admin.config.session_config": "Session Configuration", "admin.config.session_provider": "Session Provider", "admin.config.provider_config": "Provider Config", diff --git a/routers/common/pagetmpl.go b/routers/common/pagetmpl.go index c48596d48b..b5a12a5334 100644 --- a/routers/common/pagetmpl.go +++ b/routers/common/pagetmpl.go @@ -12,6 +12,8 @@ import ( "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/services/context" ) @@ -69,14 +71,50 @@ type pageGlobalDataType struct { IsSigned bool IsSiteAdmin bool + InstanceNoticeBanner *InstanceNoticeBannerTmplInfo + GetNotificationUnreadCount func() int64 GetActiveStopwatch func() *StopwatchTmplInfo } +type InstanceNoticeBannerTmplInfo struct { + Message string + Level string + ShowIcon bool + IconName string +} + +func instanceNoticeIconName(level string) string { + switch level { + case setting.InstanceNoticeLevelSuccess: + return "octicon-check-circle" + case setting.InstanceNoticeLevelWarning: + return "octicon-alert" + case setting.InstanceNoticeLevelDanger: + return "octicon-stop" + default: + return "octicon-info" + } +} + +func getInstanceNoticeBanner(ctx *context.Context) *InstanceNoticeBannerTmplInfo { + notice := setting.GetInstanceNotice(ctx) + if !notice.IsActive(int64(timeutil.TimeStampNow())) { + return nil + } + return &InstanceNoticeBannerTmplInfo{ + Message: notice.Message, + Level: notice.Level, + ShowIcon: notice.ShowIcon, + IconName: instanceNoticeIconName(notice.Level), + } +} + func PageGlobalData(ctx *context.Context) { var data pageGlobalDataType data.IsSigned = ctx.Doer != nil data.IsSiteAdmin = ctx.Doer != nil && ctx.Doer.IsAdmin + data.InstanceNoticeBanner = getInstanceNoticeBanner(ctx) data.GetNotificationUnreadCount = sync.OnceValue(func() int64 { return notificationUnreadCount(ctx) }) data.GetActiveStopwatch = sync.OnceValue(func() *StopwatchTmplInfo { return getActiveStopwatch(ctx) }) ctx.Data["PageGlobalData"] = data diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index 774b31ab98..cc9e04a670 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -9,6 +9,7 @@ import ( "net/url" "strconv" "strings" + "time" system_model "code.gitea.io/gitea/models/system" "code.gitea.io/gitea/modules/cache" @@ -145,6 +146,14 @@ func Config(ctx *context.Context) { ctx.Data["Service"] = setting.Service ctx.Data["DbCfg"] = setting.Database ctx.Data["Webhook"] = setting.Webhook + instanceNotice := setting.GetInstanceNotice(ctx) + ctx.Data["InstanceNotice"] = instanceNotice + if instanceNotice.StartTime > 0 { + ctx.Data["InstanceNoticeStartTime"] = time.Unix(instanceNotice.StartTime, 0).In(setting.DefaultUILocation).Format("2006-01-02T15:04") + } + if instanceNotice.EndTime > 0 { + ctx.Data["InstanceNoticeEndTime"] = time.Unix(instanceNotice.EndTime, 0).In(setting.DefaultUILocation).Format("2006-01-02T15:04") + } ctx.Data["MailerEnabled"] = false if setting.MailService != nil { @@ -187,6 +196,92 @@ func Config(ctx *context.Context) { ctx.HTML(http.StatusOK, tplConfig) } +func parseDatetimeLocalValue(raw string) (int64, error) { + if raw == "" { + return 0, nil + } + tm, err := time.ParseInLocation("2006-01-02T15:04", raw, setting.DefaultUILocation) + if err != nil { + return 0, err + } + return tm.Unix(), nil +} + +func SetInstanceNotice(ctx *context.Context) { + saveInstanceNotice := func(instanceNotice setting.InstanceNotice) { + instanceNotice.Normalize() + marshaled, err := json.Marshal(instanceNotice) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + if err := system_model.SetSettings(ctx, map[string]string{ + setting.Config().InstanceNotice.Banner.DynKey(): string(marshaled), + }); err != nil { + ctx.ServerError("SetSettings", err) + return + } + config.GetDynGetter().InvalidateCache() + } + + if ctx.FormString("action") == "delete" { + saveInstanceNotice(setting.DefaultInstanceNotice()) + if ctx.Written() { + return + } + ctx.Flash.Success(ctx.Tr("admin.config.instance_notice.delete_success")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice") + return + } + + enabled := ctx.FormBool("enabled") + message := strings.TrimSpace(ctx.FormString("message")) + level := strings.TrimSpace(ctx.FormString("level")) + showIcon := ctx.FormBool("show_icon") + startTime, err := parseDatetimeLocalValue(strings.TrimSpace(ctx.FormString("start_time"))) + if err != nil { + ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.invalid_time")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice") + return + } + endTime, err := parseDatetimeLocalValue(strings.TrimSpace(ctx.FormString("end_time"))) + if err != nil { + ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.invalid_time")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice") + return + } + if !setting.IsValidInstanceNoticeLevel(level) { + level = setting.InstanceNoticeLevelInfo + } + if enabled && message == "" { + ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.message_required")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice") + return + } + if startTime > 0 && endTime > 0 && endTime < startTime { + ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.invalid_time_range")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice") + return + } + + instanceNotice := setting.InstanceNotice{ + Enabled: enabled, + Message: message, + Level: level, + ShowIcon: showIcon, + StartTime: startTime, + EndTime: endTime, + } + + saveInstanceNotice(instanceNotice) + if ctx.Written() { + return + } + + ctx.Flash.Success(ctx.Tr("admin.config.instance_notice.save_success")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice") +} + func ConfigSettings(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.config_settings") ctx.Data["PageIsAdminConfig"] = true diff --git a/routers/web/web.go b/routers/web/web.go index 9e6354e138..4e991ac51b 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -717,6 +717,7 @@ func registerWebRoutes(m *web.Router) { m.Group("/config", func() { m.Get("", admin.Config) m.Post("", admin.ChangeConfig) + m.Post("/instance_notice", admin.SetInstanceNotice) m.Post("/test_mail", admin.SendTestMail) m.Post("/test_cache", admin.TestCache) m.Get("/settings", admin.ConfigSettings) diff --git a/templates/admin/config.tmpl b/templates/admin/config.tmpl index 728746713c..940b7ec65f 100644 --- a/templates/admin/config.tmpl +++ b/templates/admin/config.tmpl @@ -265,6 +265,79 @@ +

+ {{ctx.Locale.Tr "admin.config.instance_notice"}} +

+
+
+
{{ctx.Locale.Tr "admin.config.instance_notice.settings"}}
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+

{{ctx.Locale.Tr "admin.config.instance_notice.markdown_hint"}}

+ +
+ +
+
+
+ {{if eq .InstanceNotice.Level "success"}}{{svg "octicon-check-circle"}}{{else if eq .InstanceNotice.Level "warning"}}{{svg "octicon-alert"}}{{else if eq .InstanceNotice.Level "danger"}}{{svg "octicon-stop"}}{{else}}{{svg "octicon-info"}}{{end}} +
+
{{ctx.RenderUtils.MarkdownToHtml .InstanceNotice.Message}}
+
+
+
+ {{svg "octicon-info"}} + {{svg "octicon-check-circle"}} + {{svg "octicon-alert"}} + {{svg "octicon-stop"}} +
+
+ + +
+
+ + +
+
+
+
+

{{ctx.Locale.Tr "admin.config.session_config"}}

diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index cda1f377b4..2144851e66 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -176,3 +176,19 @@ {{end}} +{{if and .PageGlobalData .PageGlobalData.InstanceNoticeBanner}} + {{$banner := .PageGlobalData.InstanceNoticeBanner}} +
+
+ {{if $banner.ShowIcon}} + {{svg $banner.IconName}} + {{end}} +
{{ctx.RenderUtils.MarkdownToHtml $banner.Message}}
+
+ {{if .PageGlobalData.IsSiteAdmin}} +
+ {{ctx.Locale.Tr "admin.config.instance_notice.edit_hint"}} +
+ {{end}} +
+{{end}} diff --git a/tests/integration/instance_notice_test.go b/tests/integration/instance_notice_test.go new file mode 100644 index 0000000000..2e5235d9d6 --- /dev/null +++ b/tests/integration/instance_notice_test.go @@ -0,0 +1,119 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + "time" + + system_model "code.gitea.io/gitea/models/system" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/setting/config" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstanceNoticeVisibility(t *testing.T) { + defer tests.PrepareTestEnv(t)() + setInstanceNoticeForTest(t, setting.DefaultInstanceNotice()) + + setInstanceNoticeForTest(t, setting.InstanceNotice{ + Enabled: true, + Message: "Planned **upgrade** in progress.", + Level: setting.InstanceNoticeLevelWarning, + ShowIcon: true, + }) + + t.Run("AnonymousUserSeesBanner", func(t *testing.T) { + resp := MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK) + assert.Contains(t, resp.Body.String(), "Planned upgrade in progress.") + }) + + t.Run("NormalUserSeesBanner", func(t *testing.T) { + sess := loginUser(t, "user2") + resp := sess.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusOK) + assert.Contains(t, resp.Body.String(), "Planned upgrade in progress.") + }) + + t.Run("AdminSeesBannerAndEditHint", func(t *testing.T) { + sess := loginUser(t, "user1") + resp := sess.MakeRequest(t, NewRequest(t, "GET", "/-/admin"), http.StatusOK) + assert.Contains(t, resp.Body.String(), "Planned upgrade in progress.") + assert.Contains(t, resp.Body.String(), "Edit this banner") + }) + + t.Run("APIRequestUnchanged", func(t *testing.T) { + MakeRequest(t, NewRequest(t, "GET", "/api/v1/version"), http.StatusOK) + }) +} + +func TestInstanceNoticeTimeWindow(t *testing.T) { + defer tests.PrepareTestEnv(t)() + setInstanceNoticeForTest(t, setting.DefaultInstanceNotice()) + + now := time.Now().Unix() + setInstanceNoticeForTest(t, setting.InstanceNotice{ + Enabled: true, + Message: "Future banner", + Level: setting.InstanceNoticeLevelInfo, + StartTime: now + 3600, + EndTime: now + 7200, + }) + + resp := MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK) + assert.NotContains(t, resp.Body.String(), "Future banner") + + setInstanceNoticeForTest(t, setting.InstanceNotice{ + Enabled: true, + Message: "Expired banner", + Level: setting.InstanceNoticeLevelInfo, + StartTime: now - 7200, + EndTime: now - 3600, + }) + + resp = MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK) + assert.NotContains(t, resp.Body.String(), "Expired banner") +} + +func TestInstanceNoticeAdminCRUD(t *testing.T) { + defer tests.PrepareTestEnv(t)() + setInstanceNoticeForTest(t, setting.DefaultInstanceNotice()) + + adminSession := loginUser(t, "user1") + req := NewRequestWithValues(t, "POST", "/-/admin/config/instance_notice", map[string]string{ + "enabled": "true", + "message": "Admin set banner", + "level": "danger", + "show_icon": "true", + }) + adminSession.MakeRequest(t, req, http.StatusSeeOther) + + notice := setting.GetInstanceNotice(t.Context()) + assert.True(t, notice.Enabled) + assert.True(t, notice.ShowIcon) + assert.Equal(t, "Admin set banner", notice.Message) + assert.Equal(t, setting.InstanceNoticeLevelDanger, notice.Level) + + req = NewRequestWithValues(t, "POST", "/-/admin/config/instance_notice", map[string]string{ + "action": "delete", + }) + adminSession.MakeRequest(t, req, http.StatusSeeOther) + + notice = setting.GetInstanceNotice(t.Context()) + assert.Equal(t, setting.DefaultInstanceNotice(), notice) +} + +func setInstanceNoticeForTest(t *testing.T, notice setting.InstanceNotice) { + t.Helper() + marshaled, err := json.Marshal(notice) + require.NoError(t, err) + require.NoError(t, system_model.SetSettings(t.Context(), map[string]string{ + setting.Config().InstanceNotice.Banner.DynKey(): string(marshaled), + })) + config.GetDynGetter().InvalidateCache() +} diff --git a/web_src/js/features/admin/config.test.ts b/web_src/js/features/admin/config.test.ts new file mode 100644 index 0000000000..5e303df5b7 --- /dev/null +++ b/web_src/js/features/admin/config.test.ts @@ -0,0 +1,136 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest'; +import {initAdminConfigs} from './config.ts'; +import {POST} from '../../modules/fetch.ts'; + +vi.mock('../../modules/fetch.ts', () => ({ + POST: vi.fn(), +})); + +vi.mock('../../modules/tippy.ts', () => ({ + showTemporaryTooltip: vi.fn(), +})); + +function createPreviewDOM() { + document.body.innerHTML = ` +
+
+ + + +
+
+
+
+
+
+ + + + +
+
+ `; +} + +describe('Admin Instance Notice Preview', () => { + beforeEach(() => { + vi.clearAllMocks(); + createPreviewDOM(); + }); + + test('renders markdown preview on input', async () => { + vi.mocked(POST).mockResolvedValue({ + text: async () => '

Rendered message

', + } as Response); + + initAdminConfigs(); + + const messageInput = document.querySelector('textarea[name="message"]')!; + messageInput.value = 'Updated message'; + messageInput.dispatchEvent(new Event('input')); + + await Promise.resolve(); + await Promise.resolve(); + + expect(POST).toHaveBeenCalledWith('/-/markup', expect.objectContaining({ + data: expect.any(FormData), + })); + + const formData = vi.mocked(POST).mock.calls[0][1]?.data as FormData; + expect(formData.get('mode')).toBe('comment'); + expect(formData.get('text')).toBe('Updated message'); + + const previewContent = document.querySelector('#instance-notice-preview-content')!; + expect(previewContent.innerHTML).toContain('Rendered message'); + }); + + test('updates preview class and icon when level and icon toggle change', () => { + initAdminConfigs(); + + const levelSelect = document.querySelector('select[name="level"]')!; + const showIcon = document.querySelector('input[name="show_icon"]')!; + const preview = document.querySelector('#instance-notice-preview')!; + const previewIcon = document.querySelector('#instance-notice-preview-icon')!; + + levelSelect.value = 'danger'; + levelSelect.dispatchEvent(new Event('change')); + expect(preview.classList.contains('negative')).toBe(true); + expect(previewIcon.innerHTML).toContain('data-icon="danger"'); + + showIcon.checked = false; + showIcon.dispatchEvent(new Event('change')); + expect(previewIcon.classList.contains('tw-hidden')).toBe(true); + }); + + test('queues a second render while first request is in flight and re-renders with latest text', async () => { + let firstResolve: ((value: Response) => void) | undefined; + const firstPending = new Promise((resolve) => { + firstResolve = resolve; + }); + + vi.mocked(POST) + .mockImplementationOnce(async () => await firstPending) + .mockResolvedValueOnce({ + text: async () => '

Second render

', + } as Response); + + initAdminConfigs(); + + const messageInput = document.querySelector('textarea[name="message"]')!; + + messageInput.value = 'First value'; + messageInput.dispatchEvent(new Event('input')); + + await Promise.resolve(); + + messageInput.value = 'Latest value'; + messageInput.dispatchEvent(new Event('input')); + + firstResolve?.({ + text: async () => '

First render

', + } as Response); + + for (let i = 0; i < 10 && vi.mocked(POST).mock.calls.length < 2; i++) { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } + + expect(POST).toHaveBeenCalledTimes(2); + const secondData = vi.mocked(POST).mock.calls[1][1]?.data as FormData; + expect(secondData.get('text')).toBe('Latest value'); + + const previewContent = document.querySelector('#instance-notice-preview-content')!; + for (let i = 0; i < 10 && !previewContent.innerHTML.includes('Second render'); i++) { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } + expect(previewContent.innerHTML).toContain('Second render'); + }); +}); diff --git a/web_src/js/features/admin/config.ts b/web_src/js/features/admin/config.ts index 76f7c1db50..7f00cd611c 100644 --- a/web_src/js/features/admin/config.ts +++ b/web_src/js/features/admin/config.ts @@ -1,8 +1,78 @@ import {showTemporaryTooltip} from '../../modules/tippy.ts'; import {POST} from '../../modules/fetch.ts'; +import {html, htmlRaw} from '../../utils/html.ts'; const {appSubUrl} = window.config; +function initInstanceNoticePreview(elAdminConfig: HTMLDivElement): void { + const form = elAdminConfig.querySelector('form[action$="/-/admin/config/instance_notice"]'); + if (!form) return; + + const inputMessage = form.querySelector('textarea[name="message"]'); + const selectLevel = form.querySelector('select[name="level"]'); + const inputShowIcon = form.querySelector('input[name="show_icon"]'); + const preview = elAdminConfig.querySelector('#instance-notice-preview'); + const previewIcon = elAdminConfig.querySelector('#instance-notice-preview-icon'); + const previewContent = elAdminConfig.querySelector('#instance-notice-preview-content'); + const iconContainer = elAdminConfig.querySelector('#instance-notice-preview-icons'); + if (!inputMessage || !selectLevel || !inputShowIcon || !preview || !previewIcon || !previewContent || !iconContainer) return; + + const iconHTMLByLevel = new Map(); + for (const el of iconContainer.querySelectorAll('[data-level]')) { + iconHTMLByLevel.set(el.getAttribute('data-level')!, el.innerHTML); + } + + const classByLevel: Record = { + info: 'info', + success: 'positive', + warning: 'warning', + danger: 'negative', + }; + + const updateStyle = () => { + preview.classList.remove('info', 'positive', 'warning', 'negative'); + preview.classList.add(classByLevel[selectLevel.value] || 'info'); + previewIcon.innerHTML = iconHTMLByLevel.get(selectLevel.value) || iconHTMLByLevel.get('info') || ''; + previewIcon.classList.toggle('tw-hidden', !inputShowIcon.checked); + }; + + let renderRequesting = false; + let pendingRender = false; + const renderPreviewMarkdown = async () => { + if (renderRequesting) { + pendingRender = true; + return; + } + renderRequesting = true; + try { + while (true) { + pendingRender = false; + const formData = new FormData(); + formData.append('mode', 'comment'); + formData.append('text', inputMessage.value); + try { + const response = await POST(`${appSubUrl}/-/markup`, {data: formData}); + const rendered = await response.text(); + previewContent.innerHTML = html`${htmlRaw(rendered)}`; + } catch (error) { + console.error('Error rendering instance notice preview:', error); + } + if (!pendingRender) break; + } + } finally { + renderRequesting = false; + } + }; + + inputMessage.addEventListener('input', () => { + renderPreviewMarkdown(); + }); + selectLevel.addEventListener('change', updateStyle); + inputShowIcon.addEventListener('change', updateStyle); + + updateStyle(); +} + export function initAdminConfigs(): void { const elAdminConfig = document.querySelector('.page-content.admin.config'); if (!elAdminConfig) return; @@ -21,4 +91,6 @@ export function initAdminConfigs(): void { } }); } + + initInstanceNoticePreview(elAdminConfig); }