feat(server): add owner-tools injection and inline code theme CSS
- Add owner-tools serving and injection for blog owners - Inline code theme CSS in templates for soft reload support - Update import paths from github.com/writekitapp to writekit - Add optional session middleware for owner detection - Update platform index with improved UI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
771ff7615a
commit
119e3b7a6d
11 changed files with 838 additions and 483 deletions
|
|
@ -6,8 +6,8 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/writekitapp/writekit/internal/auth"
|
||||
"github.com/writekitapp/writekit/internal/tenant"
|
||||
"writekit/internal/auth"
|
||||
"writekit/internal/tenant"
|
||||
)
|
||||
|
||||
func (s *Server) publicAPIRoutes() chi.Router {
|
||||
|
|
|
|||
|
|
@ -16,13 +16,13 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/writekitapp/writekit/internal/auth"
|
||||
"github.com/writekitapp/writekit/internal/build/assets"
|
||||
"github.com/writekitapp/writekit/internal/build/templates"
|
||||
"github.com/writekitapp/writekit/internal/config"
|
||||
"github.com/writekitapp/writekit/internal/markdown"
|
||||
"github.com/writekitapp/writekit/internal/tenant"
|
||||
"github.com/writekitapp/writekit/studio"
|
||||
"writekit/internal/auth"
|
||||
"writekit/internal/tenant/assets"
|
||||
"writekit/internal/tenant/templates"
|
||||
"writekit/internal/config"
|
||||
"writekit/internal/markdown"
|
||||
"writekit/internal/tenant"
|
||||
"writekit/frontends/studio"
|
||||
)
|
||||
|
||||
func (s *Server) serveBlog(w http.ResponseWriter, r *http.Request, subdomain string) {
|
||||
|
|
@ -59,12 +59,14 @@ func (s *Server) serveBlog(w http.ResponseWriter, r *http.Request, subdomain str
|
|||
r = r.WithContext(ctx)
|
||||
|
||||
mux := chi.NewRouter()
|
||||
mux.Use(auth.OptionalSessionMiddleware(s.database))
|
||||
|
||||
mux.Get("/", s.blogHome)
|
||||
mux.Get("/posts", s.blogList)
|
||||
mux.Get("/posts/{slug}", s.blogPost)
|
||||
|
||||
mux.Handle("/static/*", http.StripPrefix("/static/", assets.Handler()))
|
||||
mux.Handle("/@owner-tools/*", http.HandlerFunc(s.serveOwnerTools))
|
||||
|
||||
mux.Route("/api/studio", func(r chi.Router) {
|
||||
r.Use(demoAwareSessionMiddleware(s.database))
|
||||
|
|
@ -638,8 +640,20 @@ func computeETag(data []byte) string {
|
|||
}
|
||||
|
||||
func (s *Server) servePreRendered(w http.ResponseWriter, r *http.Request, html []byte, etag, cacheControl string) {
|
||||
tenantID, _ := r.Context().Value(tenantIDKey).(string)
|
||||
modified := false
|
||||
|
||||
if demoInfo := GetDemoInfo(r); demoInfo.IsDemo {
|
||||
html = s.injectDemoBanner(html, demoInfo.ExpiresAt)
|
||||
modified = true
|
||||
}
|
||||
|
||||
if s.isOwner(r, tenantID) {
|
||||
html = s.injectOwnerTools(html)
|
||||
modified = true
|
||||
}
|
||||
|
||||
if modified {
|
||||
etag = computeETag(html)
|
||||
}
|
||||
|
||||
|
|
@ -701,3 +715,49 @@ func timeOrZero(t *time.Time) time.Time {
|
|||
}
|
||||
return *t
|
||||
}
|
||||
|
||||
func (s *Server) isOwner(r *http.Request, tenantID string) bool {
|
||||
if GetDemoInfo(r).IsDemo {
|
||||
return true
|
||||
}
|
||||
userID := auth.GetUserID(r)
|
||||
if userID == "" {
|
||||
return false
|
||||
}
|
||||
isOwner, err := s.database.IsUserTenantOwner(r.Context(), userID, tenantID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return isOwner
|
||||
}
|
||||
|
||||
func (s *Server) injectOwnerTools(html []byte) []byte {
|
||||
var script string
|
||||
if s.ownerToolsURL != "" {
|
||||
script = `<script type="module" src="/@owner-tools/src/main.tsx"></script>`
|
||||
} else {
|
||||
script = `<script src="/static/js/owner-tools.js"></script>`
|
||||
}
|
||||
return bytes.Replace(html, []byte("</body>"), []byte(script+"</body>"), 1)
|
||||
}
|
||||
|
||||
func (s *Server) serveOwnerTools(w http.ResponseWriter, r *http.Request) {
|
||||
if s.ownerToolsURL == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
target, err := url.Parse(s.ownerToolsURL)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
proxy.Director = func(req *http.Request) {
|
||||
req.URL.Scheme = target.Scheme
|
||||
req.URL.Host = target.Host
|
||||
req.Host = target.Host
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/writekitapp/writekit/internal/build/templates"
|
||||
"github.com/writekitapp/writekit/internal/markdown"
|
||||
"github.com/writekitapp/writekit/internal/tenant"
|
||||
"writekit/internal/tenant/templates"
|
||||
"writekit/internal/markdown"
|
||||
"writekit/internal/tenant"
|
||||
)
|
||||
|
||||
type renderedPage struct {
|
||||
|
|
@ -39,6 +39,7 @@ func (s *Server) rebuildSite(ctx context.Context, tenantID string, db *sql.DB, h
|
|||
codeTheme := getSettingOr(settings, "code_theme", "github")
|
||||
fontKey := getSettingOr(settings, "font", "system")
|
||||
isDemo := getSettingOr(settings, "is_demo", "") == "true"
|
||||
codeThemeCSS, _ := markdown.GenerateChromaCSS(codeTheme)
|
||||
|
||||
pageData := templates.PageData{
|
||||
SiteName: siteName,
|
||||
|
|
@ -47,6 +48,7 @@ func (s *Server) rebuildSite(ctx context.Context, tenantID string, db *sql.DB, h
|
|||
FontFamily: templates.GetFontFamily(fontKey),
|
||||
Settings: settingsToMap(settings),
|
||||
NoIndex: isDemo,
|
||||
CodeThemeCSS: template.CSS(codeThemeCSS),
|
||||
}
|
||||
|
||||
var pages []renderedPage
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import (
|
|||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/writekitapp/writekit/internal/auth"
|
||||
"github.com/writekitapp/writekit/internal/db"
|
||||
"github.com/writekitapp/writekit/internal/tenant"
|
||||
"writekit/internal/auth"
|
||||
"writekit/internal/db"
|
||||
"writekit/internal/tenant"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/writekitapp/writekit/internal/auth"
|
||||
"writekit/internal/auth"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/writekitapp/writekit/internal/config"
|
||||
"writekit/internal/config"
|
||||
)
|
||||
|
||||
type bucket struct {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/writekitapp/writekit/internal/tenant"
|
||||
"writekit/internal/tenant"
|
||||
)
|
||||
|
||||
func (s *Server) readerRoutes() chi.Router {
|
||||
|
|
|
|||
|
|
@ -11,12 +11,12 @@ import (
|
|||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/writekitapp/writekit/internal/auth"
|
||||
"github.com/writekitapp/writekit/internal/cloudflare"
|
||||
"github.com/writekitapp/writekit/internal/db"
|
||||
"github.com/writekitapp/writekit/internal/imaginary"
|
||||
"github.com/writekitapp/writekit/internal/storage"
|
||||
"github.com/writekitapp/writekit/internal/tenant"
|
||||
"writekit/internal/auth"
|
||||
"writekit/internal/cloudflare"
|
||||
"writekit/internal/db"
|
||||
"writekit/internal/imaginary"
|
||||
"writekit/internal/storage"
|
||||
"writekit/internal/tenant"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
|
|
@ -30,6 +30,7 @@ type Server struct {
|
|||
rateLimiter *RateLimiter
|
||||
domain string
|
||||
jarvisURL string
|
||||
ownerToolsURL string
|
||||
stopCleanup chan struct{}
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +45,8 @@ func New(database *db.DB, pool *tenant.Pool, cache *tenant.Cache, storageClient
|
|||
jarvisURL = "http://localhost:8090"
|
||||
}
|
||||
|
||||
ownerToolsURL := os.Getenv("OWNER_TOOLS_URL")
|
||||
|
||||
var imgClient *imaginary.Client
|
||||
if url := os.Getenv("IMAGINARY_URL"); url != "" {
|
||||
imgClient = imaginary.New(url)
|
||||
|
|
@ -62,6 +65,7 @@ func New(database *db.DB, pool *tenant.Pool, cache *tenant.Cache, storageClient
|
|||
rateLimiter: NewRateLimiter(),
|
||||
domain: domain,
|
||||
jarvisURL: jarvisURL,
|
||||
ownerToolsURL: ownerToolsURL,
|
||||
stopCleanup: make(chan struct{}),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ import (
|
|||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/writekitapp/writekit/internal/config"
|
||||
"github.com/writekitapp/writekit/internal/db"
|
||||
"github.com/writekitapp/writekit/internal/imaginary"
|
||||
"github.com/writekitapp/writekit/internal/markdown"
|
||||
"github.com/writekitapp/writekit/internal/tenant"
|
||||
"writekit/internal/config"
|
||||
"writekit/internal/db"
|
||||
"writekit/internal/imaginary"
|
||||
"writekit/internal/markdown"
|
||||
"writekit/internal/tenant"
|
||||
)
|
||||
|
||||
func renderContent(q *tenant.Queries, r *http.Request, content string) string {
|
||||
|
|
@ -52,6 +52,7 @@ func (s *Server) studioRoutes() chi.Router {
|
|||
|
||||
r.Get("/settings", s.getSettings)
|
||||
r.Put("/settings", s.updateSettings)
|
||||
r.Get("/settings/schema", s.getSettingsSchema)
|
||||
|
||||
r.Get("/interaction-config", s.getStudioInteractionConfig)
|
||||
r.Put("/interaction-config", s.updateInteractionConfig)
|
||||
|
|
@ -93,9 +94,8 @@ func (s *Server) studioRoutes() chi.Router {
|
|||
r.Get("/sdk", s.getSDK)
|
||||
r.Get("/lsp", s.proxyLSP)
|
||||
|
||||
// Code themes
|
||||
// Code theme CSS
|
||||
r.Get("/code-theme.css", s.codeThemeCSS)
|
||||
r.Get("/code-themes", s.listCodeThemes)
|
||||
|
||||
// Plugin testing
|
||||
r.Post("/plugins/test", s.testPlugin)
|
||||
|
|
@ -818,6 +818,89 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
|
|||
jsonResponse(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
type settingOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type settingDefinition struct {
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Options []settingOption `json:"options,omitempty"`
|
||||
Default string `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) getSettingsSchema(w http.ResponseWriter, r *http.Request) {
|
||||
schema := []settingDefinition{
|
||||
{
|
||||
Key: "accent_color",
|
||||
Type: "color",
|
||||
Label: "Accent Color",
|
||||
Default: "#10b981",
|
||||
},
|
||||
{
|
||||
Key: "font",
|
||||
Type: "select",
|
||||
Label: "Font",
|
||||
Options: []settingOption{
|
||||
{Value: "system", Label: "System Default"},
|
||||
{Value: "inter", Label: "Inter"},
|
||||
{Value: "georgia", Label: "Georgia"},
|
||||
{Value: "merriweather", Label: "Merriweather"},
|
||||
{Value: "source-serif", Label: "Source Serif"},
|
||||
{Value: "jetbrains-mono", Label: "JetBrains Mono"},
|
||||
},
|
||||
Default: "system",
|
||||
},
|
||||
{
|
||||
Key: "code_theme",
|
||||
Type: "select",
|
||||
Label: "Code Theme",
|
||||
Options: []settingOption{
|
||||
{Value: "github", Label: "GitHub Light"},
|
||||
{Value: "github-dark", Label: "GitHub Dark"},
|
||||
{Value: "vs", Label: "VS Light"},
|
||||
{Value: "xcode", Label: "Xcode Light"},
|
||||
{Value: "xcode-dark", Label: "Xcode Dark"},
|
||||
{Value: "solarized-light", Label: "Solarized Light"},
|
||||
{Value: "solarized-dark", Label: "Solarized Dark"},
|
||||
{Value: "gruvbox-light", Label: "Gruvbox Light"},
|
||||
{Value: "gruvbox", Label: "Gruvbox Dark"},
|
||||
{Value: "nord", Label: "Nord"},
|
||||
{Value: "onedark", Label: "One Dark"},
|
||||
{Value: "dracula", Label: "Dracula"},
|
||||
{Value: "monokai", Label: "Monokai"},
|
||||
},
|
||||
Default: "github",
|
||||
},
|
||||
{
|
||||
Key: "layout",
|
||||
Type: "select",
|
||||
Label: "Layout",
|
||||
Options: []settingOption{
|
||||
{Value: "default", Label: "Classic"},
|
||||
{Value: "minimal", Label: "Minimal"},
|
||||
{Value: "magazine", Label: "Magazine"},
|
||||
},
|
||||
Default: "default",
|
||||
},
|
||||
{
|
||||
Key: "compactness",
|
||||
Type: "select",
|
||||
Label: "Density",
|
||||
Options: []settingOption{
|
||||
{Value: "compact", Label: "Compact"},
|
||||
{Value: "cozy", Label: "Cozy"},
|
||||
{Value: "spacious", Label: "Spacious"},
|
||||
},
|
||||
Default: "cozy",
|
||||
},
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, schema)
|
||||
}
|
||||
|
||||
func (s *Server) listAssets(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID := r.Context().Value(tenantIDKey).(string)
|
||||
|
||||
|
|
@ -1789,11 +1872,6 @@ func (s *Server) codeThemeCSS(w http.ResponseWriter, r *http.Request) {
|
|||
w.Write([]byte(css))
|
||||
}
|
||||
|
||||
func (s *Server) listCodeThemes(w http.ResponseWriter, r *http.Request) {
|
||||
themes := markdown.ListThemes()
|
||||
jsonResponse(w, http.StatusOK, themes)
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/writekitapp/writekit/internal/tenant"
|
||||
"writekit/internal/tenant"
|
||||
)
|
||||
|
||||
func (s *Server) StartAnalyticsSync() {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,28 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WriteKit — Full Blogging Platform. Lightweight. Yours.</title>
|
||||
<title>WriteKit — Blogging Platform for Developers</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/writekit-icon.ico" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/writekit-icon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/assets/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/assets/favicon-16x16.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/assets/apple-touch-icon.png"
|
||||
/>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{--bg:#fafafa;--text:#0a0a0a;--muted:#737373;--border:#e5e5e5;--accent:{{ACCENT}};--primary:#10b981}
|
||||
|
|
@ -121,8 +135,8 @@
|
|||
@media(max-width:768px){.features-grid{grid-template-columns:1fr}}
|
||||
@media(max-width:480px){.hero,.section{padding:2rem 1.5rem}.footer-links{flex-direction:column;gap:2rem}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<div>
|
||||
|
|
@ -132,7 +146,7 @@
|
|||
<nav class="sidebar-nav">
|
||||
<a href="#why">Why WriteKit</a>
|
||||
<a href="#features">Features</a>
|
||||
<a href="/signup" style="color:var(--primary)">Create Blog →</a>
|
||||
<a href="/signup" style="color: var(--primary)">Create Blog →</a>
|
||||
</nav>
|
||||
<div class="sidebar-divider"></div>
|
||||
<div class="sidebar-label">Resources</div>
|
||||
|
|
@ -149,30 +163,97 @@
|
|||
<section class="hero">
|
||||
<canvas class="hero-canvas" id="dither-canvas"></canvas>
|
||||
<p class="hero-label">Blog Hosting for Developers / 2025</p>
|
||||
<h1>Your Words,<br>Your Platform</h1>
|
||||
<p class="hero-sub">Spin up a beautiful, fast blog in seconds. <code>SQLite</code>-powered, <code>markdown</code>-native, infinitely customizable.</p>
|
||||
<h1>Your Words,<br />Your Platform</h1>
|
||||
<p class="hero-sub">
|
||||
Spin up a beautiful, fast blog in seconds.
|
||||
<code>SQLite</code>-powered, <code>markdown</code>-native,
|
||||
infinitely customizable.
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<button class="demo-btn" id="try-demo">Try Demo</button>
|
||||
<p class="demo-note">{{DEMO_MINUTES}} minute demo. <a href="/signup" style="color:var(--primary)">Create a real blog</a> instead.</p>
|
||||
<p class="demo-note">
|
||||
{{DEMO_MINUTES}} minute demo.
|
||||
<a href="/signup" style="color: var(--primary)"
|
||||
>Create a real blog</a
|
||||
>
|
||||
instead.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section" id="why">
|
||||
<p class="section-label">Why WriteKit</p>
|
||||
<div class="mission">
|
||||
<h2>We built WriteKit because blogging platforms got complicated.</h2>
|
||||
<p>Ghost is heavy. Hashnode is bloated. Medium doesn't care about developers. Hugo outputs static sites — great, until you want comments, logins and analytics without bolting on five services.<br><br>WriteKit is a fully featured platform for developers. Comments, reactions, search, analytics, monetization, API — everything works out of the box. Deploy in seconds, own your data forever.</p>
|
||||
<h2>Blogging got complicated.</h2>
|
||||
<p>
|
||||
Ghost is heavy. Hashnode is bloated. Medium doesn't care about
|
||||
developers. Hugo outputs static sites — great, until you want
|
||||
comments, logins and analytics without bolting on five
|
||||
services.<br /><br />WriteKit is a blogging platform for
|
||||
productive developers. Everything works out of the box. Own your
|
||||
data from day one.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section-features" id="features">
|
||||
<div class="features-grid">
|
||||
<div class="feature"><p class="feature-num">01</p><h3>Comments & Reactions</h3><p>Threaded comments and emoji reactions. No Disqus, no third-party scripts.</p></div>
|
||||
<div class="feature"><p class="feature-num">02</p><h3>Full-text Search</h3><p>SQLite FTS5 powers instant search. Fast, local, no external service.</p></div>
|
||||
<div class="feature"><p class="feature-num">03</p><h3>Privacy-first Analytics</h3><p>Views, referrers, browsers — no cookies, no tracking pixels.</p></div>
|
||||
<div class="feature"><p class="feature-num">04</p><h3>REST API</h3><p>Full programmatic access. Create posts, manage content, build integrations.</p></div>
|
||||
<div class="feature"><p class="feature-num">05</p><h3>Markdown Native</h3><p>Write how you already write. YAML frontmatter, syntax highlighting.</p></div>
|
||||
<div class="feature"><p class="feature-num">06</p><h3>Custom Domains</h3><p>Your domain or *.writekit.dev. SSL included automatically.</p></div>
|
||||
<div class="feature"><p class="feature-num">07</p><h3>Own Your Data</h3><p>Export anytime. JSON, Markdown, full backup. No lock-in ever.</p></div>
|
||||
<div class="feature"><p class="feature-num">08</p><h3>One-click Deploy</h3><p>No DevOps required. One button, your instance is live.</p></div>
|
||||
<div class="feature">
|
||||
<p class="feature-num">01</p>
|
||||
<h3>Comments & Reactions</h3>
|
||||
<p>
|
||||
Threaded comments and emoji reactions. No Disqus, no third-party
|
||||
scripts.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<p class="feature-num">02</p>
|
||||
<h3>Full-text Search</h3>
|
||||
<p>
|
||||
SQLite FTS5 powers instant search. Fast, local, no external
|
||||
service.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<p class="feature-num">03</p>
|
||||
<h3>Privacy-first Analytics</h3>
|
||||
<p>
|
||||
Views, referrers, browsers — no cookies, no tracking pixels.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<p class="feature-num">04</p>
|
||||
<h3>REST API</h3>
|
||||
<p>
|
||||
Full programmatic access. Create posts, manage content, build
|
||||
integrations.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<p class="feature-num">05</p>
|
||||
<h3>Markdown Native</h3>
|
||||
<p>
|
||||
Write how you already write. YAML frontmatter, syntax
|
||||
highlighting.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<p class="feature-num">06</p>
|
||||
<h3>Custom Domains</h3>
|
||||
<p>Your domain or *.writekit.dev. SSL included automatically.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<p class="feature-num">07</p>
|
||||
<h3>Own Your Data</h3>
|
||||
<p>
|
||||
Export anytime. JSON, Markdown, full backup. No lock-in ever.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<p class="feature-num">08</p>
|
||||
<h3>One-click Deploy</h3>
|
||||
<p>
|
||||
No DevOps required. Create your very own blog with one click.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section-cta" id="cta">
|
||||
|
|
@ -181,7 +262,9 @@
|
|||
<p>Deploy your blog in seconds. No credit card required.</p>
|
||||
<div class="cta-buttons">
|
||||
<a href="/signup" class="cta-primary">Create Your Blog</a>
|
||||
<button class="cta-secondary" id="try-demo-bottom">Try Demo First</button>
|
||||
<button class="cta-secondary" id="try-demo-bottom">
|
||||
Try Demo First
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -194,10 +277,19 @@
|
|||
<h2>What's your name?</h2>
|
||||
<p>We'll use this to personalize your blog.</p>
|
||||
</div>
|
||||
<input type="text" id="demo-name" class="demo-name-input" placeholder="Your name" autofocus autocomplete="off">
|
||||
<input
|
||||
type="text"
|
||||
id="demo-name"
|
||||
class="demo-name-input"
|
||||
placeholder="Your name"
|
||||
autofocus
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="demo-step-footer">
|
||||
<button class="demo-back" id="demo-skip">Skip</button>
|
||||
<button class="demo-next" id="demo-next-1" disabled>Next →</button>
|
||||
<button class="demo-next" id="demo-next-1" disabled>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="demo-step" data-step="2">
|
||||
|
|
@ -207,30 +299,89 @@
|
|||
<p>Choose an accent color for your blog.</p>
|
||||
</div>
|
||||
<div class="color-picker">
|
||||
<button class="color-swatch selected" data-color="#10b981" style="background:#10b981" title="Emerald"></button>
|
||||
<button class="color-swatch" data-color="#3b82f6" style="background:#3b82f6" title="Blue"></button>
|
||||
<button class="color-swatch" data-color="#8b5cf6" style="background:#8b5cf6" title="Purple"></button>
|
||||
<button class="color-swatch" data-color="#f97316" style="background:#f97316" title="Orange"></button>
|
||||
<button class="color-swatch" data-color="#ef4444" style="background:#ef4444" title="Rose"></button>
|
||||
<button class="color-swatch" data-color="#64748b" style="background:#64748b" title="Slate"></button>
|
||||
<button
|
||||
class="color-swatch selected"
|
||||
data-color="#10b981"
|
||||
style="background: #10b981"
|
||||
title="Emerald"
|
||||
></button>
|
||||
<button
|
||||
class="color-swatch"
|
||||
data-color="#3b82f6"
|
||||
style="background: #3b82f6"
|
||||
title="Blue"
|
||||
></button>
|
||||
<button
|
||||
class="color-swatch"
|
||||
data-color="#8b5cf6"
|
||||
style="background: #8b5cf6"
|
||||
title="Purple"
|
||||
></button>
|
||||
<button
|
||||
class="color-swatch"
|
||||
data-color="#f97316"
|
||||
style="background: #f97316"
|
||||
title="Orange"
|
||||
></button>
|
||||
<button
|
||||
class="color-swatch"
|
||||
data-color="#ef4444"
|
||||
style="background: #ef4444"
|
||||
title="Rose"
|
||||
></button>
|
||||
<button
|
||||
class="color-swatch"
|
||||
data-color="#64748b"
|
||||
style="background: #64748b"
|
||||
title="Slate"
|
||||
></button>
|
||||
</div>
|
||||
<div class="demo-step-footer">
|
||||
<button class="demo-back" id="demo-back-2">← Back</button>
|
||||
<button class="demo-launch" id="demo-launch">Launch Demo</button>
|
||||
<button class="demo-launch" id="demo-launch">
|
||||
Launch Demo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="demo-step" data-step="3">
|
||||
<div class="demo-step-header"><h2>Launching your demo...</h2></div>
|
||||
<div class="demo-step-header">
|
||||
<h2>Launching your demo...</h2>
|
||||
</div>
|
||||
<div class="launch-progress active">
|
||||
<div class="progress-step" data-step="1"><span class="progress-dot"></span><span>Creating database...</span></div>
|
||||
<div class="progress-step" data-step="2"><span class="progress-dot"></span><span>Configuring settings...</span></div>
|
||||
<div class="progress-step" data-step="3"><span class="progress-dot"></span><span>Adding welcome post...</span></div>
|
||||
<div class="progress-step" data-step="4"><span class="progress-dot"></span><span>Ready!</span></div>
|
||||
<div class="progress-step" data-step="1">
|
||||
<span class="progress-dot"></span
|
||||
><span>Creating database...</span>
|
||||
</div>
|
||||
<div class="progress-step" data-step="2">
|
||||
<span class="progress-dot"></span
|
||||
><span>Configuring settings...</span>
|
||||
</div>
|
||||
<div class="progress-step" data-step="3">
|
||||
<span class="progress-dot"></span
|
||||
><span>Adding welcome post...</span>
|
||||
</div>
|
||||
<div class="progress-step" data-step="4">
|
||||
<span class="progress-dot"></span><span>Ready!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="demo-step" data-step="4">
|
||||
<div class="launch-success active">
|
||||
<div class="success-icon"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg></div>
|
||||
<div class="success-icon">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="success-url" id="success-url"></div>
|
||||
<div class="success-redirect">Redirecting you now...</div>
|
||||
</div>
|
||||
|
|
@ -242,7 +393,9 @@
|
|||
<div class="footer-brand">
|
||||
<div class="footer-logo">WriteKit</div>
|
||||
<div class="footer-tagline">Your Words, Your Platform</div>
|
||||
<div class="footer-copy">© 2025 WriteKit. All rights reserved.</div>
|
||||
<div class="footer-copy">
|
||||
© 2025 WriteKit. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<div class="footer-col">
|
||||
|
|
@ -253,7 +406,13 @@
|
|||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Community</h4>
|
||||
<a href="/discord"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>Discord</a>
|
||||
<a href="/discord"
|
||||
><svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||
/></svg
|
||||
>Discord</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -261,155 +420,183 @@
|
|||
</main>
|
||||
</div>
|
||||
<script>
|
||||
const $ = s => document.querySelector(s)
|
||||
const $$ = s => document.querySelectorAll(s)
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const $$ = (s) => document.querySelectorAll(s);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const modal = $('#demo-modal')
|
||||
const backdrop = modal.querySelector('.demo-modal-backdrop')
|
||||
const nameInput = $('#demo-name')
|
||||
const nextBtn = $('#demo-next-1')
|
||||
const backBtn = $('#demo-back-2')
|
||||
const launchBtn = $('#demo-launch')
|
||||
const successUrl = $('#success-url')
|
||||
const colorSwatches = $$('.color-swatch')
|
||||
const modal = $("#demo-modal");
|
||||
const backdrop = modal.querySelector(".demo-modal-backdrop");
|
||||
const nameInput = $("#demo-name");
|
||||
const nextBtn = $("#demo-next-1");
|
||||
const backBtn = $("#demo-back-2");
|
||||
const launchBtn = $("#demo-launch");
|
||||
const successUrl = $("#success-url");
|
||||
const colorSwatches = $$(".color-swatch");
|
||||
|
||||
let demoName = ''
|
||||
let demoColor = '#10b981'
|
||||
let demoName = "";
|
||||
let demoColor = "#10b981";
|
||||
|
||||
const openDemoModal = () => {
|
||||
modal.classList.add('active')
|
||||
setTimeout(() => nameInput.focus(), 100)
|
||||
}
|
||||
modal.classList.add("active");
|
||||
setTimeout(() => nameInput.focus(), 100);
|
||||
};
|
||||
|
||||
const resetModal = () => {
|
||||
nameInput.value = ''
|
||||
demoName = ''
|
||||
nextBtn.disabled = true
|
||||
goToModalStep(1)
|
||||
colorSwatches.forEach(s => s.classList.remove('selected'))
|
||||
colorSwatches[0].classList.add('selected')
|
||||
demoColor = '#10b981'
|
||||
}
|
||||
nameInput.value = "";
|
||||
demoName = "";
|
||||
nextBtn.disabled = true;
|
||||
goToModalStep(1);
|
||||
colorSwatches.forEach((s) => s.classList.remove("selected"));
|
||||
colorSwatches[0].classList.add("selected");
|
||||
demoColor = "#10b981";
|
||||
};
|
||||
|
||||
const goToModalStep = step => {
|
||||
modal.querySelectorAll('.demo-step').forEach(el => el.classList.remove('active'))
|
||||
modal.querySelector(`.demo-step[data-step="${step}"]`).classList.add('active')
|
||||
if (step === 1) setTimeout(() => nameInput.focus(), 100)
|
||||
}
|
||||
const goToModalStep = (step) => {
|
||||
modal
|
||||
.querySelectorAll(".demo-step")
|
||||
.forEach((el) => el.classList.remove("active"));
|
||||
modal
|
||||
.querySelector(`.demo-step[data-step="${step}"]`)
|
||||
.classList.add("active");
|
||||
if (step === 1) setTimeout(() => nameInput.focus(), 100);
|
||||
};
|
||||
|
||||
const setProgressStep = n => {
|
||||
modal.querySelectorAll('.progress-step').forEach(el => {
|
||||
const step = parseInt(el.dataset.step)
|
||||
el.classList.remove('active', 'done')
|
||||
if (step < n) el.classList.add('done')
|
||||
if (step === n) el.classList.add('active')
|
||||
})
|
||||
}
|
||||
const setProgressStep = (n) => {
|
||||
modal.querySelectorAll(".progress-step").forEach((el) => {
|
||||
const step = parseInt(el.dataset.step);
|
||||
el.classList.remove("active", "done");
|
||||
if (step < n) el.classList.add("done");
|
||||
if (step === n) el.classList.add("active");
|
||||
});
|
||||
};
|
||||
|
||||
const launchDemo = async () => {
|
||||
if (!demoName) return
|
||||
launchBtn.disabled = true
|
||||
launchBtn.textContent = 'Launching...'
|
||||
goToModalStep(3)
|
||||
setProgressStep(1)
|
||||
if (!demoName) return;
|
||||
launchBtn.disabled = true;
|
||||
launchBtn.textContent = "Launching...";
|
||||
goToModalStep(3);
|
||||
setProgressStep(1);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/demo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: demoName, color: demoColor })
|
||||
})
|
||||
const data = await res.json()
|
||||
const res = await fetch("/api/demo", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: demoName, color: demoColor }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.url) {
|
||||
setProgressStep(2)
|
||||
await sleep(150)
|
||||
setProgressStep(3)
|
||||
await sleep(150)
|
||||
setProgressStep(4)
|
||||
await sleep(150)
|
||||
goToModalStep(4)
|
||||
successUrl.textContent = data.url
|
||||
await sleep(300)
|
||||
location.href = data.url
|
||||
setProgressStep(2);
|
||||
await sleep(150);
|
||||
setProgressStep(3);
|
||||
await sleep(150);
|
||||
setProgressStep(4);
|
||||
await sleep(150);
|
||||
goToModalStep(4);
|
||||
successUrl.textContent = data.url;
|
||||
await sleep(300);
|
||||
location.href = data.url;
|
||||
} else {
|
||||
goToModalStep(2)
|
||||
launchBtn.disabled = false
|
||||
launchBtn.textContent = 'Launch Demo'
|
||||
alert(data.error || 'Failed to create demo')
|
||||
goToModalStep(2);
|
||||
launchBtn.disabled = false;
|
||||
launchBtn.textContent = "Launch Demo";
|
||||
alert(data.error || "Failed to create demo");
|
||||
}
|
||||
} catch {
|
||||
goToModalStep(2)
|
||||
launchBtn.disabled = false
|
||||
launchBtn.textContent = 'Launch Demo'
|
||||
alert('Error creating demo')
|
||||
goToModalStep(2);
|
||||
launchBtn.disabled = false;
|
||||
launchBtn.textContent = "Launch Demo";
|
||||
alert("Error creating demo");
|
||||
}
|
||||
};
|
||||
|
||||
$("#try-demo").addEventListener("click", openDemoModal);
|
||||
$("#try-demo-bottom").addEventListener("click", openDemoModal);
|
||||
|
||||
backdrop.addEventListener("click", () => {
|
||||
if (
|
||||
!launchBtn.disabled ||
|
||||
modal.querySelector('.demo-step[data-step="1"].active') ||
|
||||
modal.querySelector('.demo-step[data-step="2"].active')
|
||||
) {
|
||||
modal.classList.remove("active");
|
||||
resetModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal.classList.contains("active")) {
|
||||
if (
|
||||
modal.querySelector('.demo-step[data-step="1"].active') ||
|
||||
modal.querySelector('.demo-step[data-step="2"].active')
|
||||
) {
|
||||
modal.classList.remove("active");
|
||||
resetModal();
|
||||
}
|
||||
}
|
||||
|
||||
$('#try-demo').addEventListener('click', openDemoModal)
|
||||
$('#try-demo-bottom').addEventListener('click', openDemoModal)
|
||||
|
||||
backdrop.addEventListener('click', () => {
|
||||
if (!launchBtn.disabled || modal.querySelector('.demo-step[data-step="1"].active') || modal.querySelector('.demo-step[data-step="2"].active')) {
|
||||
modal.classList.remove('active')
|
||||
resetModal()
|
||||
if (e.key === "Enter" && modal.classList.contains("active")) {
|
||||
if (
|
||||
modal.querySelector('.demo-step[data-step="1"].active') &&
|
||||
!nextBtn.disabled
|
||||
)
|
||||
goToModalStep(2);
|
||||
else if (modal.querySelector('.demo-step[data-step="2"].active'))
|
||||
launchDemo();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && modal.classList.contains('active')) {
|
||||
if (modal.querySelector('.demo-step[data-step="1"].active') || modal.querySelector('.demo-step[data-step="2"].active')) {
|
||||
modal.classList.remove('active')
|
||||
resetModal()
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter' && modal.classList.contains('active')) {
|
||||
if (modal.querySelector('.demo-step[data-step="1"].active') && !nextBtn.disabled) goToModalStep(2)
|
||||
else if (modal.querySelector('.demo-step[data-step="2"].active')) launchDemo()
|
||||
}
|
||||
})
|
||||
nameInput.addEventListener("input", () => {
|
||||
demoName = nameInput.value.trim();
|
||||
nextBtn.disabled = !demoName.length;
|
||||
});
|
||||
|
||||
nameInput.addEventListener('input', () => {
|
||||
demoName = nameInput.value.trim()
|
||||
nextBtn.disabled = !demoName.length
|
||||
})
|
||||
nextBtn.addEventListener("click", () => goToModalStep(2));
|
||||
backBtn.addEventListener("click", () => goToModalStep(1));
|
||||
launchBtn.addEventListener("click", launchDemo);
|
||||
|
||||
nextBtn.addEventListener('click', () => goToModalStep(2))
|
||||
backBtn.addEventListener('click', () => goToModalStep(1))
|
||||
launchBtn.addEventListener('click', launchDemo)
|
||||
$("#demo-skip").addEventListener("click", () => {
|
||||
demoName = "Demo User";
|
||||
launchDemo();
|
||||
});
|
||||
|
||||
$('#demo-skip').addEventListener('click', () => {
|
||||
demoName = 'Demo User'
|
||||
launchDemo()
|
||||
})
|
||||
colorSwatches.forEach((swatch) => {
|
||||
swatch.addEventListener("click", () => {
|
||||
colorSwatches.forEach((s) => s.classList.remove("selected"));
|
||||
swatch.classList.add("selected");
|
||||
demoColor = swatch.dataset.color;
|
||||
});
|
||||
});
|
||||
|
||||
colorSwatches.forEach(swatch => {
|
||||
swatch.addEventListener('click', () => {
|
||||
colorSwatches.forEach(s => s.classList.remove('selected'))
|
||||
swatch.classList.add('selected')
|
||||
demoColor = swatch.dataset.color
|
||||
})
|
||||
})
|
||||
|
||||
$$('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
document.querySelector(anchor.getAttribute('href'))?.scrollIntoView({ behavior: 'smooth' })
|
||||
})
|
||||
})
|
||||
|
||||
;(() => {
|
||||
const settings = { pixelSize: 8, gridSize: 4, speed: 0.05, colorShift: 0.6, colors: { bright: [0.063, 0.725, 0.506], mid: [0.047, 0.545, 0.38], dark: [0.031, 0.363, 0.253], bg: [0.2, 0.2, 0.2] } }
|
||||
const canvas = $('#dither-canvas')
|
||||
if (!canvas) return
|
||||
const gl = canvas.getContext('webgl2')
|
||||
if (!gl) return
|
||||
$$('a[href^="#"]').forEach((anchor) => {
|
||||
anchor.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
document
|
||||
.querySelector(anchor.getAttribute("href"))
|
||||
?.scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
});
|
||||
(() => {
|
||||
const settings = {
|
||||
pixelSize: 8,
|
||||
gridSize: 4,
|
||||
speed: 0.05,
|
||||
colorShift: 0.6,
|
||||
colors: {
|
||||
bright: [0.063, 0.725, 0.506],
|
||||
mid: [0.047, 0.545, 0.38],
|
||||
dark: [0.031, 0.363, 0.253],
|
||||
bg: [0.2, 0.2, 0.2],
|
||||
},
|
||||
};
|
||||
const canvas = $("#dither-canvas");
|
||||
if (!canvas) return;
|
||||
const gl = canvas.getContext("webgl2");
|
||||
if (!gl) return;
|
||||
|
||||
const vs = `#version 300 es
|
||||
in vec2 a_position;
|
||||
out vec2 v_uv;
|
||||
void main() { v_uv = a_position * 0.5 + 0.5; gl_Position = vec4(a_position, 0.0, 1.0); }`
|
||||
void main() { v_uv = a_position * 0.5 + 0.5; gl_Position = vec4(a_position, 0.0, 1.0); }`;
|
||||
|
||||
const fs = `#version 300 es
|
||||
precision highp float;
|
||||
|
|
@ -431,54 +618,78 @@
|
|||
float level = luma * 3.0; int band = int(floor(level)); float frac = fract(level);
|
||||
vec3 result = band >= 2 ? (frac > threshold ? u_color1 : u_color2) : band == 1 ? (frac > threshold ? u_color2 : u_color3) : (frac > threshold ? u_color3 : u_bgColor);
|
||||
fragColor = vec4(result, 1.0);
|
||||
}`
|
||||
}`;
|
||||
|
||||
const createShader = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null }
|
||||
const vertexShader = createShader(gl.VERTEX_SHADER, vs)
|
||||
const fragmentShader = createShader(gl.FRAGMENT_SHADER, fs)
|
||||
const program = gl.createProgram()
|
||||
gl.attachShader(program, vertexShader)
|
||||
gl.attachShader(program, fragmentShader)
|
||||
gl.linkProgram(program)
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return
|
||||
const createShader = (type, src) => {
|
||||
const s = gl.createShader(type);
|
||||
gl.shaderSource(s, src);
|
||||
gl.compileShader(s);
|
||||
return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;
|
||||
};
|
||||
const vertexShader = createShader(gl.VERTEX_SHADER, vs);
|
||||
const fragmentShader = createShader(gl.FRAGMENT_SHADER, fs);
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vertexShader);
|
||||
gl.attachShader(program, fragmentShader);
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;
|
||||
|
||||
const vao = gl.createVertexArray()
|
||||
gl.bindVertexArray(vao)
|
||||
const buf = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW)
|
||||
const posLoc = gl.getAttribLocation(program, 'a_position')
|
||||
gl.enableVertexAttribArray(posLoc)
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0)
|
||||
const vao = gl.createVertexArray();
|
||||
gl.bindVertexArray(vao);
|
||||
const buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
|
||||
gl.STATIC_DRAW
|
||||
);
|
||||
const posLoc = gl.getAttribLocation(program, "a_position");
|
||||
gl.enableVertexAttribArray(posLoc);
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const u = { resolution: gl.getUniformLocation(program, 'u_resolution'), time: gl.getUniformLocation(program, 'u_time'), pixelSize: gl.getUniformLocation(program, 'u_pixelSize'), gridSize: gl.getUniformLocation(program, 'u_gridSize'), speed: gl.getUniformLocation(program, 'u_speed'), colorShift: gl.getUniformLocation(program, 'u_colorShift'), color1: gl.getUniformLocation(program, 'u_color1'), color2: gl.getUniformLocation(program, 'u_color2'), color3: gl.getUniformLocation(program, 'u_color3'), bgColor: gl.getUniformLocation(program, 'u_bgColor') }
|
||||
const u = {
|
||||
resolution: gl.getUniformLocation(program, "u_resolution"),
|
||||
time: gl.getUniformLocation(program, "u_time"),
|
||||
pixelSize: gl.getUniformLocation(program, "u_pixelSize"),
|
||||
gridSize: gl.getUniformLocation(program, "u_gridSize"),
|
||||
speed: gl.getUniformLocation(program, "u_speed"),
|
||||
colorShift: gl.getUniformLocation(program, "u_colorShift"),
|
||||
color1: gl.getUniformLocation(program, "u_color1"),
|
||||
color2: gl.getUniformLocation(program, "u_color2"),
|
||||
color3: gl.getUniformLocation(program, "u_color3"),
|
||||
bgColor: gl.getUniformLocation(program, "u_bgColor"),
|
||||
};
|
||||
|
||||
gl.useProgram(program)
|
||||
gl.uniform3fv(u.color1, settings.colors.bright)
|
||||
gl.uniform3fv(u.color2, settings.colors.mid)
|
||||
gl.uniform3fv(u.color3, settings.colors.dark)
|
||||
gl.uniform3fv(u.bgColor, settings.colors.bg)
|
||||
gl.useProgram(program);
|
||||
gl.uniform3fv(u.color1, settings.colors.bright);
|
||||
gl.uniform3fv(u.color2, settings.colors.mid);
|
||||
gl.uniform3fv(u.color3, settings.colors.dark);
|
||||
gl.uniform3fv(u.bgColor, settings.colors.bg);
|
||||
|
||||
const resize = () => { const dpr = Math.min(devicePixelRatio, 2); canvas.width = canvas.offsetWidth * dpr; canvas.height = canvas.offsetHeight * dpr }
|
||||
resize()
|
||||
addEventListener('resize', resize)
|
||||
const resize = () => {
|
||||
const dpr = Math.min(devicePixelRatio, 2);
|
||||
canvas.width = canvas.offsetWidth * dpr;
|
||||
canvas.height = canvas.offsetHeight * dpr;
|
||||
};
|
||||
resize();
|
||||
addEventListener("resize", resize);
|
||||
|
||||
const render = time => {
|
||||
time *= 0.001
|
||||
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||
gl.useProgram(program)
|
||||
gl.bindVertexArray(vao)
|
||||
gl.uniform2f(u.resolution, canvas.width, canvas.height)
|
||||
gl.uniform1f(u.time, time)
|
||||
gl.uniform1f(u.pixelSize, settings.pixelSize)
|
||||
gl.uniform1f(u.gridSize, settings.gridSize)
|
||||
gl.uniform1f(u.speed, settings.speed)
|
||||
gl.uniform1f(u.colorShift, settings.colorShift)
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||
requestAnimationFrame(render)
|
||||
}
|
||||
requestAnimationFrame(render)
|
||||
})()
|
||||
const render = (time) => {
|
||||
time *= 0.001;
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.useProgram(program);
|
||||
gl.bindVertexArray(vao);
|
||||
gl.uniform2f(u.resolution, canvas.width, canvas.height);
|
||||
gl.uniform1f(u.time, time);
|
||||
gl.uniform1f(u.pixelSize, settings.pixelSize);
|
||||
gl.uniform1f(u.gridSize, settings.gridSize);
|
||||
gl.uniform1f(u.speed, settings.speed);
|
||||
gl.uniform1f(u.colorShift, settings.colorShift);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
requestAnimationFrame(render);
|
||||
};
|
||||
requestAnimationFrame(render);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue