Draft: First draft of new fiber based server #7
11 changed files with 213 additions and 221 deletions
29
api/views.go
29
api/views.go
|
@ -1,13 +1,9 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"hash/fnv"
|
||||
"io"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"github.com/baez90/goveal/config"
|
||||
|
@ -15,13 +11,6 @@ import (
|
|||
"github.com/baez90/goveal/rendering"
|
||||
)
|
||||
|
||||
const (
|
||||
parserExtensions = parser.NoIntraEmphasis | parser.Tables | parser.FencedCode |
|
||||
parser.Autolink | parser.Strikethrough | parser.SpaceHeadings | parser.HeadingIDs |
|
||||
parser.BackslashLineBreak | parser.DefinitionLists | parser.MathJax | parser.Titleblock |
|
||||
parser.OrderedListStart | parser.Attributes
|
||||
)
|
||||
|
||||
type Views struct {
|
||||
cfg *config.Components
|
||||
wdfs fs.FS
|
||||
|
@ -54,20 +43,14 @@ func (p *Views) RenderedMarkdown(ctx *fiber.Ctx) (err error) {
|
|||
return err
|
||||
}
|
||||
|
||||
mdParser := parser.NewWithExtensions(parserExtensions)
|
||||
rr := &rendering.RevealRenderer{
|
||||
StateMachine: rendering.NewStateMachine("***", "---"),
|
||||
Hash: fnv.New32a(),
|
||||
}
|
||||
renderer := html.NewRenderer(html.RendererOptions{
|
||||
Flags: html.CommonFlags | html.HrefTargetBlank,
|
||||
RenderNodeHook: rr.RenderHook,
|
||||
})
|
||||
|
||||
ctx.Append(fiber.HeaderContentType, fiber.MIMETextHTML)
|
||||
if _, err = ctx.Write(markdown.ToHTML(data, mdParser, renderer)); err != nil {
|
||||
var rendered []byte
|
||||
if rendered, err = rendering.ToHTML(string(data), p.cfg.Rendering); err != nil {
|
||||
return err
|
||||
} else if _, err = ctx.Write(rendered); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.Append(fiber.HeaderContentType, fiber.MIMETextHTML)
|
||||
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -13,6 +13,10 @@ var defaults = map[string]interface{}{
|
|||
"slideNumber": true,
|
||||
"menu.numbers": true,
|
||||
"menu.useTextContentForMissingTitles": true,
|
||||
"menu.transitions": true,
|
||||
"menu.hideMissingTitles": true,
|
||||
"menu.markers": true,
|
||||
"menu.openButton": true,
|
||||
}
|
||||
|
||||
const (
|
||||
|
@ -51,7 +55,10 @@ type (
|
|||
Menu struct {
|
||||
Numbers bool `json:"numbers"`
|
||||
UseTextContentForMissingTitles bool `json:"useTextContentForMissingTitles"`
|
||||
Transitions bool
|
||||
Transitions bool `json:"transitions"`
|
||||
HideMissingTitles bool `json:"hideMissingTitles"`
|
||||
Markers bool `json:"markers"`
|
||||
OpenButton bool `json:"openButton"`
|
||||
} `json:"menu"`
|
||||
}
|
||||
Components struct {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
theme: night
|
||||
codeTheme: monokai
|
||||
horizontalSeparator: ===
|
||||
verticalSeparator: ---
|
||||
horizontalSeparator: ---
|
||||
verticalSeparator: '\*\*\*'
|
||||
transition: convex
|
||||
controlsLayout: edges
|
||||
controls: true
|
||||
|
@ -12,6 +12,10 @@ slideNumber: true
|
|||
menu:
|
||||
numbers: false
|
||||
useTextContentForMissingTitles: true
|
||||
transitions: true
|
||||
hideMissingTitles: false
|
||||
markers: true
|
||||
openButton: true
|
||||
mermaid:
|
||||
theme: forest
|
||||
stylesheets:
|
||||
|
|
|
@ -91,6 +91,7 @@ for (var j = 0; j < i; j++) {
|
|||
### Mermaid
|
||||
|
||||
```mermaid
|
||||
%%{init: {'theme': 'dark'}}%%
|
||||
flowchart LR
|
||||
a --> b & c--> d
|
||||
```
|
||||
|
@ -102,7 +103,7 @@ flowchart LR
|
|||
{line-numbers="1-2|3|4"}
|
||||
|
||||
```js
|
||||
let a = 1;
|
||||
let a = 1;
|
||||
let b = 2;
|
||||
let c = x => 1 + 2 + x;
|
||||
c(3);
|
||||
|
|
90
rendering/html.go
Normal file
90
rendering/html.go
Normal file
|
@ -0,0 +1,90 @@
|
|||
package rendering
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"regexp"
|
||||
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
|
||||
"github.com/baez90/goveal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
parserExtensions = parser.NoIntraEmphasis | parser.Tables | parser.FencedCode |
|
||||
parser.Autolink | parser.Strikethrough | parser.SpaceHeadings | parser.HeadingIDs |
|
||||
parser.BackslashLineBreak | parser.DefinitionLists | parser.MathJax | parser.Titleblock |
|
||||
parser.OrderedListStart | parser.Attributes
|
||||
)
|
||||
|
||||
func ToHTML(markdown string, renderCfg config.Rendering) (rendered []byte, err error) {
|
||||
var slides []rawSlide
|
||||
if slides, err = splitIntoRawSlides(markdown, renderCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
buf := templateRenderBufferPool.Get().(*bytes.Buffer)
|
||||
defer func() {
|
||||
buf.Reset()
|
||||
templateRenderBufferPool.Put(buf)
|
||||
}()
|
||||
|
||||
for idx := range slides {
|
||||
if rendered, err := slides[idx].ToHTML(); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
buf.WriteString(string(rendered))
|
||||
}
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type rawSlide struct {
|
||||
Content string
|
||||
Children []rawSlide
|
||||
}
|
||||
|
||||
func (s rawSlide) HasNotes() bool {
|
||||
return notesLineRegexp.MatchString(s.Content)
|
||||
}
|
||||
|
||||
func (s rawSlide) ToHTML() (template.HTML, error) {
|
||||
if rendered, err := renderTemplate("slide.gohtml", s); err != nil {
|
||||
return "", err
|
||||
} else {
|
||||
//nolint:gosec // should not be sanitized
|
||||
return template.HTML(rendered), nil
|
||||
}
|
||||
}
|
||||
|
||||
func splitIntoRawSlides(markdown string, renderCfg config.Rendering) ([]rawSlide, error) {
|
||||
var (
|
||||
verticalSplit, horizontalSplit *regexp.Regexp
|
||||
err error
|
||||
)
|
||||
if verticalSplit, err = regexp.Compile(fmt.Sprintf(splitFormat, renderCfg.VerticalSeparator)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if horizontalSplit, err = regexp.Compile(fmt.Sprintf(splitFormat, renderCfg.HorizontalSeparator)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
horizontalSlides := horizontalSplit.Split(markdown, -1)
|
||||
slides := make([]rawSlide, 0, len(horizontalSlides))
|
||||
for _, hs := range horizontalSlides {
|
||||
s := rawSlide{
|
||||
Content: hs,
|
||||
}
|
||||
verticalSlides := verticalSplit.Split(hs, -1)
|
||||
s.Children = make([]rawSlide, 0, len(verticalSlides))
|
||||
for _, vs := range verticalSlides {
|
||||
s.Children = append(s.Children, rawSlide{Content: vs})
|
||||
}
|
||||
slides = append(slides, s)
|
||||
}
|
||||
|
||||
return slides, nil
|
||||
}
|
19
rendering/patterns.go
Normal file
19
rendering/patterns.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
package rendering
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const (
|
||||
// language=regexp
|
||||
notesRegex = `(?i)notes?:`
|
||||
// language=regexp
|
||||
splitFormat = `\r?\n%s\r?\n`
|
||||
)
|
||||
|
||||
var (
|
||||
htmlElementAttributesRegexp = regexp.MustCompile(`(?P<key>[a-z]+(-[a-z]+)*)="(?P<value>.+)"`)
|
||||
notesRegexp = regexp.MustCompile(fmt.Sprintf(`^%s`, notesRegex))
|
||||
notesLineRegexp = regexp.MustCompile(fmt.Sprintf(`\r?\n%s\r?\n`, notesRegex))
|
||||
)
|
|
@ -2,65 +2,26 @@ package rendering
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"hash"
|
||||
"html"
|
||||
"html/template"
|
||||
"io"
|
||||
"path"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed templates/*.gohtml
|
||||
templatesFS embed.FS
|
||||
templates *template.Template
|
||||
templateRenderBufferPool = &sync.Pool{
|
||||
New: func() interface{} {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
htmlElementAttributesRegexp = regexp.MustCompile(`(?P<key>[a-z]+(-[a-z]+)*)="(?P<value>.+)"`)
|
||||
notesRegexp = regexp.MustCompile(`^(?i)notes?`)
|
||||
)
|
||||
|
||||
const (
|
||||
mermaidCodeBlock = "mermaid"
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
templates = template.New("rendering").Funcs(sprig.FuncMap())
|
||||
if templates, err = templates.ParseFS(templatesFS, "templates/*.gohtml"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
type RevealRenderer struct {
|
||||
StateMachine *StateMachine
|
||||
Hash hash.Hash
|
||||
hasNotes bool
|
||||
}
|
||||
|
||||
//nolint:gocyclo // under construction
|
||||
func (r *RevealRenderer) RenderHook(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
|
||||
switch b := node.(type) {
|
||||
case *ast.Document:
|
||||
if entering {
|
||||
_, _ = w.Write([]byte("<section>\n"))
|
||||
if next := peekNextRuler(b); next != nil && string(next.Literal) == "***" {
|
||||
_, _ = w.Write([]byte("<section>\n"))
|
||||
r.StateMachine.CurrentState = StateTypeNested
|
||||
}
|
||||
} else {
|
||||
_, _ = w.Write([]byte("</section>\n"))
|
||||
}
|
||||
return ast.GoToNext, false
|
||||
case *ast.ListItem:
|
||||
if entering {
|
||||
return r.handleListItem(w, b)
|
||||
|
@ -72,7 +33,6 @@ func (r *RevealRenderer) RenderHook(w io.Writer, node ast.Node, entering bool) (
|
|||
}
|
||||
if notesRegexp.Match(b.Literal) {
|
||||
_, err := w.Write([]byte(`<aside class="notes">`))
|
||||
r.hasNotes = true
|
||||
return ast.SkipChildren, err == nil
|
||||
}
|
||||
return ast.GoToNext, false
|
||||
|
@ -81,18 +41,6 @@ func (r *RevealRenderer) RenderHook(w io.Writer, node ast.Node, entering bool) (
|
|||
return r.handleCodeBlock(w, b)
|
||||
}
|
||||
return ast.GoToNext, false
|
||||
case *ast.HorizontalRule:
|
||||
next := peekNextRuler(b)
|
||||
input := string(b.Literal)
|
||||
if next != nil {
|
||||
input += string(next.Literal)
|
||||
}
|
||||
if r.hasNotes {
|
||||
_, _ = w.Write([]byte(`</aside>`))
|
||||
r.hasNotes = false
|
||||
}
|
||||
_, _ = w.Write(r.StateMachine.Accept(input))
|
||||
return ast.GoToNext, true
|
||||
case *ast.Image:
|
||||
if entering {
|
||||
return r.handleImage(w, b)
|
||||
|
@ -215,17 +163,6 @@ func renderCodeTemplate(templateName string, codeBlock *ast.CodeBlock) (output [
|
|||
return renderTemplate(templateName, data)
|
||||
}
|
||||
|
||||
func renderTemplate(templateName string, data interface{}) (output []byte, err error) {
|
||||
buffer := templateRenderBufferPool.Get().(*bytes.Buffer)
|
||||
defer func() {
|
||||
buffer.Reset()
|
||||
templateRenderBufferPool.Put(buffer)
|
||||
}()
|
||||
|
||||
err = templates.ExecuteTemplate(buffer, templateName, data)
|
||||
return buffer.Bytes(), err
|
||||
}
|
||||
|
||||
func lineNumbers(attrs *ast.Attribute) string {
|
||||
if attrs == nil || attrs.Attrs == nil {
|
||||
return ""
|
||||
|
|
|
@ -1,124 +0,0 @@
|
|||
package rendering
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
)
|
||||
|
||||
const (
|
||||
EventTypeHorizontalSplit EventType = iota
|
||||
EventTypeVerticalSplit
|
||||
EventTypeHorizontalEnd
|
||||
EventTypeVerticalSplitEnd
|
||||
EventTypeVerticalDocumentEnd
|
||||
EventTypeVerticalVerticalSplit
|
||||
|
||||
StateTypeRegular StateType = iota
|
||||
StateTypeNested
|
||||
)
|
||||
|
||||
var EventMapping = map[EventType][]byte{
|
||||
EventTypeHorizontalSplit: []byte(`
|
||||
</section>
|
||||
<section>`),
|
||||
EventTypeVerticalSplit: []byte(`
|
||||
</section>
|
||||
<section>
|
||||
<section>
|
||||
`),
|
||||
EventTypeHorizontalEnd: []byte(`
|
||||
</section>
|
||||
`),
|
||||
EventTypeVerticalSplitEnd: []byte(`
|
||||
</section>
|
||||
</section>
|
||||
<section>`),
|
||||
EventTypeVerticalDocumentEnd: []byte(`
|
||||
</section>
|
||||
</section>
|
||||
`),
|
||||
EventTypeVerticalVerticalSplit: []byte(`
|
||||
</section>
|
||||
</section>
|
||||
<section>
|
||||
<section>`),
|
||||
}
|
||||
|
||||
func NewStateMachine(verticalSplit, horizontalSplit string) *StateMachine {
|
||||
return &StateMachine{
|
||||
CurrentState: StateTypeRegular,
|
||||
States: map[StateType]State{
|
||||
StateTypeRegular: {
|
||||
Transitions: map[string]TransitionResult{
|
||||
horizontalSplit: {EventTypeHorizontalSplit, StateTypeRegular},
|
||||
fmt.Sprintf("%s%s", horizontalSplit, horizontalSplit): {EventTypeHorizontalSplit, StateTypeRegular},
|
||||
fmt.Sprintf("%s%s", horizontalSplit, verticalSplit): {EventTypeVerticalSplit, StateTypeNested},
|
||||
fmt.Sprintf("%s%s", verticalSplit, horizontalSplit): {EventTypeVerticalSplit, StateTypeNested},
|
||||
"": {EventTypeHorizontalEnd, StateTypeRegular},
|
||||
},
|
||||
},
|
||||
StateTypeNested: {
|
||||
Transitions: map[string]TransitionResult{
|
||||
fmt.Sprintf("%s%s", verticalSplit, verticalSplit): {EventTypeHorizontalSplit, StateTypeNested},
|
||||
verticalSplit: {EventTypeHorizontalSplit, StateTypeNested},
|
||||
fmt.Sprintf("%s%s", verticalSplit, horizontalSplit): {EventTypeVerticalSplitEnd, StateTypeNested},
|
||||
horizontalSplit: {EventTypeVerticalSplitEnd, StateTypeRegular},
|
||||
fmt.Sprintf("%s%s", horizontalSplit, verticalSplit): {EventTypeVerticalVerticalSplit, StateTypeNested},
|
||||
fmt.Sprintf("%s%s", horizontalSplit, horizontalSplit): {EventTypeVerticalSplitEnd, StateTypeRegular},
|
||||
"": {EventTypeVerticalDocumentEnd, StateTypeRegular},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type (
|
||||
EventType uint
|
||||
StateType uint
|
||||
TransitionResult struct {
|
||||
EventType EventType
|
||||
StateType StateType
|
||||
}
|
||||
State struct {
|
||||
Transitions map[string]TransitionResult
|
||||
}
|
||||
StateMachine struct {
|
||||
CurrentState StateType
|
||||
States map[StateType]State
|
||||
}
|
||||
)
|
||||
|
||||
func (m *StateMachine) Accept(input string) []byte {
|
||||
if result, ok := m.States[m.CurrentState].Transitions[input]; ok {
|
||||
m.CurrentState = result.StateType
|
||||
return EventMapping[result.EventType]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func peekNextRuler(node ast.Node) *ast.HorizontalRule {
|
||||
if node.AsContainer() == nil {
|
||||
node = node.GetParent()
|
||||
}
|
||||
|
||||
nodes := node.GetChildren()
|
||||
if nodes == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var selfIdx int
|
||||
for idx := range nodes {
|
||||
if nodes[idx] == node {
|
||||
selfIdx = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for idx := selfIdx + 1; idx < len(nodes); idx++ {
|
||||
if hr, ok := nodes[idx].(*ast.HorizontalRule); ok {
|
||||
return hr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
61
rendering/templates.go
Normal file
61
rendering/templates.go
Normal file
|
@ -0,0 +1,61 @@
|
|||
package rendering
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"hash/fnv"
|
||||
"html/template"
|
||||
"sync"
|
||||
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
"github.com/gomarkdown/markdown"
|
||||
mdhtml "github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed templates/*.gohtml
|
||||
templatesFS embed.FS
|
||||
templates *template.Template
|
||||
templateRenderBufferPool = &sync.Pool{
|
||||
New: func() interface{} {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
templates = template.New("rendering").
|
||||
Funcs(sprig.FuncMap()).
|
||||
Funcs(template.FuncMap{
|
||||
"renderMarkdown": func(md string) template.HTML {
|
||||
rr := &RevealRenderer{
|
||||
Hash: fnv.New32a(),
|
||||
}
|
||||
mdParser := parser.NewWithExtensions(parserExtensions)
|
||||
renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{
|
||||
Flags: mdhtml.CommonFlags | mdhtml.HrefTargetBlank,
|
||||
RenderNodeHook: rr.RenderHook,
|
||||
})
|
||||
|
||||
renderedHTML := markdown.ToHTML([]byte(md), mdParser, renderer)
|
||||
//nolint:gosec // template should be esacped
|
||||
return template.HTML(renderedHTML)
|
||||
},
|
||||
})
|
||||
var err error
|
||||
if templates, err = templates.ParseFS(templatesFS, "templates/*.gohtml"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func renderTemplate(templateName string, data interface{}) (output []byte, err error) {
|
||||
buffer := templateRenderBufferPool.Get().(*bytes.Buffer)
|
||||
defer func() {
|
||||
buffer.Reset()
|
||||
templateRenderBufferPool.Put(buffer)
|
||||
}()
|
||||
|
||||
err = templates.ExecuteTemplate(buffer, templateName, data)
|
||||
return buffer.Bytes(), err
|
||||
}
|
12
rendering/templates/slide.gohtml
Normal file
12
rendering/templates/slide.gohtml
Normal file
|
@ -0,0 +1,12 @@
|
|||
<section>
|
||||
{{ if .Children }}
|
||||
{{ range .Children }}
|
||||
{{ .ToHTML }}
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
{{ renderMarkdown .Content }}
|
||||
{{ end }}
|
||||
{{ if .HasNotes }}
|
||||
</aside>
|
||||
{{ end }}
|
||||
</section>
|
|
@ -5,7 +5,6 @@ document.addEventListener("DOMContentLoaded", _ => {
|
|||
})
|
||||
.then(() => {
|
||||
subscribeToEvents()
|
||||
console.info("finished initializing")
|
||||
})
|
||||
|
||||
});
|
||||
|
@ -34,6 +33,7 @@ async function initReveal() {
|
|||
let cfg = await getRevealConfig()
|
||||
Reveal.initialize({
|
||||
controls: cfg.controls,
|
||||
controlsLayout: cfg.controlsLayout,
|
||||
progress: cfg.progress,
|
||||
history: cfg.history,
|
||||
center: cfg.center,
|
||||
|
@ -44,6 +44,10 @@ async function initReveal() {
|
|||
menu: {
|
||||
numbers: cfg.menu.numbers,
|
||||
useTextContentForMissingTitles: cfg.menu.useTextContentForMissingTitles,
|
||||
transitions: cfg.menu.transitions,
|
||||
hideMissingTitles: cfg.hideMissingTitles,
|
||||
markers: cfg.menu.markers,
|
||||
openButton: cfg.menu.openButton,
|
||||
custom: [
|
||||
{
|
||||
title: 'Print',
|
||||
|
@ -64,7 +68,6 @@ async function initReveal() {
|
|||
{name: 'Solarized', theme: '/reveal/dist/theme/solarized.css'},
|
||||
{name: 'White', theme: '/reveal/dist/theme/white.css'}
|
||||
],
|
||||
transitions: true,
|
||||
},
|
||||
plugins: [RevealHighlight, RevealNotes, RevealMenu]
|
||||
})
|
||||
|
@ -87,20 +90,19 @@ function subscribeToEvents() {
|
|||
let source = new EventSource("/api/v1/events");
|
||||
|
||||
source.onopen = (() => {
|
||||
console.log("eventsource connection open");
|
||||
console.debug("eventsource connection open");
|
||||
})
|
||||
|
||||
source.onerror = (ev => {
|
||||
if (ev.target.readyState === 0) {
|
||||
console.log("reconnecting to eventsource");
|
||||
console.debug("reconnecting to eventsource");
|
||||
} else {
|
||||
console.log("eventsource error", ev);
|
||||
console.error("eventsource error", ev);
|
||||
}
|
||||
})
|
||||
|
||||
source.onmessage = (ev => {
|
||||
let obj = JSON.parse(ev.data);
|
||||
console.log(obj);
|
||||
switch (true) {
|
||||
case obj.forceReload:
|
||||
window.location.reload()
|
||||
|
|
Loading…
Reference in a new issue