Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
563062368f | ||
|
|
735d48953a | ||
|
|
f364a4b6db |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,4 @@
|
|||||||
notes/**/**
|
notes/**/**
|
||||||
.github
|
|
||||||
mynotes_*
|
mynotes_*
|
||||||
_python_example/
|
_python_example/
|
||||||
notes/
|
notes/
|
||||||
@@ -13,5 +12,6 @@ __pycache__/
|
|||||||
*.sqlite3
|
*.sqlite3
|
||||||
*.log
|
*.log
|
||||||
*.bak
|
*.bak
|
||||||
|
tailwindcss-linux-x64
|
||||||
|
|
||||||
./gobsidian
|
gobsidian
|
||||||
27
Makefile
Normal file
27
Makefile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Build the project ensuring Tailwind CSS is embedded.
|
||||||
|
# Usage:
|
||||||
|
# make build - builds gobsidian with embedded assets
|
||||||
|
# make build-linux - builds linux/amd64 binary (for server deploy)
|
||||||
|
# make clean
|
||||||
|
|
||||||
|
TAILWIND=./tailwindcss-linux-x64
|
||||||
|
TAILWIND_IN=./web/static/tailwind.input.css
|
||||||
|
TAILWIND_OUT=./web/static/tailwind.css
|
||||||
|
BINARY=gobsidian
|
||||||
|
|
||||||
|
.PHONY: build build-linux css clean
|
||||||
|
|
||||||
|
css:
|
||||||
|
@echo "Building Tailwind CSS -> $(TAILWIND_OUT)"
|
||||||
|
$(TAILWIND) -i $(TAILWIND_IN) -o $(TAILWIND_OUT) --minify
|
||||||
|
|
||||||
|
build: css
|
||||||
|
@echo "Building $(BINARY) with embedded assets"
|
||||||
|
go build -o $(BINARY) ./cmd
|
||||||
|
|
||||||
|
build-linux: css
|
||||||
|
@echo "Building $(BINARY) for linux/amd64 with embedded assets"
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o $(BINARY) ./cmd
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(BINARY)
|
||||||
@@ -120,10 +120,15 @@ gobsidian/
|
|||||||
### Building
|
### Building
|
||||||
|
|
||||||
To build a standalone binary:
|
To build a standalone binary:
|
||||||
|
```bash
|
||||||
|
# in case you change CSS or add some, recompile tailwind.css
|
||||||
|
wget https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.12/tailwindcss-linux-x64
|
||||||
|
chmod +x tailwindcss-linux-x64
|
||||||
|
./tailwindcss-linux-x64 -i ./web/static/tailwind.input.css -o ./web/static/tailwind.css --minify
|
||||||
|
```
|
||||||
```bash
|
```bash
|
||||||
go mod tidy
|
go mod tidy
|
||||||
go build -o gobsidian ./cmd
|
GOOS=linux GOARCH=amd64 go build -o gobsidian ./cmd
|
||||||
```
|
```
|
||||||
## Image storing trying to follow Obsidian settings
|
## Image storing trying to follow Obsidian settings
|
||||||
Image storing modes:
|
Image storing modes:
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -9,7 +9,8 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/gorilla/sessions v1.2.1
|
github.com/gorilla/sessions v1.2.1
|
||||||
github.com/h2non/filetype v1.1.3
|
github.com/h2non/filetype v1.1.3
|
||||||
github.com/yuin/goldmark v1.6.0
|
github.com/yuin/goldmark v1.7.10
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.6
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||||
golang.org/x/crypto v0.9.0
|
golang.org/x/crypto v0.9.0
|
||||||
gopkg.in/ini.v1 v1.67.0
|
gopkg.in/ini.v1 v1.67.0
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -92,8 +92,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2
|
|||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
|
github.com/yuin/goldmark v1.7.10 h1:S+LrtBjRmqMac2UdtB6yyCEJm+UILZ2fefI4p7o0QpI=
|
||||||
github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.7.10/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
|||||||
@@ -46,14 +46,6 @@ type Config struct {
|
|||||||
RequireEmailConfirmation bool
|
RequireEmailConfirmation bool
|
||||||
MFAEnabledByDefault bool
|
MFAEnabledByDefault bool
|
||||||
|
|
||||||
// Email (SMTP) settings
|
|
||||||
SMTPHost string
|
|
||||||
SMTPPort int
|
|
||||||
SMTPUsername string
|
|
||||||
SMTPPassword string
|
|
||||||
SMTPSender string
|
|
||||||
SMTPUseTLS bool
|
|
||||||
|
|
||||||
// Security settings (failed-login thresholds and auto-ban config)
|
// Security settings (failed-login thresholds and auto-ban config)
|
||||||
PwdFailuresThreshold int
|
PwdFailuresThreshold int
|
||||||
MFAFailuresThreshold int
|
MFAFailuresThreshold int
|
||||||
@@ -94,17 +86,9 @@ var defaultConfig = map[string]map[string]string{
|
|||||||
},
|
},
|
||||||
"AUTH": {
|
"AUTH": {
|
||||||
"REQUIRE_ADMIN_ACTIVATION": "true",
|
"REQUIRE_ADMIN_ACTIVATION": "true",
|
||||||
"REQUIRE_EMAIL_CONFIRMATION": "true",
|
"REQUIRE_EMAIL_CONFIRMATION": "false",
|
||||||
"MFA_ENABLED_BY_DEFAULT": "false",
|
"MFA_ENABLED_BY_DEFAULT": "false",
|
||||||
},
|
},
|
||||||
"EMAIL": {
|
|
||||||
"SMTP_HOST": "",
|
|
||||||
"SMTP_PORT": "587",
|
|
||||||
"SMTP_USERNAME": "",
|
|
||||||
"SMTP_PASSWORD": "",
|
|
||||||
"SMTP_SENDER": "",
|
|
||||||
"SMTP_USE_TLS": "true",
|
|
||||||
},
|
|
||||||
"SECURITY": {
|
"SECURITY": {
|
||||||
"PWD_FAILURES_THRESHOLD": "5",
|
"PWD_FAILURES_THRESHOLD": "5",
|
||||||
"MFA_FAILURES_THRESHOLD": "10",
|
"MFA_FAILURES_THRESHOLD": "10",
|
||||||
@@ -228,15 +212,6 @@ func Load() (*Config, error) {
|
|||||||
config.RequireEmailConfirmation, _ = authSection.Key("REQUIRE_EMAIL_CONFIRMATION").Bool()
|
config.RequireEmailConfirmation, _ = authSection.Key("REQUIRE_EMAIL_CONFIRMATION").Bool()
|
||||||
config.MFAEnabledByDefault, _ = authSection.Key("MFA_ENABLED_BY_DEFAULT").Bool()
|
config.MFAEnabledByDefault, _ = authSection.Key("MFA_ENABLED_BY_DEFAULT").Bool()
|
||||||
|
|
||||||
// Load EMAIL (SMTP) section
|
|
||||||
emailSection := cfg.Section("EMAIL")
|
|
||||||
config.SMTPHost = emailSection.Key("SMTP_HOST").String()
|
|
||||||
config.SMTPPort, _ = emailSection.Key("SMTP_PORT").Int()
|
|
||||||
config.SMTPUsername = emailSection.Key("SMTP_USERNAME").String()
|
|
||||||
config.SMTPPassword = emailSection.Key("SMTP_PASSWORD").String()
|
|
||||||
config.SMTPSender = emailSection.Key("SMTP_SENDER").String()
|
|
||||||
config.SMTPUseTLS, _ = emailSection.Key("SMTP_USE_TLS").Bool()
|
|
||||||
|
|
||||||
// Load SECURITY section
|
// Load SECURITY section
|
||||||
secSection := cfg.Section("SECURITY")
|
secSection := cfg.Section("SECURITY")
|
||||||
config.PwdFailuresThreshold, _ = secSection.Key("PWD_FAILURES_THRESHOLD").Int()
|
config.PwdFailuresThreshold, _ = secSection.Key("PWD_FAILURES_THRESHOLD").Int()
|
||||||
@@ -408,23 +383,6 @@ func (c *Config) SaveSetting(section, key, value string) error {
|
|||||||
case "MFA_ENABLED_BY_DEFAULT":
|
case "MFA_ENABLED_BY_DEFAULT":
|
||||||
c.MFAEnabledByDefault = value == "true"
|
c.MFAEnabledByDefault = value == "true"
|
||||||
}
|
}
|
||||||
case "EMAIL":
|
|
||||||
switch key {
|
|
||||||
case "SMTP_HOST":
|
|
||||||
c.SMTPHost = value
|
|
||||||
case "SMTP_PORT":
|
|
||||||
if v, err := strconv.Atoi(value); err == nil {
|
|
||||||
c.SMTPPort = v
|
|
||||||
}
|
|
||||||
case "SMTP_USERNAME":
|
|
||||||
c.SMTPUsername = value
|
|
||||||
case "SMTP_PASSWORD":
|
|
||||||
c.SMTPPassword = value
|
|
||||||
case "SMTP_SENDER":
|
|
||||||
c.SMTPSender = value
|
|
||||||
case "SMTP_USE_TLS":
|
|
||||||
c.SMTPUseTLS = value == "true"
|
|
||||||
}
|
|
||||||
case "SECURITY":
|
case "SECURITY":
|
||||||
switch key {
|
switch key {
|
||||||
case "PWD_FAILURES_THRESHOLD":
|
case "PWD_FAILURES_THRESHOLD":
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ func (h *Handlers) CreateNoteHandler(c *gin.Context) {
|
|||||||
title += ".md"
|
title += ".md"
|
||||||
ext = "md"
|
ext = "md"
|
||||||
} else {
|
} else {
|
||||||
// Has extension: allow if md or in allowed file extensions
|
// Has extension: allow if md/markdown or in allowed file extensions
|
||||||
allowed := ext == "md"
|
allowed := (ext == "md" || ext == "markdown")
|
||||||
if !allowed {
|
if !allowed {
|
||||||
for _, a := range h.config.AllowedFileExtensions {
|
for _, a := range h.config.AllowedFileExtensions {
|
||||||
if strings.EqualFold(a, ext) {
|
if strings.EqualFold(a, ext) {
|
||||||
@@ -143,7 +143,7 @@ func (h *Handlers) CreateNoteHandler(c *gin.Context) {
|
|||||||
|
|
||||||
// Redirect based on extension
|
// Redirect based on extension
|
||||||
redirect := h.config.URLPrefix + "/note/" + notePath
|
redirect := h.config.URLPrefix + "/note/" + notePath
|
||||||
if strings.ToLower(ext) != "md" {
|
if e := strings.ToLower(ext); e != "md" && e != "markdown" {
|
||||||
redirect = h.config.URLPrefix + "/view_text/" + notePath
|
redirect = h.config.URLPrefix + "/view_text/" + notePath
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,11 +158,12 @@ func (h *Handlers) CreateNoteHandler(c *gin.Context) {
|
|||||||
func (h *Handlers) EditNotePageHandler(c *gin.Context) {
|
func (h *Handlers) EditNotePageHandler(c *gin.Context) {
|
||||||
notePath := strings.TrimPrefix(c.Param("path"), "/")
|
notePath := strings.TrimPrefix(c.Param("path"), "/")
|
||||||
|
|
||||||
if !strings.HasSuffix(notePath, ".md") {
|
lp := strings.ToLower(notePath)
|
||||||
|
if !(strings.HasSuffix(lp, ".md") || strings.HasSuffix(lp, ".markdown")) {
|
||||||
c.HTML(http.StatusBadRequest, "error", gin.H{
|
c.HTML(http.StatusBadRequest, "error", gin.H{
|
||||||
"error": "Invalid note path",
|
"error": "Invalid note path",
|
||||||
"app_name": h.config.AppName,
|
"app_name": h.config.AppName,
|
||||||
"message": "Note path must end with .md",
|
"message": "Note path must end with .md or .markdown",
|
||||||
"ContentTemplate": "error_content",
|
"ContentTemplate": "error_content",
|
||||||
"ScriptsTemplate": "error_scripts",
|
"ScriptsTemplate": "error_scripts",
|
||||||
"Page": "error",
|
"Page": "error",
|
||||||
@@ -236,7 +237,13 @@ func (h *Handlers) EditNotePageHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
title := strings.TrimSuffix(filepath.Base(notePath), ".md")
|
base := filepath.Base(notePath)
|
||||||
|
var title string
|
||||||
|
if strings.HasSuffix(strings.ToLower(base), ".markdown") {
|
||||||
|
title = strings.TrimSuffix(base, ".markdown")
|
||||||
|
} else {
|
||||||
|
title = strings.TrimSuffix(base, ".md")
|
||||||
|
}
|
||||||
folderPath := filepath.Dir(notePath)
|
folderPath := filepath.Dir(notePath)
|
||||||
if folderPath == "." {
|
if folderPath == "." {
|
||||||
folderPath = ""
|
folderPath = ""
|
||||||
@@ -264,7 +271,8 @@ func (h *Handlers) EditNoteHandler(c *gin.Context) {
|
|||||||
notePath := strings.TrimPrefix(c.Param("path"), "/")
|
notePath := strings.TrimPrefix(c.Param("path"), "/")
|
||||||
content := c.PostForm("content")
|
content := c.PostForm("content")
|
||||||
|
|
||||||
if !strings.HasSuffix(notePath, ".md") {
|
lp := strings.ToLower(notePath)
|
||||||
|
if !(strings.HasSuffix(lp, ".md") || strings.HasSuffix(lp, ".markdown")) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid note path"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid note path"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1079,11 +1079,12 @@ func (h *Handlers) FolderHandler(c *gin.Context) {
|
|||||||
func (h *Handlers) NoteHandler(c *gin.Context) {
|
func (h *Handlers) NoteHandler(c *gin.Context) {
|
||||||
notePath := strings.TrimPrefix(c.Param("path"), "/")
|
notePath := strings.TrimPrefix(c.Param("path"), "/")
|
||||||
|
|
||||||
if !strings.HasSuffix(notePath, ".md") {
|
// Allow both .md and .markdown files
|
||||||
|
if !(strings.HasSuffix(strings.ToLower(notePath), ".md") || strings.HasSuffix(strings.ToLower(notePath), ".markdown")) {
|
||||||
c.HTML(http.StatusBadRequest, "error", gin.H{
|
c.HTML(http.StatusBadRequest, "error", gin.H{
|
||||||
"error": "Invalid note path",
|
"error": "Invalid note path",
|
||||||
"app_name": h.config.AppName,
|
"app_name": h.config.AppName,
|
||||||
"message": "Note path must end with .md",
|
"message": "Note path must end with .md or .markdown",
|
||||||
"ContentTemplate": "error_content",
|
"ContentTemplate": "error_content",
|
||||||
"ScriptsTemplate": "error_scripts",
|
"ScriptsTemplate": "error_scripts",
|
||||||
"Page": "error",
|
"Page": "error",
|
||||||
@@ -1143,15 +1144,48 @@ func (h *Handlers) NoteHandler(c *gin.Context) {
|
|||||||
fullPath := filepath.Join(h.config.NotesDir, notePath)
|
fullPath := filepath.Join(h.config.NotesDir, notePath)
|
||||||
|
|
||||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||||
c.HTML(http.StatusNotFound, "error", gin.H{
|
// Fallback: try the alternate markdown extension
|
||||||
"error": "Note not found",
|
base := filepath.Base(notePath)
|
||||||
"app_name": h.config.AppName,
|
dir := filepath.Dir(notePath)
|
||||||
"message": "The requested note does not exist",
|
lower := strings.ToLower(base)
|
||||||
"ContentTemplate": "error_content",
|
var alt string
|
||||||
"ScriptsTemplate": "error_scripts",
|
if strings.HasSuffix(lower, ".md") {
|
||||||
"Page": "error",
|
alt = base[:len(base)-len(".md")] + ".markdown"
|
||||||
})
|
} else if strings.HasSuffix(lower, ".markdown") {
|
||||||
return
|
alt = base[:len(base)-len(".markdown")] + ".md"
|
||||||
|
}
|
||||||
|
if alt != "" {
|
||||||
|
altRel := alt
|
||||||
|
if dir != "." && dir != "" {
|
||||||
|
altRel = filepath.Join(dir, alt)
|
||||||
|
}
|
||||||
|
altFull := filepath.Join(h.config.NotesDir, altRel)
|
||||||
|
if _, err2 := os.Stat(altFull); err2 == nil {
|
||||||
|
// Use the alternate path
|
||||||
|
notePath = altRel
|
||||||
|
fullPath = altFull
|
||||||
|
} else {
|
||||||
|
c.HTML(http.StatusNotFound, "error", gin.H{
|
||||||
|
"error": "Note not found",
|
||||||
|
"app_name": h.config.AppName,
|
||||||
|
"message": "The requested note does not exist",
|
||||||
|
"ContentTemplate": "error_content",
|
||||||
|
"ScriptsTemplate": "error_scripts",
|
||||||
|
"Page": "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.HTML(http.StatusNotFound, "error", gin.H{
|
||||||
|
"error": "Note not found",
|
||||||
|
"app_name": h.config.AppName,
|
||||||
|
"message": "The requested note does not exist",
|
||||||
|
"ContentTemplate": "error_content",
|
||||||
|
"ScriptsTemplate": "error_scripts",
|
||||||
|
"Page": "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := os.ReadFile(fullPath)
|
content, err := os.ReadFile(fullPath)
|
||||||
@@ -1193,7 +1227,16 @@ func (h *Handlers) NoteHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
title := strings.TrimSuffix(filepath.Base(notePath), ".md")
|
base := filepath.Base(notePath)
|
||||||
|
lower := strings.ToLower(base)
|
||||||
|
var title string
|
||||||
|
if strings.HasSuffix(lower, ".markdown") {
|
||||||
|
title = base[:len(base)-len(".markdown")]
|
||||||
|
} else if strings.HasSuffix(lower, ".md") {
|
||||||
|
title = base[:len(base)-len(".md")]
|
||||||
|
} else {
|
||||||
|
title = strings.TrimSuffix(base, filepath.Ext(base))
|
||||||
|
}
|
||||||
folderPath := filepath.Dir(notePath)
|
folderPath := filepath.Dir(notePath)
|
||||||
if folderPath == "." {
|
if folderPath == "." {
|
||||||
folderPath = ""
|
folderPath = ""
|
||||||
@@ -1679,7 +1722,7 @@ func (h *Handlers) SearchHandler(c *gin.Context) {
|
|||||||
|
|
||||||
// Skip disallowed files
|
// Skip disallowed files
|
||||||
ext := strings.ToLower(filepath.Ext(relPath))
|
ext := strings.ToLower(filepath.Ext(relPath))
|
||||||
isMD := ext == ".md"
|
isMD := ext == ".md" || ext == ".markdown"
|
||||||
if !isMD && !models.IsAllowedFile(relPath, h.config.AllowedFileExtensions) {
|
if !isMD && !models.IsAllowedFile(relPath, h.config.AllowedFileExtensions) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/yuin/goldmark/extension"
|
"github.com/yuin/goldmark/extension"
|
||||||
"github.com/yuin/goldmark/parser"
|
"github.com/yuin/goldmark/parser"
|
||||||
"github.com/yuin/goldmark/renderer/html"
|
"github.com/yuin/goldmark/renderer/html"
|
||||||
|
emoji "github.com/yuin/goldmark-emoji"
|
||||||
|
|
||||||
"gobsidian/internal/config"
|
"gobsidian/internal/config"
|
||||||
)
|
)
|
||||||
@@ -22,6 +23,38 @@ type Renderer struct {
|
|||||||
config *config.Config
|
config *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processMermaidFences wraps fenced code blocks marked as "mermaid" into <div class="mermaid">...</div>
|
||||||
|
func (r *Renderer) processMermaidFences(content string) string {
|
||||||
|
// ```mermaid\n...\n```
|
||||||
|
re := regexp.MustCompile("(?s)```mermaid\\s*(.*?)\\s*```")
|
||||||
|
return re.ReplaceAllString(content, "<div class=\"mermaid\">$1</div>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// processMediaEmbeds turns a bare URL on its own line into an embed element
|
||||||
|
// Supports: audio (mp3|wav|ogg), video (mp4|webm|ogg), pdf
|
||||||
|
func (r *Renderer) processMediaEmbeds(content string) string {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
mediaRe := regexp.MustCompile(`^(https?://\S+\.(?:mp3|wav|ogg|mp4|webm|ogg|pdf))$`)
|
||||||
|
|
||||||
|
for i, ln := range lines {
|
||||||
|
trimmed := strings.TrimSpace(ln)
|
||||||
|
m := mediaRe.FindStringSubmatch(trimmed)
|
||||||
|
if len(m) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url := m[1]
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(strings.ToLower(url), ".mp3") || strings.HasSuffix(strings.ToLower(url), ".wav") || strings.HasSuffix(strings.ToLower(url), ".ogg"):
|
||||||
|
lines[i] = fmt.Sprintf("<audio controls preload=\"metadata\" src=\"%s\"></audio>", url)
|
||||||
|
case strings.HasSuffix(strings.ToLower(url), ".mp4") || strings.HasSuffix(strings.ToLower(url), ".webm") || strings.HasSuffix(strings.ToLower(url), ".ogg"):
|
||||||
|
lines[i] = fmt.Sprintf("<video controls preload=\"metadata\" style=\"max-width:100%%\" src=\"%s\"></video>", url)
|
||||||
|
case strings.HasSuffix(strings.ToLower(url), ".pdf"):
|
||||||
|
lines[i] = fmt.Sprintf("<iframe src=\"%s\" style=\"width:100%%;height:70vh;border:1px solid #374151;border-radius:8px\"></iframe>", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
func NewRenderer(cfg *config.Config) *Renderer {
|
func NewRenderer(cfg *config.Config) *Renderer {
|
||||||
md := goldmark.New(
|
md := goldmark.New(
|
||||||
goldmark.WithExtensions(
|
goldmark.WithExtensions(
|
||||||
@@ -29,6 +62,11 @@ func NewRenderer(cfg *config.Config) *Renderer {
|
|||||||
extension.Table,
|
extension.Table,
|
||||||
extension.Strikethrough,
|
extension.Strikethrough,
|
||||||
extension.TaskList,
|
extension.TaskList,
|
||||||
|
extension.Footnote,
|
||||||
|
extension.DefinitionList,
|
||||||
|
extension.Linkify,
|
||||||
|
extension.Typographer,
|
||||||
|
emoji.Emoji,
|
||||||
highlighting.NewHighlighting(
|
highlighting.NewHighlighting(
|
||||||
highlighting.WithStyle("github-dark"),
|
highlighting.WithStyle("github-dark"),
|
||||||
highlighting.WithFormatOptions(
|
highlighting.WithFormatOptions(
|
||||||
@@ -61,6 +99,12 @@ func (r *Renderer) RenderMarkdown(content string, notePath string) (string, erro
|
|||||||
// Process Obsidian links
|
// Process Obsidian links
|
||||||
content = r.processObsidianLinks(content)
|
content = r.processObsidianLinks(content)
|
||||||
|
|
||||||
|
// Convert Mermaid fenced code blocks to <div class="mermaid"> for client rendering
|
||||||
|
content = r.processMermaidFences(content)
|
||||||
|
|
||||||
|
// Convert bare media links on their own line to embedded players (audio/video/pdf)
|
||||||
|
content = r.processMediaEmbeds(content)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := r.md.Convert([]byte(content), &buf); err != nil {
|
if err := r.md.Convert([]byte(content), &buf); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ type ImageStorageInfo struct {
|
|||||||
func GetFileType(extension string, allowedImageExts, allowedFileExts []string) FileType {
|
func GetFileType(extension string, allowedImageExts, allowedFileExts []string) FileType {
|
||||||
ext := strings.ToLower(strings.TrimPrefix(extension, "."))
|
ext := strings.ToLower(strings.TrimPrefix(extension, "."))
|
||||||
|
|
||||||
if ext == "md" {
|
if ext == "md" || ext == "markdown" {
|
||||||
return FileTypeMarkdown
|
return FileTypeMarkdown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,15 @@ func GetFolderContents(folderPath string, cfg *config.Config) ([]models.FileInfo
|
|||||||
|
|
||||||
// Set display name based on file type
|
// Set display name based on file type
|
||||||
if fileInfo.Type == models.FileTypeMarkdown {
|
if fileInfo.Type == models.FileTypeMarkdown {
|
||||||
fileInfo.DisplayName = strings.TrimSuffix(entry.Name(), ".md")
|
name := entry.Name()
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
if strings.HasSuffix(lower, ".markdown") {
|
||||||
|
fileInfo.DisplayName = name[:len(name)-len(".markdown")]
|
||||||
|
} else if strings.HasSuffix(lower, ".md") {
|
||||||
|
fileInfo.DisplayName = name[:len(name)-len(".md")]
|
||||||
|
} else {
|
||||||
|
fileInfo.DisplayName = strings.TrimSuffix(name, filepath.Ext(name))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fileInfo.DisplayName = entry.Name()
|
fileInfo.DisplayName = entry.Name()
|
||||||
}
|
}
|
||||||
|
|||||||
34
tailwind.config.js
Normal file
34
tailwind.config.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: [
|
||||||
|
"./web/templates/**/*.html",
|
||||||
|
"./web/static/styles.css",
|
||||||
|
"./web/static/*.js",
|
||||||
|
"./**/*.go"
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
obsidian: {
|
||||||
|
50: '#f8fafc',
|
||||||
|
100: '#f1f5f9',
|
||||||
|
200: '#e2e8f0',
|
||||||
|
300: '#cbd5e1',
|
||||||
|
400: '#94a3b8',
|
||||||
|
500: '#64748b',
|
||||||
|
600: '#475569',
|
||||||
|
700: '#334155',
|
||||||
|
800: '#1e293b',
|
||||||
|
900: '#0f172a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
safelist: [],
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
// wget https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.12/tailwindcss-linux-x64
|
||||||
|
// chmod +x tailwindcss-linux-x64
|
||||||
|
// ./tailwindcss-linux-x64 -i ./web/static/tailwind.input.css -o ./web/static/tailwind.css --minify
|
||||||
2
web/static/tailwind.css
Normal file
2
web/static/tailwind.css
Normal file
File diff suppressed because one or more lines are too long
1
web/static/tailwind.input.css
Normal file
1
web/static/tailwind.input.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -11,6 +11,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-1">
|
||||||
|
<i class="fas fa-tools mr-2"></i>Admin Tools
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-400">Access logs and security controls</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="{{url "/editor/admin/logs"}}" class="btn-secondary inline-flex items-center">
|
||||||
|
<i class="fas fa-list mr-2"></i>View Logs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Users -->
|
<!-- Users -->
|
||||||
<div class="bg-gray-800 rounded-lg p-6">
|
<div class="bg-gray-800 rounded-lg p-6">
|
||||||
<h2 class="text-xl font-semibold text-white mb-4">
|
<h2 class="text-xl font-semibold text-white mb-4">
|
||||||
|
|||||||
@@ -5,30 +5,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{.app_name}}</title>
|
<title>{{.app_name}}</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<link rel="stylesheet" href="{{url "/static/tailwind.css"}}">
|
||||||
<script>
|
<link rel="stylesheet" href="{{url "/static/styles.css"}}">
|
||||||
tailwind.config = {
|
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
'obsidian': {
|
|
||||||
'50': '#f8fafc',
|
|
||||||
'100': '#f1f5f9',
|
|
||||||
'200': '#e2e8f0',
|
|
||||||
'300': '#cbd5e1',
|
|
||||||
'400': '#94a3b8',
|
|
||||||
'500': '#64748b',
|
|
||||||
'600': '#475569',
|
|
||||||
'700': '#334155',
|
|
||||||
'800': '#1e293b',
|
|
||||||
'900': '#0f172a',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/go.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/go.min.js"></script>
|
||||||
@@ -41,6 +19,8 @@
|
|||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/sql.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/sql.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/bash.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/bash.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/css.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/css.min.js"></script>
|
||||||
|
<!-- Mermaid for diagrams -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
/* Custom scrollbar for dark theme */
|
/* Custom scrollbar for dark theme */
|
||||||
@@ -66,6 +46,21 @@
|
|||||||
.prose-dark h1, .prose-dark h2, .prose-dark h3, .prose-dark h4, .prose-dark h5, .prose-dark h6 {
|
.prose-dark h1, .prose-dark h2, .prose-dark h3, .prose-dark h4, .prose-dark h5, .prose-dark h6 {
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
/* Headings sizing and spacing */
|
||||||
|
.prose-dark h1 { font-size: 1.875rem; line-height: 2.25rem; margin-top: 1.5rem; margin-bottom: 0.75rem; }
|
||||||
|
.prose-dark h2 { font-size: 1.5rem; line-height: 2rem; margin-top: 1.25rem; margin-bottom: 0.5rem; }
|
||||||
|
.prose-dark h3 { font-size: 1.25rem; line-height: 1.75rem; margin-top: 1rem; margin-bottom: 0.5rem; }
|
||||||
|
.prose-dark h4 { font-size: 1.125rem; line-height: 1.5rem; margin-top: 0.75rem; margin-bottom: 0.5rem; }
|
||||||
|
.prose-dark p { margin: 0.75rem 0; }
|
||||||
|
.prose-dark hr { border-color: #374151; margin: 1.25rem 0; }
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
.prose-dark ul { list-style-type: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
|
||||||
|
.prose-dark ol { list-style-type: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
|
||||||
|
.prose-dark li { margin: 0.25rem 0; }
|
||||||
|
.prose-dark li > ul { list-style-type: circle; }
|
||||||
|
.prose-dark li > ol { list-style-type: lower-alpha; }
|
||||||
|
|
||||||
.prose-dark a {
|
.prose-dark a {
|
||||||
color: #60a5fa;
|
color: #60a5fa;
|
||||||
}
|
}
|
||||||
@@ -81,7 +76,9 @@
|
|||||||
.prose-dark pre {
|
.prose-dark pre {
|
||||||
background-color: #111827;
|
background-color: #111827;
|
||||||
border: 1px solid #374151;
|
border: 1px solid #374151;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
.prose-dark pre code { background: transparent; color: inherit; padding: 0; }
|
||||||
.prose-dark blockquote {
|
.prose-dark blockquote {
|
||||||
border-left: 4px solid #3b82f6;
|
border-left: 4px solid #3b82f6;
|
||||||
background-color: #1f2937;
|
background-color: #1f2937;
|
||||||
@@ -255,6 +252,20 @@
|
|||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script>
|
||||||
|
// Initialize Mermaid (dark theme) and run on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (window.mermaid) {
|
||||||
|
try {
|
||||||
|
window.mermaid.initialize({ startOnLoad: false, theme: 'dark' });
|
||||||
|
const containers = document.querySelectorAll('.mermaid');
|
||||||
|
if (containers.length) {
|
||||||
|
window.mermaid.run({ querySelector: '.mermaid' });
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-900 text-gray-300 min-h-screen">
|
<body class="bg-slate-900 text-gray-300 min-h-screen">
|
||||||
<div class="flex h-screen">
|
<div class="flex h-screen">
|
||||||
@@ -264,14 +275,21 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="p-4 border-b border-gray-700">
|
<div class="p-4 border-b border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<a href="{{url "/"}}" class="sidebar-title text-xl font-bold text-white hover:text-blue-300" title="Home">
|
<a href="{{url "/"}}" class="sidebar-title text-xl font-bold text-white hover:text-blue-300" title="Home" aria-label="Home">
|
||||||
{{.app_name}}
|
<i class="fas fa-house"></i>
|
||||||
|
<span class="sr-only">{{.app_name}}</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex items-center space-x-2 items-center">
|
<div class="flex items-center space-x-2 items-center">
|
||||||
<div class="sidebar-actions flex items-center space-x-3">
|
<div class="sidebar-actions flex items-center space-x-3">
|
||||||
<button id="open-search" class="text-gray-400 hover:text-white transition-colors" title="Search" aria-label="Search">
|
<button id="open-search" class="text-gray-400 hover:text-white transition-colors" title="Search" aria-label="Search">
|
||||||
<i class="fas fa-magnifying-glass"></i>
|
<i class="fas fa-magnifying-glass"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="expand-all" class="text-gray-400 hover:text-white transition-colors" title="Expand all" aria-label="Expand all">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
</button>
|
||||||
|
<button id="collapse-all" class="text-gray-400 hover:text-white transition-colors" title="Collapse all" aria-label="Collapse all">
|
||||||
|
<i class="fas fa-folder"></i>
|
||||||
|
</button>
|
||||||
{{if .Authenticated}}
|
{{if .Authenticated}}
|
||||||
{{if .IsAdmin}}
|
{{if .IsAdmin}}
|
||||||
<a href="{{url "/editor/admin"}}" class="text-gray-400 hover:text-white transition-colors" title="Admin">
|
<a href="{{url "/editor/admin"}}" class="text-gray-400 hover:text-white transition-colors" title="Admin">
|
||||||
@@ -404,7 +422,19 @@
|
|||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script>
|
<script>
|
||||||
// Initialize syntax highlighting
|
// Initialize syntax highlighting
|
||||||
hljs.highlightAll();
|
// Avoid warnings and double-highlighting: skip elements already highlighted by Chroma
|
||||||
|
if (window.hljs) {
|
||||||
|
try {
|
||||||
|
// Suppress unescaped HTML warnings from hljs
|
||||||
|
window.hljs.configure({ ignoreUnescapedHTML: true });
|
||||||
|
// Only highlight code blocks that are not inside a .chroma container
|
||||||
|
document.querySelectorAll('pre code').forEach(function (el) {
|
||||||
|
if (!el.closest('.chroma')) {
|
||||||
|
window.hljs.highlightElement(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Base URL prefix from server
|
// Base URL prefix from server
|
||||||
window.BASE = '{{base}}';
|
window.BASE = '{{base}}';
|
||||||
@@ -418,18 +448,48 @@
|
|||||||
return p[0] === '/' ? (b + p) : (b + '/' + p);
|
return p[0] === '/' ? (b + p) : (b + '/' + p);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tree functionality
|
// Tree functionality with persisted expanded state
|
||||||
|
const LS_KEY_EXPANDED = 'tree:expanded';
|
||||||
|
function getExpandedSet() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LS_KEY_EXPANDED);
|
||||||
|
const arr = raw ? JSON.parse(raw) : [];
|
||||||
|
return new Set(Array.isArray(arr) ? arr : []);
|
||||||
|
} catch { return new Set(); }
|
||||||
|
}
|
||||||
|
function saveExpandedSet(set) {
|
||||||
|
try { localStorage.setItem(LS_KEY_EXPANDED, JSON.stringify(Array.from(set))); } catch {}
|
||||||
|
}
|
||||||
|
function setExpanded(toggleEl, expand) {
|
||||||
|
const children = toggleEl.nextElementSibling;
|
||||||
|
const chevron = toggleEl.querySelector('.tree-chevron');
|
||||||
|
if (!children || !children.classList.contains('tree-children')) return;
|
||||||
|
const isHidden = children.classList.contains('hidden');
|
||||||
|
if (expand && isHidden) children.classList.remove('hidden');
|
||||||
|
if (!expand && !isHidden) children.classList.add('hidden');
|
||||||
|
if (chevron) chevron.classList.toggle('rotate-90', expand);
|
||||||
|
}
|
||||||
|
function applyExpandedState() {
|
||||||
|
const expanded = getExpandedSet();
|
||||||
|
document.querySelectorAll('.tree-toggle').forEach(t => {
|
||||||
|
const path = t.getAttribute('data-path') || '';
|
||||||
|
const shouldExpand = expanded.has(path);
|
||||||
|
setExpanded(t, shouldExpand);
|
||||||
|
});
|
||||||
|
}
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (e.target.closest('.tree-toggle')) {
|
if (e.target.closest('.tree-toggle')) {
|
||||||
const toggle = e.target.closest('.tree-toggle');
|
const toggle = e.target.closest('.tree-toggle');
|
||||||
const children = toggle.nextElementSibling;
|
const children = toggle.nextElementSibling;
|
||||||
const chevron = toggle.querySelector('.tree-chevron');
|
const chevron = toggle.querySelector('.tree-chevron');
|
||||||
|
|
||||||
if (children && children.classList.contains('tree-children')) {
|
if (children && children.classList.contains('tree-children')) {
|
||||||
|
const expanded = getExpandedSet();
|
||||||
|
const path = toggle.getAttribute('data-path') || '';
|
||||||
|
const willExpand = children.classList.contains('hidden');
|
||||||
children.classList.toggle('hidden');
|
children.classList.toggle('hidden');
|
||||||
if (chevron) {
|
if (chevron) chevron.classList.toggle('rotate-90');
|
||||||
chevron.classList.toggle('rotate-90');
|
if (willExpand) expanded.add(path); else expanded.delete(path);
|
||||||
}
|
saveExpandedSet(expanded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -458,6 +518,11 @@
|
|||||||
if (chevron) {
|
if (chevron) {
|
||||||
chevron.classList.add('rotate-90');
|
chevron.classList.add('rotate-90');
|
||||||
}
|
}
|
||||||
|
// persist this expanded state
|
||||||
|
const expanded = getExpandedSet();
|
||||||
|
const path = toggle.getAttribute('data-path') || '';
|
||||||
|
expanded.add(path);
|
||||||
|
saveExpandedSet(expanded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
parent = parent.parentElement;
|
parent = parent.parentElement;
|
||||||
@@ -528,9 +593,28 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expand active path in tree
|
// Apply persisted expanded folders, then ensure active path is expanded
|
||||||
|
applyExpandedState();
|
||||||
expandActivePath();
|
expandActivePath();
|
||||||
|
|
||||||
|
// Wire expand/collapse all
|
||||||
|
const expandAllBtn = document.getElementById('expand-all');
|
||||||
|
const collapseAllBtn = document.getElementById('collapse-all');
|
||||||
|
if (expandAllBtn) expandAllBtn.addEventListener('click', function() {
|
||||||
|
const expanded = getExpandedSet();
|
||||||
|
document.querySelectorAll('.tree-toggle').forEach(t => {
|
||||||
|
const path = t.getAttribute('data-path') || '';
|
||||||
|
setExpanded(t, true);
|
||||||
|
expanded.add(path);
|
||||||
|
});
|
||||||
|
saveExpandedSet(expanded);
|
||||||
|
});
|
||||||
|
if (collapseAllBtn) collapseAllBtn.addEventListener('click', function() {
|
||||||
|
const expanded = new Set();
|
||||||
|
document.querySelectorAll('.tree-toggle').forEach(t => setExpanded(t, false));
|
||||||
|
saveExpandedSet(expanded);
|
||||||
|
});
|
||||||
|
|
||||||
// Sidebar tree fallback: if server didn't render any tree nodes (e.g., error pages), fetch and render via API
|
// Sidebar tree fallback: if server didn't render any tree nodes (e.g., error pages), fetch and render via API
|
||||||
(function ensureSidebarTree() {
|
(function ensureSidebarTree() {
|
||||||
const container = document.getElementById('sidebar-tree');
|
const container = document.getElementById('sidebar-tree');
|
||||||
@@ -549,6 +633,7 @@
|
|||||||
container.appendChild(renderTreeNode(child));
|
container.appendChild(renderTreeNode(child));
|
||||||
});
|
});
|
||||||
// Re-apply expanded state and active path if any
|
// Re-apply expanded state and active path if any
|
||||||
|
applyExpandedState();
|
||||||
expandActivePath();
|
expandActivePath();
|
||||||
})
|
})
|
||||||
.catch(() => {/* ignore */});
|
.catch(() => {/* ignore */});
|
||||||
|
|||||||
@@ -77,6 +77,69 @@ console.log('Hello, World!');
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor Toolbar -->
|
||||||
|
<div class="flex items-center justify-between border-t border-gray-700 pt-4">
|
||||||
|
<div class="flex items-center flex-wrap gap-2">
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('**', '**')" title="Bold">
|
||||||
|
<i class="fas fa-bold"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('*', '*')" title="Italic">
|
||||||
|
<i class="fas fa-italic"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('~~', '~~')" title="Strikethrough">
|
||||||
|
<i class="fas fa-strikethrough"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('`', '`')" title="Inline code">
|
||||||
|
<i class="fas fa-code"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertCodeBlock()" title="Code block">
|
||||||
|
<i class="fas fa-file-code"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertMermaid()" title="Mermaid diagram">
|
||||||
|
<i class="fas fa-project-diagram"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('[', '](url)')" title="Link">
|
||||||
|
<i class="fas fa-link"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('')" title="Image">
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
</button>
|
||||||
|
<!-- Headings dropdown -->
|
||||||
|
<div class="relative">
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="toggleHeadingMenu(event)" title="Headings">
|
||||||
|
<i class="fas fa-heading"></i>
|
||||||
|
</button>
|
||||||
|
<div id="heading-menu" class="absolute z-10 hidden bg-slate-800 border border-gray-700 rounded shadow-lg right-0 bottom-full mb-2">
|
||||||
|
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(1)">H1</button>
|
||||||
|
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(2)">H2</button>
|
||||||
|
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(3)">H3</button>
|
||||||
|
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(4)">H4</button>
|
||||||
|
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(5)">H5</button>
|
||||||
|
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(6)">H6</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertList()" title="Bulleted list">
|
||||||
|
<i class="fas fa-list"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertNumberedList()" title="Numbered list">
|
||||||
|
<i class="fas fa-list-ol"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertChecklist()" title="Task list">
|
||||||
|
<i class="fas fa-square-check"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertBlockquote()" title="Blockquote">
|
||||||
|
<i class="fas fa-quote-left"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertHr()" title="Horizontal rule">
|
||||||
|
<i class="fas fa-minus"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertTable()" title="Table">
|
||||||
|
<i class="fas fa-table"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">Press Ctrl+S to create</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -145,16 +208,38 @@ console.log('Hello, World!');
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function transformMermaidFences(md) {
|
||||||
|
return (md || '').replace(/```mermaid\n([\s\S]*?)```/g, function(_, inner){
|
||||||
|
return `<div class=\"mermaid\">${inner}</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformMediaEmbeds(md) {
|
||||||
|
const lines = (md || '').split(/\n/);
|
||||||
|
return lines.map(l => {
|
||||||
|
const t = l.trim();
|
||||||
|
if (/^https?:\/\/\S+\.(?:mp3|wav|ogg)$/.test(t)) return `<audio controls preload=\"metadata\" src=\"${t}\"></audio>`;
|
||||||
|
if (/^https?:\/\/\S+\.(?:mp4|webm|ogg)$/.test(t)) return `<video controls preload=\"metadata\" style=\"max-width:100%\" src=\"${t}\"></video>`;
|
||||||
|
if (/^https?:\/\/\S+\.(?:pdf)$/.test(t)) return `<iframe src=\"${t}\" style=\"width:100%;height:70vh;border:1px solid #374151;border-radius:8px\"></iframe>`;
|
||||||
|
return l;
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
// Render preview
|
// Render preview
|
||||||
function renderPreview() {
|
function renderPreview() {
|
||||||
if (!previewEnabled) return;
|
if (!previewEnabled) return;
|
||||||
if (!livePreview || !window.marked) return;
|
if (!livePreview || !window.marked) return;
|
||||||
const transformed = transformObsidianEmbeds(contentTextarea.value || '');
|
let transformed = transformObsidianEmbeds(contentTextarea.value || '');
|
||||||
|
transformed = transformMermaidFences(transformed);
|
||||||
|
transformed = transformMediaEmbeds(transformed);
|
||||||
livePreview.innerHTML = marked.parse(transformed);
|
livePreview.innerHTML = marked.parse(transformed);
|
||||||
// Highlight code blocks
|
// Highlight code blocks
|
||||||
livePreview.querySelectorAll('pre code').forEach(block => {
|
livePreview.querySelectorAll('pre code').forEach(block => {
|
||||||
try { hljs.highlightElement(block); } catch (e) {}
|
try { hljs.highlightElement(block); } catch (e) {}
|
||||||
});
|
});
|
||||||
|
if (window.mermaid) {
|
||||||
|
try { window.mermaid.run({ querySelector: '#live-preview .mermaid' }); } catch (e) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createForm.addEventListener('submit', function(e) {
|
createForm.addEventListener('submit', function(e) {
|
||||||
@@ -238,6 +323,154 @@ console.log('Hello, World!');
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Markdown insertion functions (parity with edit.html)
|
||||||
|
function insertMarkdown(before, after) {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const end = contentTextarea.selectionEnd;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const selectedText = text.substring(start, end);
|
||||||
|
const newText = text.substring(0, start) + before + selectedText + after + text.substring(end);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
const newPos = start + before.length + selectedText.length;
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(newPos, newPos);
|
||||||
|
renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHeadingMenu(e) {
|
||||||
|
const menu = document.getElementById('heading-menu');
|
||||||
|
if (!menu) return;
|
||||||
|
const wasHidden = menu.classList.contains('hidden');
|
||||||
|
menu.classList.remove('hidden');
|
||||||
|
menu.classList.remove('top-full','mt-2','bottom-full','mb-2');
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
const spaceAbove = rect.top;
|
||||||
|
const needed = Math.max(menu.offsetHeight || 192, 192) + 12;
|
||||||
|
if (spaceBelow < needed && spaceAbove >= needed) {
|
||||||
|
menu.classList.add('bottom-full','mb-2','right-0');
|
||||||
|
} else {
|
||||||
|
menu.classList.add('top-full','mt-2','right-0');
|
||||||
|
}
|
||||||
|
if (wasHidden) {
|
||||||
|
const close = (ev) => {
|
||||||
|
if (!menu.contains(ev.target) && ev.target !== btn) {
|
||||||
|
menu.classList.add('hidden');
|
||||||
|
document.removeEventListener('click', close);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => document.addEventListener('click', close), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertHeadingLevel(level) {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const hashes = '#'.repeat(Math.min(Math.max(level,1),6)) + ' ';
|
||||||
|
const newText = text.substring(0, lineStart) + hashes + text.substring(lineStart);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
const newPos = start + hashes.length;
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(newPos, newPos);
|
||||||
|
const menu = document.getElementById('heading-menu');
|
||||||
|
if (menu) menu.classList.add('hidden');
|
||||||
|
renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertList() {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const newText = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(start + 2, start + 2);
|
||||||
|
renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertNumberedList() {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const newText = text.substring(0, lineStart) + '1. ' + text.substring(lineStart);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(start + 3, start + 3);
|
||||||
|
renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertChecklist() {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const newText = text.substring(0, lineStart) + '- [ ] ' + text.substring(lineStart);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(start + 6, start + 6);
|
||||||
|
renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertBlockquote() {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const newText = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(start + 2, start + 2);
|
||||||
|
renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertHr() {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const insert = '\n\n---\n\n';
|
||||||
|
const newText = text.substring(0, start) + insert + text.substring(start);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
const pos = start + insert.length;
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(pos, pos);
|
||||||
|
renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertCodeBlock() {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const insert = '\n```language\n// code\n```\n';
|
||||||
|
const newText = text.substring(0, start) + insert + text.substring(start);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
const pos = start + 4;
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(pos, pos);
|
||||||
|
renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMermaid() {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const tpl = '\n```mermaid\nflowchart TD\n A[Start] --> B{Decision}\n B -- Yes --> C[Do thing]\n B -- No --> D[Something else]\n```\n';
|
||||||
|
const newText = text.substring(0, start) + tpl + text.substring(start);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
const pos = start + 4;
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(pos, pos);
|
||||||
|
renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertTable() {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const tpl = '\n| Col 1 | Col 2 | Col 3 |\n|-------|-------|-------|\n| A1 | B1 | C1 |\n| A2 | B2 | C2 |\n';
|
||||||
|
const newText = text.substring(0, start) + tpl + text.substring(start);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
const pos = start + 3;
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(pos, pos);
|
||||||
|
renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
// Paste-to-upload image
|
// Paste-to-upload image
|
||||||
contentTextarea.addEventListener('paste', async function(e) {
|
contentTextarea.addEventListener('paste', async function(e) {
|
||||||
const items = (e.clipboardData || window.clipboardData).items || [];
|
const items = (e.clipboardData || window.clipboardData).items || [];
|
||||||
|
|||||||
@@ -43,28 +43,63 @@
|
|||||||
|
|
||||||
<!-- Editor Toolbar -->
|
<!-- Editor Toolbar -->
|
||||||
<div class="flex items-center justify-between border-t border-gray-700 pt-4">
|
<div class="flex items-center justify-between border-t border-gray-700 pt-4">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center flex-wrap gap-2">
|
||||||
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('**', '**')" title="Bold">
|
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('**', '**')" title="Bold">
|
||||||
<i class="fas fa-bold"></i>
|
<i class="fas fa-bold"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('*', '*')" title="Italic">
|
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('*', '*')" title="Italic">
|
||||||
<i class="fas fa-italic"></i>
|
<i class="fas fa-italic"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('`', '`')" title="Code">
|
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('~~', '~~')" title="Strikethrough">
|
||||||
|
<i class="fas fa-strikethrough"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('`', '`')" title="Inline code">
|
||||||
<i class="fas fa-code"></i>
|
<i class="fas fa-code"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertCodeBlock()" title="Code block">
|
||||||
|
<i class="fas fa-file-code"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertMermaid()" title="Mermaid diagram">
|
||||||
|
<i class="fas fa-project-diagram"></i>
|
||||||
|
</button>
|
||||||
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('[', '](url)')" title="Link">
|
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('[', '](url)')" title="Link">
|
||||||
<i class="fas fa-link"></i>
|
<i class="fas fa-link"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('')" title="Image">
|
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('')" title="Image">
|
||||||
<i class="fas fa-image"></i>
|
<i class="fas fa-image"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-secondary text-sm" onclick="insertHeading()" title="Heading">
|
<!-- Headings dropdown -->
|
||||||
<i class="fas fa-heading"></i>
|
<div class="relative">
|
||||||
</button>
|
<button type="button" class="btn-secondary text-sm" onclick="toggleHeadingMenu(event)" title="Headings">
|
||||||
<button type="button" class="btn-secondary text-sm" onclick="insertList()" title="List">
|
<i class="fas fa-heading"></i>
|
||||||
|
</button>
|
||||||
|
<div id="heading-menu" class="absolute z-10 hidden bg-slate-800 border border-gray-700 rounded shadow-lg right-0 bottom-full mb-2">
|
||||||
|
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(1)">H1</button>
|
||||||
|
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(2)">H2</button>
|
||||||
|
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(3)">H3</button>
|
||||||
|
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(4)">H4</button>
|
||||||
|
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(5)">H5</button>
|
||||||
|
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(6)">H6</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertList()" title="Bulleted list">
|
||||||
<i class="fas fa-list"></i>
|
<i class="fas fa-list"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertNumberedList()" title="Numbered list">
|
||||||
|
<i class="fas fa-list-ol"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertChecklist()" title="Task list">
|
||||||
|
<i class="fas fa-square-check"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertBlockquote()" title="Blockquote">
|
||||||
|
<i class="fas fa-quote-left"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertHr()" title="Horizontal rule">
|
||||||
|
<i class="fas fa-minus"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary text-sm" onclick="insertTable()" title="Table">
|
||||||
|
<i class="fas fa-table"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xs text-gray-500">
|
<div class="text-xs text-gray-500">
|
||||||
@@ -139,14 +174,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function transformMermaidFences(md) {
|
||||||
|
return (md || '').replace(/```mermaid\n([\s\S]*?)```/g, function(_, inner){
|
||||||
|
return `<div class=\"mermaid\">${inner}</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformMediaEmbeds(md) {
|
||||||
|
const lines = (md || '').split(/\n/);
|
||||||
|
return lines.map(l => {
|
||||||
|
const t = l.trim();
|
||||||
|
if (/^https?:\/\/\S+\.(?:mp3|wav|ogg)$/.test(t)) return `<audio controls preload=\"metadata\" src=\"${t}\"></audio>`;
|
||||||
|
if (/^https?:\/\/\S+\.(?:mp4|webm|ogg)$/.test(t)) return `<video controls preload=\"metadata\" style=\"max-width:100%\" src=\"${t}\"></video>`;
|
||||||
|
if (/^https?:\/\/\S+\.(?:pdf)$/.test(t)) return `<iframe src=\"${t}\" style=\"width:100%;height:70vh;border:1px solid #374151;border-radius:8px\"></iframe>`;
|
||||||
|
return l;
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function renderPreview() {
|
function renderPreview() {
|
||||||
if (!previewEnabled) return;
|
if (!previewEnabled) return;
|
||||||
if (!livePreview || !window.marked) return;
|
if (!livePreview || !window.marked) return;
|
||||||
const transformed = transformObsidianEmbeds(contentTextarea.value || '');
|
let transformed = transformObsidianEmbeds(contentTextarea.value || '');
|
||||||
|
transformed = transformMermaidFences(transformed);
|
||||||
|
transformed = transformMediaEmbeds(transformed);
|
||||||
livePreview.innerHTML = marked.parse(transformed);
|
livePreview.innerHTML = marked.parse(transformed);
|
||||||
livePreview.querySelectorAll('pre code').forEach(block => {
|
livePreview.querySelectorAll('pre code').forEach(block => {
|
||||||
try { hljs.highlightElement(block); } catch (e) {}
|
try { hljs.highlightElement(block); } catch (e) {}
|
||||||
});
|
});
|
||||||
|
if (window.mermaid) {
|
||||||
|
try { window.mermaid.run({ querySelector: '#live-preview .mermaid' }); } catch (e) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
editForm.addEventListener('submit', function(e) {
|
editForm.addEventListener('submit', function(e) {
|
||||||
@@ -247,22 +304,52 @@
|
|||||||
autoResize();
|
autoResize();
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertHeading() {
|
function toggleHeadingMenu(e) {
|
||||||
|
const menu = document.getElementById('heading-menu');
|
||||||
|
if (!menu) return;
|
||||||
|
// Show to measure, then decide direction
|
||||||
|
const wasHidden = menu.classList.contains('hidden');
|
||||||
|
menu.classList.remove('hidden');
|
||||||
|
// Reset placement classes
|
||||||
|
menu.classList.remove('top-full','mt-2','bottom-full','mb-2');
|
||||||
|
// Determine available space
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
const spaceAbove = rect.top;
|
||||||
|
const needed = Math.max(menu.offsetHeight || 192, 192) + 12; // fallback height
|
||||||
|
if (spaceBelow < needed && spaceAbove >= needed) {
|
||||||
|
// Open upwards
|
||||||
|
menu.classList.add('bottom-full','mb-2','right-0');
|
||||||
|
} else {
|
||||||
|
// Open downwards
|
||||||
|
menu.classList.add('top-full','mt-2','right-0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasHidden) {
|
||||||
|
const close = (ev) => {
|
||||||
|
if (!menu.contains(ev.target) && ev.target !== btn) {
|
||||||
|
menu.classList.add('hidden');
|
||||||
|
document.removeEventListener('click', close);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => document.addEventListener('click', close), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertHeadingLevel(level) {
|
||||||
const start = contentTextarea.selectionStart;
|
const start = contentTextarea.selectionStart;
|
||||||
const text = contentTextarea.value;
|
const text = contentTextarea.value;
|
||||||
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||||
const lineText = text.substring(lineStart, start);
|
const hashes = '#'.repeat(Math.min(Math.max(level,1),6)) + ' ';
|
||||||
|
const newText = text.substring(0, lineStart) + hashes + text.substring(lineStart);
|
||||||
if (lineText.startsWith('# ')) {
|
|
||||||
return; // Already a heading
|
|
||||||
}
|
|
||||||
|
|
||||||
const newText = text.substring(0, lineStart) + '# ' + text.substring(lineStart);
|
|
||||||
contentTextarea.value = newText;
|
contentTextarea.value = newText;
|
||||||
|
const newPos = start + hashes.length;
|
||||||
contentTextarea.focus();
|
contentTextarea.focus();
|
||||||
contentTextarea.setSelectionRange(start + 2, start + 2);
|
contentTextarea.setSelectionRange(newPos, newPos);
|
||||||
|
|
||||||
autoResize();
|
autoResize();
|
||||||
|
const menu = document.getElementById('heading-menu');
|
||||||
|
if (menu) menu.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertList() {
|
function insertList() {
|
||||||
@@ -278,6 +365,88 @@
|
|||||||
autoResize();
|
autoResize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function insertNumberedList() {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const newText = text.substring(0, lineStart) + '1. ' + text.substring(lineStart);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(start + 3, start + 3);
|
||||||
|
autoResize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertChecklist() {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const newText = text.substring(0, lineStart) + '- [ ] ' + text.substring(lineStart);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(start + 6, start + 6);
|
||||||
|
autoResize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertBlockquote() {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const newText = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(start + 2, start + 2);
|
||||||
|
autoResize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertHr() {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const insert = '\n\n---\n\n';
|
||||||
|
const newText = text.substring(0, start) + insert + text.substring(start);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
const pos = start + insert.length;
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(pos, pos);
|
||||||
|
autoResize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertCodeBlock() {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const insert = '\n```language\n// code\n```\n';
|
||||||
|
const newText = text.substring(0, start) + insert + text.substring(start);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
const pos = start + 4; // place cursor after first backticks
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(pos, pos);
|
||||||
|
autoResize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMermaid() {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const tpl = '\n```mermaid\nflowchart TD\n A[Start] --> B{Decision}\n B -- Yes --> C[Do thing]\n B -- No --> D[Something else]\n```\n';
|
||||||
|
const newText = text.substring(0, start) + tpl + text.substring(start);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
const pos = start + 4; // cursor after opening backticks
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(pos, pos);
|
||||||
|
autoResize();
|
||||||
|
renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertTable() {
|
||||||
|
const start = contentTextarea.selectionStart;
|
||||||
|
const text = contentTextarea.value;
|
||||||
|
const tpl = '\n| Col 1 | Col 2 | Col 3 |\n|-------|-------|-------|\n| A1 | B1 | C1 |\n| A2 | B2 | C2 |\n';
|
||||||
|
const newText = text.substring(0, start) + tpl + text.substring(start);
|
||||||
|
contentTextarea.value = newText;
|
||||||
|
const pos = start + 3; // inside first cell
|
||||||
|
contentTextarea.focus();
|
||||||
|
contentTextarea.setSelectionRange(pos, pos);
|
||||||
|
autoResize();
|
||||||
|
}
|
||||||
|
|
||||||
// Paste-to-upload image
|
// Paste-to-upload image
|
||||||
contentTextarea.addEventListener('paste', async function(e) {
|
contentTextarea.addEventListener('paste', async function(e) {
|
||||||
const items = (e.clipboardData || window.clipboardData).items || [];
|
const items = (e.clipboardData || window.clipboardData).items || [];
|
||||||
|
|||||||
@@ -12,22 +12,6 @@
|
|||||||
|
|
||||||
<!-- Settings Sections -->
|
<!-- Settings Sections -->
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<!-- Quick Actions -->
|
|
||||||
<div class="bg-gray-800 rounded-lg p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-semibold text-white mb-1">
|
|
||||||
<i class="fas fa-tools mr-2"></i>Admin Tools
|
|
||||||
</h2>
|
|
||||||
<p class="text-gray-400">Access logs and security controls</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<a href="{{url "/editor/admin/logs"}}" target="_blank" class="btn-secondary inline-flex items-center">
|
|
||||||
<i class="fas fa-list mr-2"></i>View Logs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Image Storage Settings -->
|
<!-- Image Storage Settings -->
|
||||||
<div class="bg-gray-800 rounded-lg p-6">
|
<div class="bg-gray-800 rounded-lg p-6">
|
||||||
<h2 class="text-xl font-semibold text-white mb-4">
|
<h2 class="text-xl font-semibold text-white mb-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user