609 lines
17 KiB
Go
609 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Page struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Filename string `json:"filename"`
|
|
HTML string `json:"html"`
|
|
Tags []string `json:"tags"`
|
|
Views int `json:"views"`
|
|
Created time.Time `json:"created"`
|
|
}
|
|
|
|
type Store struct {
|
|
Pages map[string]*Page `json:"pages"`
|
|
mu sync.RWMutex
|
|
filename string
|
|
}
|
|
|
|
var (
|
|
store *Store
|
|
sessions = make(map[string]time.Time)
|
|
sessMu sync.RWMutex
|
|
password string
|
|
port string
|
|
)
|
|
|
|
func loadEnv() {
|
|
password = "htmlkit-admin" // default
|
|
port = "8080" // default
|
|
|
|
file, err := os.Open(".env")
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
key, val := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
|
switch key {
|
|
case "PASSWORD":
|
|
password = val
|
|
case "PORT":
|
|
port = val
|
|
}
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
loadEnv()
|
|
|
|
store = &Store{
|
|
Pages: make(map[string]*Page),
|
|
filename: "config/pages.json",
|
|
}
|
|
store.load()
|
|
|
|
os.MkdirAll("pages", 0755)
|
|
os.MkdirAll("config", 0755)
|
|
|
|
http.HandleFunc("/", handleIndex)
|
|
http.HandleFunc("/login", handleLogin)
|
|
http.HandleFunc("/logout", handleLogout)
|
|
http.HandleFunc("/admin", authMiddleware(handleAdmin))
|
|
http.HandleFunc("/api/pages", authMiddleware(handleAPIPages))
|
|
http.HandleFunc("/api/page/", handleAPIPage)
|
|
http.HandleFunc("/p/", handleViewPage)
|
|
|
|
log.Printf("Server starting on :%s (password: %s)\n", port, password)
|
|
log.Fatal(http.ListenAndServe(":"+port, nil))
|
|
}
|
|
|
|
func (s *Store) load() {
|
|
data, err := os.ReadFile(s.filename)
|
|
if err != nil {
|
|
return
|
|
}
|
|
json.Unmarshal(data, s)
|
|
}
|
|
|
|
func (s *Store) save() error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
data, _ := json.MarshalIndent(s, "", " ")
|
|
return os.WriteFile(s.filename, data, 0644)
|
|
}
|
|
|
|
func generateID() string {
|
|
b := make([]byte, 8)
|
|
rand.Read(b)
|
|
return base64.URLEncoding.EncodeToString(b)[:11]
|
|
}
|
|
|
|
func validFilename(s string) bool {
|
|
match, _ := regexp.MatchString(`^[a-zA-Z0-9_-]+$`, s)
|
|
return match && len(s) > 0 && len(s) < 100
|
|
}
|
|
|
|
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie("session")
|
|
if err != nil {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
sessMu.RLock()
|
|
exp, ok := sessions[cookie.Value]
|
|
sessMu.RUnlock()
|
|
if !ok || time.Now().After(exp) {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
store.mu.RLock()
|
|
pages := make([]*Page, 0, len(store.Pages))
|
|
for _, p := range store.Pages {
|
|
pages = append(pages, p)
|
|
}
|
|
store.mu.RUnlock()
|
|
|
|
sort.Slice(pages, func(i, j int) bool {
|
|
return pages[i].Created.After(pages[j].Created)
|
|
})
|
|
|
|
tmpl := template.Must(template.New("index").Parse(indexHTML))
|
|
tmpl.Execute(w, pages)
|
|
}
|
|
|
|
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "GET" {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte(loginHTML))
|
|
return
|
|
}
|
|
|
|
if r.FormValue("password") == password {
|
|
token := generateID()
|
|
sessMu.Lock()
|
|
sessions[token] = time.Now().Add(24 * time.Hour)
|
|
sessMu.Unlock()
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "session",
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
MaxAge: 86400,
|
|
})
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/login?error=1", http.StatusSeeOther)
|
|
}
|
|
|
|
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
cookie, _ := r.Cookie("session")
|
|
if cookie != nil {
|
|
sessMu.Lock()
|
|
delete(sessions, cookie.Value)
|
|
sessMu.Unlock()
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "session",
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
})
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
func handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|
store.mu.RLock()
|
|
pages := make([]*Page, 0, len(store.Pages))
|
|
for _, p := range store.Pages {
|
|
pages = append(pages, p)
|
|
}
|
|
store.mu.RUnlock()
|
|
|
|
sort.Slice(pages, func(i, j int) bool {
|
|
return pages[i].Created.After(pages[j].Created)
|
|
})
|
|
|
|
tmpl := template.Must(template.New("admin").Parse(adminHTML))
|
|
tmpl.Execute(w, pages)
|
|
}
|
|
|
|
func handleAPIPages(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case "POST":
|
|
createPage(w, r)
|
|
case "PUT":
|
|
updatePage(w, r)
|
|
case "DELETE":
|
|
deletePage(w, r)
|
|
default:
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func createPage(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Filename string `json:"filename"`
|
|
HTML string `json:"html"`
|
|
Tags []string `json:"tags"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if !validFilename(req.Filename) {
|
|
http.Error(w, "Invalid filename", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if len(req.HTML) > 1024*1024 {
|
|
http.Error(w, "HTML too large", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
page := &Page{
|
|
ID: generateID(),
|
|
Name: req.Name,
|
|
Filename: req.Filename,
|
|
HTML: req.HTML,
|
|
Tags: req.Tags,
|
|
Created: time.Now(),
|
|
}
|
|
|
|
store.mu.Lock()
|
|
store.Pages[page.ID] = page
|
|
store.mu.Unlock()
|
|
|
|
if err := os.WriteFile(filepath.Join("pages", req.Filename+".html"), []byte(req.HTML), 0644); err != nil {
|
|
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
store.save()
|
|
json.NewEncoder(w).Encode(page)
|
|
}
|
|
|
|
func updatePage(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Filename string `json:"filename"`
|
|
HTML string `json:"html"`
|
|
Tags []string `json:"tags"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
store.mu.Lock()
|
|
page, ok := store.Pages[req.ID]
|
|
if !ok {
|
|
store.mu.Unlock()
|
|
http.Error(w, "Page not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
oldFilename := page.Filename
|
|
page.Name = req.Name
|
|
page.Filename = req.Filename
|
|
page.HTML = req.HTML
|
|
page.Tags = req.Tags
|
|
store.mu.Unlock()
|
|
|
|
if oldFilename != req.Filename {
|
|
os.Remove(filepath.Join("pages", oldFilename+".html"))
|
|
}
|
|
|
|
os.WriteFile(filepath.Join("pages", req.Filename+".html"), []byte(req.HTML), 0644)
|
|
store.save()
|
|
json.NewEncoder(w).Encode(page)
|
|
}
|
|
|
|
func deletePage(w http.ResponseWriter, r *http.Request) {
|
|
id := r.URL.Query().Get("id")
|
|
|
|
store.mu.Lock()
|
|
page, ok := store.Pages[id]
|
|
if !ok {
|
|
store.mu.Unlock()
|
|
http.Error(w, "Page not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
delete(store.Pages, id)
|
|
store.mu.Unlock()
|
|
|
|
os.Remove(filepath.Join("pages", page.Filename+".html"))
|
|
store.save()
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func handleAPIPage(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/page/")
|
|
|
|
store.mu.RLock()
|
|
page, ok := store.Pages[id]
|
|
store.mu.RUnlock()
|
|
|
|
if !ok {
|
|
http.Error(w, "Page not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(page)
|
|
}
|
|
|
|
func handleViewPage(w http.ResponseWriter, r *http.Request) {
|
|
filename := strings.TrimPrefix(r.URL.Path, "/p/")
|
|
filename = strings.TrimSuffix(filename, ".html")
|
|
|
|
store.mu.Lock()
|
|
var page *Page
|
|
for _, p := range store.Pages {
|
|
if p.Filename == filename {
|
|
p.Views++
|
|
page = p
|
|
break
|
|
}
|
|
}
|
|
store.mu.Unlock()
|
|
|
|
if page == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
store.save()
|
|
|
|
file, err := os.Open(filepath.Join("pages", filename+".html"))
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src * data:; font-src *")
|
|
io.Copy(w, file)
|
|
}
|
|
|
|
const indexHTML = `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>HTML Tools</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: system-ui, -apple-system, sans-serif; padding: 20px; background: #f5f5f5; }
|
|
body.dark { background: #1a1a1a; color: #e0e0e0; }
|
|
.header { max-width: 1200px; margin: 0 auto 30px; display: flex; justify-content: space-between; align-items: center; }
|
|
.header h1 { font-size: 28px; }
|
|
.controls { display: flex; gap: 10px; align-items: center; }
|
|
.search { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; width: 200px; }
|
|
body.dark .search { background: #2a2a2a; border-color: #444; color: #e0e0e0; }
|
|
.btn { padding: 8px 16px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; display: inline-block; }
|
|
.btn:hover { background: #0056b3; }
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; max-width: 1200px; margin: 0 auto; }
|
|
.card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); transition: transform 0.2s; }
|
|
body.dark .card { background: #2a2a2a; box-shadow: 0 2px 4px rgba(0,0,0,0.3); }
|
|
.card:hover { transform: translateY(-2px); }
|
|
.card h3 { margin-bottom: 10px; font-size: 18px; }
|
|
.card .meta { color: #666; font-size: 14px; margin-bottom: 10px; }
|
|
body.dark .card .meta { color: #999; }
|
|
.tags { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 10px; }
|
|
.tag { background: #e9ecef; padding: 3px 8px; border-radius: 3px; font-size: 12px; }
|
|
body.dark .tag { background: #3a3a3a; }
|
|
.views { color: #999; font-size: 12px; margin-top: 5px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>HTML Tools</h1>
|
|
<div class="controls">
|
|
<input type="text" class="search" placeholder="Search..." id="search">
|
|
<select id="sort" class="search" style="width:auto">
|
|
<option value="date">Sort by Date</option>
|
|
<option value="name">Sort by Name</option>
|
|
<option value="views">Sort by Views</option>
|
|
</select>
|
|
<button class="btn" onclick="toggleDark()">🌓</button>
|
|
<a href="/login" class="btn">Admin</a>
|
|
</div>
|
|
</div>
|
|
<div class="grid" id="grid">
|
|
{{range .}}
|
|
<div class="card" data-name="{{.Name}}" data-tags="{{range .Tags}}{{.}} {{end}}">
|
|
<h3><a href="/p/{{.Filename}}.html" target="_blank" style="color:inherit;text-decoration:none">{{.Name}}</a></h3>
|
|
<div class="meta">{{.Created.Format "2006-01-02 15:04"}}</div>
|
|
{{if .Tags}}<div class="tags">{{range .Tags}}<span class="tag">{{.}}</span>{{end}}</div>{{end}}
|
|
<div class="views">👁 {{.Views}} views</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
<script>
|
|
if(localStorage.dark==='1') document.body.classList.add('dark');
|
|
function toggleDark(){ document.body.classList.toggle('dark'); localStorage.dark=document.body.classList.contains('dark')?'1':'0'; }
|
|
const cards = [...document.querySelectorAll('.card')];
|
|
document.getElementById('search').oninput = e => {
|
|
const q = e.target.value.toLowerCase();
|
|
cards.forEach(c => c.style.display = (c.dataset.name.toLowerCase().includes(q) || c.dataset.tags.toLowerCase().includes(q)) ? '' : 'none');
|
|
};
|
|
document.getElementById('sort').onchange = e => {
|
|
const grid = document.getElementById('grid');
|
|
const sorted = [...cards].sort((a,b) => {
|
|
if(e.target.value==='name') return a.dataset.name.localeCompare(b.dataset.name);
|
|
if(e.target.value==='views') return parseInt(b.querySelector('.views').textContent.match(/\d+/)[0]) - parseInt(a.querySelector('.views').textContent.match(/\d+/)[0]);
|
|
return 0;
|
|
});
|
|
sorted.forEach(c => grid.appendChild(c));
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>`
|
|
|
|
const loginHTML = `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Login</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: system-ui; display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #f5f5f5; }
|
|
.box { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); width: 300px; }
|
|
h2 { margin-bottom: 20px; }
|
|
input { width: 100%; padding: 10px; margin-bottom: 15px; border: 1px solid #ddd; border-radius: 4px; }
|
|
button { width: 100%; padding: 10px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
|
button:hover { background: #0056b3; }
|
|
.error { color: red; font-size: 14px; margin-bottom: 10px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="box">
|
|
<h2>Admin Login</h2>
|
|
<form method="post">
|
|
<input type="password" name="password" placeholder="Password" required autofocus>
|
|
<button type="submit">Login</button>
|
|
</form>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
|
|
const adminHTML = `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Admin</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: system-ui; padding: 20px; background: #f5f5f5; }
|
|
body.dark { background: #1a1a1a; color: #e0e0e0; }
|
|
.header { max-width: 1200px; margin: 0 auto 30px; display: flex; justify-content: space-between; align-items: center; }
|
|
.btn { padding: 8px 16px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; }
|
|
.btn:hover { background: #0056b3; }
|
|
.btn-danger { background: #dc3545; }
|
|
.btn-danger:hover { background: #c82333; }
|
|
.container { max-width: 1200px; margin: 0 auto; }
|
|
.form { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
|
|
body.dark .form { background: #2a2a2a; }
|
|
.form input, .form textarea { width: 100%; padding: 10px; margin-bottom: 10px; border: 1px solid #ddd; border-radius: 4px; }
|
|
body.dark .form input, body.dark .form textarea { background: #1a1a1a; border-color: #444; color: #e0e0e0; }
|
|
.form textarea { min-height: 200px; font-family: monospace; }
|
|
.list { background: white; padding: 20px; border-radius: 8px; }
|
|
body.dark .list { background: #2a2a2a; }
|
|
.item { padding: 15px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
|
|
body.dark .item { border-color: #444; }
|
|
.item:last-child { border: none; }
|
|
.actions { display: flex; gap: 10px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>Admin Panel</h1>
|
|
<div>
|
|
<button class="btn" onclick="toggleDark()">🌓</button>
|
|
<a href="/" class="btn">Home</a>
|
|
<a href="/logout" class="btn">Logout</a>
|
|
</div>
|
|
</div>
|
|
<div class="container">
|
|
<div class="form">
|
|
<h2 id="formTitle">Create New Page</h2>
|
|
<input type="hidden" id="pageId">
|
|
<input type="text" id="name" placeholder="Page Name" required>
|
|
<input type="text" id="filename" placeholder="Filename (no extension)" required>
|
|
<input type="text" id="tags" placeholder="Tags (comma separated)">
|
|
<textarea id="html" placeholder="HTML Code"></textarea>
|
|
<button class="btn" onclick="savePage()">Save</button>
|
|
<button class="btn" onclick="resetForm()" style="background:#6c757d">Cancel</button>
|
|
<button class="btn" onclick="previewPage()" style="background:#28a745">Preview</button>
|
|
</div>
|
|
<div class="list">
|
|
<h2>Pages</h2>
|
|
{{range .}}
|
|
<div class="item">
|
|
<div>
|
|
<strong>{{.Name}}</strong><br>
|
|
<small>{{.Filename}}.html | {{.Created.Format "2006-01-02"}} | 👁 {{.Views}}</small>
|
|
</div>
|
|
<div class="actions">
|
|
<a href="/p/{{.Filename}}.html" target="_blank" class="btn">View</a>
|
|
<button class="btn" onclick="editPage('{{.ID}}')">Edit</button>
|
|
<button class="btn btn-danger" onclick="deletePage('{{.ID}}')">Delete</button>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
<script>
|
|
if(localStorage.dark==='1') document.body.classList.add('dark');
|
|
function toggleDark(){ document.body.classList.toggle('dark'); localStorage.dark=document.body.classList.contains('dark')?'1':'0'; }
|
|
|
|
async function savePage() {
|
|
const id = document.getElementById('pageId').value;
|
|
const data = {
|
|
id,
|
|
name: document.getElementById('name').value,
|
|
filename: document.getElementById('filename').value,
|
|
html: document.getElementById('html').value,
|
|
tags: document.getElementById('tags').value.split(',').map(t=>t.trim()).filter(t=>t)
|
|
};
|
|
const method = id ? 'PUT' : 'POST';
|
|
const res = await fetch('/api/pages', { method, headers: {'Content-Type':'application/json'}, body: JSON.stringify(data) });
|
|
if(res.ok) { location.reload(); } else { alert('Error: ' + await res.text()); }
|
|
}
|
|
|
|
async function editPage(id) {
|
|
const res = await fetch('/api/page/' + id);
|
|
const page = await res.json();
|
|
document.getElementById('formTitle').textContent = 'Edit Page';
|
|
document.getElementById('pageId').value = page.id;
|
|
document.getElementById('name').value = page.name;
|
|
document.getElementById('filename').value = page.filename;
|
|
document.getElementById('html').value = page.html;
|
|
document.getElementById('tags').value = (page.tags || []).join(', ');
|
|
window.scrollTo(0, 0);
|
|
}
|
|
|
|
async function deletePage(id) {
|
|
if(!confirm('Delete this page?')) return;
|
|
await fetch('/api/pages?id=' + id, { method: 'DELETE' });
|
|
location.reload();
|
|
}
|
|
|
|
function resetForm() {
|
|
document.getElementById('formTitle').textContent = 'Create New Page';
|
|
document.getElementById('pageId').value = '';
|
|
document.getElementById('name').value = '';
|
|
document.getElementById('filename').value = '';
|
|
document.getElementById('html').value = '';
|
|
document.getElementById('tags').value = '';
|
|
}
|
|
|
|
function previewPage() {
|
|
const html = document.getElementById('html').value;
|
|
const win = window.open('', '_blank');
|
|
win.document.write(html);
|
|
win.document.close();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`
|