This commit is contained in:
Josh 2026-01-09 00:16:46 +02:00
commit d69342b2e9
160 changed files with 28681 additions and 0 deletions

208
internal/server/api.go Normal file
View file

@ -0,0 +1,208 @@
package server
import (
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/writekitapp/writekit/internal/auth"
"github.com/writekitapp/writekit/internal/tenant"
)
func (s *Server) publicAPIRoutes() chi.Router {
r := chi.NewRouter()
r.Use(s.apiKeyMiddleware)
r.Use(s.apiRateLimitMiddleware(s.rateLimiter))
r.Get("/posts", s.apiListPosts)
r.Post("/posts", s.apiCreatePost)
r.Get("/posts/{slug}", s.apiGetPost)
r.Put("/posts/{slug}", s.apiUpdatePost)
r.Delete("/posts/{slug}", s.apiDeletePost)
r.Get("/settings", s.apiGetSettings)
return r
}
func (s *Server) apiKeyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID, ok := r.Context().Value(tenantIDKey).(string)
if !ok || tenantID == "" {
jsonError(w, http.StatusUnauthorized, "unauthorized")
return
}
key := extractAPIKey(r)
if key != "" {
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
valid, err := q.ValidateAPIKey(r.Context(), key)
if err != nil {
jsonError(w, http.StatusInternalServerError, "validation error")
return
}
if valid {
next.ServeHTTP(w, r)
return
}
jsonError(w, http.StatusUnauthorized, "invalid API key")
return
}
if GetDemoInfo(r).IsDemo {
next.ServeHTTP(w, r)
return
}
userID := auth.GetUserID(r)
if userID == "" {
jsonError(w, http.StatusUnauthorized, "API key required")
return
}
isOwner, err := s.database.IsUserTenantOwner(r.Context(), userID, tenantID)
if err != nil || !isOwner {
jsonError(w, http.StatusUnauthorized, "API key required")
return
}
next.ServeHTTP(w, r)
})
}
func extractAPIKey(r *http.Request) string {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return strings.TrimPrefix(auth, "Bearer ")
}
return r.URL.Query().Get("api_key")
}
type paginatedPostsResponse struct {
Posts []postResponse `json:"posts"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func (s *Server) apiListPosts(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
tag := r.URL.Query().Get("tag")
includeContent := r.URL.Query().Get("include") == "content"
q := tenant.NewQueries(db)
result, err := q.ListPostsPaginated(r.Context(), tenant.ListPostsOptions{
Limit: limit,
Offset: offset,
Tag: tag,
})
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to list posts")
return
}
posts := make([]postResponse, len(result.Posts))
for i, p := range result.Posts {
posts[i] = postToResponse(&p, includeContent)
}
jsonResponse(w, http.StatusOK, paginatedPostsResponse{
Posts: posts,
Total: result.Total,
Limit: limit,
Offset: offset,
})
}
func (s *Server) apiGetPost(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
post, err := q.GetPost(r.Context(), slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get post")
return
}
if post == nil {
jsonError(w, http.StatusNotFound, "post not found")
return
}
jsonResponse(w, http.StatusOK, postToResponse(post, true))
}
func (s *Server) apiCreatePost(w http.ResponseWriter, r *http.Request) {
s.createPost(w, r)
}
func (s *Server) apiUpdatePost(w http.ResponseWriter, r *http.Request) {
s.updatePost(w, r)
}
func (s *Server) apiDeletePost(w http.ResponseWriter, r *http.Request) {
s.deletePost(w, r)
}
var publicSettingsKeys = []string{
"site_name",
"site_description",
"author_name",
"author_role",
"author_bio",
"author_photo",
"twitter_handle",
"github_handle",
"linkedin_handle",
"email",
"accent_color",
"font",
}
func (s *Server) apiGetSettings(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
allSettings, err := q.GetSettings(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get settings")
return
}
result := make(map[string]string)
for _, key := range publicSettingsKeys {
if val, ok := allSettings[key]; ok {
result[key] = val
}
}
jsonResponse(w, http.StatusOK, result)
}

703
internal/server/blog.go Normal file
View file

@ -0,0 +1,703 @@
package server
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
"os"
"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"
)
func (s *Server) serveBlog(w http.ResponseWriter, r *http.Request, subdomain string) {
var tenantID string
var demoInfo DemoInfo
tenantID, ok := s.tenantCache.Get(subdomain)
if !ok {
t, err := s.database.GetTenantBySubdomain(r.Context(), subdomain)
if err != nil || t == nil {
d, err := s.database.GetDemoBySubdomain(r.Context(), subdomain)
if err != nil || d == nil {
s.notFound(w, r)
return
}
tenantID = d.ID
demoInfo = DemoInfo{IsDemo: true, ExpiresAt: d.ExpiresAt}
s.tenantPool.MarkAsDemo(tenantID)
s.ensureDemoSeeded(tenantID)
} else {
tenantID = t.ID
}
s.tenantCache.Set(subdomain, tenantID)
} else {
d, _ := s.database.GetDemoBySubdomain(r.Context(), subdomain)
if d != nil {
demoInfo = DemoInfo{IsDemo: true, ExpiresAt: d.ExpiresAt}
s.tenantPool.MarkAsDemo(tenantID)
}
}
ctx := context.WithValue(r.Context(), tenantIDKey, tenantID)
ctx = context.WithValue(ctx, demoInfoKey, demoInfo)
r = r.WithContext(ctx)
mux := chi.NewRouter()
mux.Get("/", s.blogHome)
mux.Get("/posts", s.blogList)
mux.Get("/posts/{slug}", s.blogPost)
mux.Handle("/static/*", http.StripPrefix("/static/", assets.Handler()))
mux.Route("/api/studio", func(r chi.Router) {
r.Use(demoAwareSessionMiddleware(s.database))
r.Use(s.ownerMiddleware)
r.Mount("/", s.studioRoutes())
})
mux.Mount("/api/v1", s.publicAPIRoutes())
mux.Mount("/api/reader", s.readerRoutes())
mux.Get("/studio", s.serveStudio)
mux.Get("/studio/*", s.serveStudio)
mux.Get("/sitemap.xml", s.sitemap)
mux.Get("/robots.txt", s.robots)
mux.ServeHTTP(w, r)
}
func (s *Server) blogHome(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
slog.Error("blogHome: get tenant pool", "error", err, "tenantID", tenantID)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
q := tenant.NewQueries(db)
s.recordPageView(q, r, "/", "")
if html, etag, err := q.GetPage(r.Context(), "/"); err == nil && html != nil {
s.servePreRendered(w, r, html, etag, "public, max-age=300")
return
}
posts, err := q.ListPosts(r.Context(), false)
if err != nil {
slog.Error("blogHome: list posts", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
settings, _ := q.GetSettings(r.Context())
siteName := getSettingOr(settings, "site_name", "My Blog")
siteDesc := getSettingOr(settings, "site_description", "")
baseURL := getBaseURL(r.Host)
showBadge := true
if t, err := s.database.GetTenantByID(r.Context(), tenantID); err == nil && t != nil {
tierInfo := config.GetTierInfo(t.Premium)
showBadge = tierInfo.Config.BadgeRequired
}
postSummaries := make([]templates.PostSummary, 0, len(posts))
for _, p := range posts {
if len(postSummaries) >= 10 {
break
}
postSummaries = append(postSummaries, templates.PostSummary{
Slug: p.Slug,
Title: p.Title,
Description: p.Description,
Date: timeOrZero(p.PublishedAt),
})
}
data := templates.HomeData{
PageData: templates.PageData{
Title: siteName,
Description: siteDesc,
CanonicalURL: baseURL + "/",
OGType: "website",
SiteName: siteName,
Year: time.Now().Year(),
Settings: settingsToMap(settings),
NoIndex: GetDemoInfo(r).IsDemo,
ShowBadge: showBadge,
},
Posts: postSummaries,
HasMore: len(posts) > 10,
}
html, err := templates.RenderHome(data)
if err != nil {
slog.Error("blogHome: render template", "error", err)
http.Error(w, "render error", http.StatusInternalServerError)
return
}
s.servePreRendered(w, r, html, computeETag(html), "public, max-age=300")
}
func (s *Server) blogList(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
q := tenant.NewQueries(db)
s.recordPageView(q, r, "/posts", "")
if html, etag, err := q.GetPage(r.Context(), "/posts"); err == nil && html != nil {
s.servePreRendered(w, r, html, etag, "public, max-age=300")
return
}
posts, err := q.ListPosts(r.Context(), false)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
settings, _ := q.GetSettings(r.Context())
siteName := getSettingOr(settings, "site_name", "My Blog")
baseURL := getBaseURL(r.Host)
showBadge := true
if t, err := s.database.GetTenantByID(r.Context(), tenantID); err == nil && t != nil {
tierInfo := config.GetTierInfo(t.Premium)
showBadge = tierInfo.Config.BadgeRequired
}
postSummaries := make([]templates.PostSummary, len(posts))
for i, p := range posts {
postSummaries[i] = templates.PostSummary{
Slug: p.Slug,
Title: p.Title,
Description: p.Description,
Date: timeOrZero(p.PublishedAt),
}
}
data := templates.BlogData{
PageData: templates.PageData{
Title: "Posts - " + siteName,
Description: "All posts",
CanonicalURL: baseURL + "/posts",
OGType: "website",
SiteName: siteName,
Year: time.Now().Year(),
Settings: settingsToMap(settings),
NoIndex: GetDemoInfo(r).IsDemo,
ShowBadge: showBadge,
},
Posts: postSummaries,
}
html, err := templates.RenderBlog(data)
if err != nil {
http.Error(w, "render error", http.StatusInternalServerError)
return
}
s.servePreRendered(w, r, html, computeETag(html), "public, max-age=300")
}
func (s *Server) blogPost(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
isPreview := r.URL.Query().Get("preview") == "true"
db, err := s.tenantPool.Get(tenantID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
q := tenant.NewQueries(db)
if isPreview && !s.canPreview(r, tenantID) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if !isPreview {
path := "/posts/" + slug
s.recordPageView(q, r, path, slug)
if html, etag, err := q.GetPage(r.Context(), path); err == nil && html != nil {
s.servePreRendered(w, r, html, etag, "public, max-age=3600")
return
}
}
post, err := q.GetPost(r.Context(), slug)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if post == nil {
aliasPost, _ := q.GetPostByAlias(r.Context(), slug)
if aliasPost != nil && aliasPost.IsPublished {
http.Redirect(w, r, "/posts/"+aliasPost.Slug, http.StatusMovedPermanently)
return
}
http.NotFound(w, r)
return
}
if !post.IsPublished && !isPreview {
http.NotFound(w, r)
return
}
title := post.Title
description := post.Description
contentMD := post.ContentMD
tags := post.Tags
coverImage := post.CoverImage
if isPreview {
if draft, _ := q.GetDraft(r.Context(), post.ID); draft != nil {
title = draft.Title
description = draft.Description
contentMD = draft.ContentMD
tags = draft.Tags
coverImage = draft.CoverImage
}
}
settings, _ := q.GetSettings(r.Context())
siteName := getSettingOr(settings, "site_name", "My Blog")
baseURL := getBaseURL(r.Host)
codeTheme := getSettingOr(settings, "code_theme", "github")
showBadge := true
if t, err := s.database.GetTenantByID(r.Context(), tenantID); err == nil && t != nil {
tierInfo := config.GetTierInfo(t.Premium)
showBadge = tierInfo.Config.BadgeRequired
}
contentHTML := ""
if contentMD != "" {
contentHTML, _ = markdown.RenderWithTheme(contentMD, codeTheme)
}
interactionConfig := q.GetInteractionConfig(r.Context())
structuredData := buildArticleSchema(post, siteName, baseURL)
data := templates.PostData{
PageData: templates.PageData{
Title: title + " - " + siteName,
Description: description,
CanonicalURL: baseURL + "/posts/" + post.Slug,
OGType: "article",
OGImage: coverImage,
SiteName: siteName,
Year: time.Now().Year(),
StructuredData: template.JS(structuredData),
Settings: settingsToMap(settings),
NoIndex: GetDemoInfo(r).IsDemo || isPreview,
ShowBadge: showBadge,
},
Post: templates.PostDetail{
Slug: post.Slug,
Title: title,
Description: description,
CoverImage: coverImage,
Date: timeOrZero(post.PublishedAt),
Tags: tags,
},
ContentHTML: template.HTML(contentHTML),
InteractionConfig: interactionConfig,
}
html, err := templates.RenderPost(data)
if err != nil {
http.Error(w, "render error", http.StatusInternalServerError)
return
}
if isPreview {
previewScript := `<style>
.preview-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
color: #fff;
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
z-index: 99999;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
}
@media(min-width:640px) {
.preview-banner {
bottom: auto;
top: 0;
left: 50%;
right: auto;
transform: translateX(-50%);
border-radius: 0 0 8px 8px;
padding: 8px 20px;
}
}
.preview-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
letter-spacing: 0.02em;
}
.preview-badge svg {
width: 14px;
height: 14px;
}
.preview-status {
opacity: 0.9;
font-size: 12px;
}
.preview-link {
background: rgba(255,255,255,0.2);
color: #fff;
padding: 4px 10px;
border-radius: 4px;
text-decoration: none;
font-weight: 500;
font-size: 12px;
transition: background 0.15s;
}
.preview-link:hover {
background: rgba(255,255,255,0.3);
}
.preview-rebuild {
position: fixed;
top: 16px;
right: 16px;
background: #18181b;
color: #fafafa;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-family: system-ui, sans-serif;
z-index: 99998;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
.preview-rebuild.visible { opacity: 1; }
</style>
<div class="preview-banner">
<span class="preview-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
Preview Mode
</span>
<span class="preview-status">Viewing as author</span>
<a class="preview-link" href="/studio/posts/` + post.Slug + `/edit">Back to Editor</a>
</div>
<div class="preview-rebuild" id="preview-rebuild">Rebuilding...</div>
<script>
(function() {
var channel = new BroadcastChannel('writekit-preview');
var slug = '` + post.Slug + `';
var rebuild = document.getElementById('preview-rebuild');
channel.onmessage = function(e) {
if (e.data.slug !== slug) return;
if (e.data.type === 'rebuilding') {
rebuild.classList.add('visible');
return;
}
if (e.data.type === 'content-update') {
var content = document.querySelector('.post-content');
if (content) content.innerHTML = e.data.html;
var title = document.querySelector('h1');
if (title) title.textContent = e.data.title;
var desc = document.querySelector('meta[name="description"]');
if (desc) desc.content = e.data.description;
rebuild.classList.remove('visible');
}
};
})();
</script>`
html = bytes.Replace(html, []byte("</body>"), []byte(previewScript+"</body>"), 1)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(html)
return
}
s.servePreRendered(w, r, html, computeETag(html), "public, max-age=3600")
}
func (s *Server) canPreview(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) serveStudio(w http.ResponseWriter, r *http.Request) {
if viteURL := os.Getenv("VITE_URL"); viteURL != "" && os.Getenv("ENV") == "local" {
target, err := url.Parse(viteURL)
if err != nil {
slog.Error("invalid VITE_URL", "error", err)
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)
return
}
path := chi.URLParam(r, "*")
if path == "" {
path = "index.html"
}
data, err := studio.Read(path)
if err != nil {
data, _ = studio.Read("index.html")
}
contentType := "text/html; charset=utf-8"
if len(path) > 3 {
switch path[len(path)-3:] {
case ".js":
contentType = "application/javascript"
case "css":
contentType = "text/css"
}
}
if contentType == "text/html; charset=utf-8" {
if demoInfo := GetDemoInfo(r); demoInfo.IsDemo {
data = s.injectDemoBanner(data, demoInfo.ExpiresAt)
}
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
if contentType == "text/html; charset=utf-8" {
w.Header().Set("Cache-Control", "no-cache")
}
w.Write(data)
}
func (s *Server) sitemap(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
q := tenant.NewQueries(db)
posts, _ := q.ListPosts(r.Context(), false)
baseURL := getBaseURL(r.Host)
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>` + baseURL + `/</loc></url>
`))
for _, p := range posts {
lastmod := p.ModifiedAt.Format("2006-01-02")
if p.UpdatedAt != nil {
lastmod = p.UpdatedAt.Format("2006-01-02")
}
w.Write([]byte(fmt.Sprintf(" <url><loc>%s/posts/%s</loc><lastmod>%s</lastmod></url>\n",
baseURL, p.Slug, lastmod)))
}
w.Write([]byte("</urlset>"))
}
func (s *Server) robots(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
if GetDemoInfo(r).IsDemo {
w.Write([]byte("User-agent: *\nDisallow: /\n"))
return
}
baseURL := getBaseURL(r.Host)
fmt.Fprintf(w, "User-agent: *\nAllow: /\n\nSitemap: %s/sitemap.xml\n", baseURL)
}
func (s *Server) ownerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
demoInfo := GetDemoInfo(r)
if demoInfo.IsDemo {
next.ServeHTTP(w, r)
return
}
userID := auth.GetUserID(r)
if userID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, ok := r.Context().Value(tenantIDKey).(string)
if !ok || tenantID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
isOwner, err := s.database.IsUserTenantOwner(r.Context(), userID, tenantID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !isOwner {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func getSettingOr(settings tenant.Settings, key, fallback string) string {
if v, ok := settings[key]; ok && v != "" {
return v
}
return fallback
}
func settingsToMap(settings tenant.Settings) map[string]any {
m := make(map[string]any)
for k, v := range settings {
m[k] = v
}
return m
}
func getBaseURL(host string) string {
scheme := "https"
if env := os.Getenv("ENV"); env != "prod" {
scheme = "http"
}
return fmt.Sprintf("%s://%s", scheme, host)
}
func computeETag(data []byte) string {
hash := md5.Sum(data)
return `"` + hex.EncodeToString(hash[:]) + `"`
}
func (s *Server) servePreRendered(w http.ResponseWriter, r *http.Request, html []byte, etag, cacheControl string) {
if demoInfo := GetDemoInfo(r); demoInfo.IsDemo {
html = s.injectDemoBanner(html, demoInfo.ExpiresAt)
etag = computeETag(html)
}
if match := r.Header.Get("If-None-Match"); match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", cacheControl)
w.Header().Set("ETag", etag)
w.Write(html)
}
func buildArticleSchema(post *tenant.Post, siteName, baseURL string) string {
publishedAt := timeOrZero(post.PublishedAt)
modifiedAt := publishedAt
if post.UpdatedAt != nil {
modifiedAt = *post.UpdatedAt
}
schema := map[string]any{
"@context": "https://schema.org",
"@type": "Article",
"headline": post.Title,
"datePublished": publishedAt.Format(time.RFC3339),
"dateModified": modifiedAt.Format(time.RFC3339),
"author": map[string]any{
"@type": "Person",
"name": siteName,
},
"publisher": map[string]any{
"@type": "Organization",
"name": siteName,
},
"mainEntityOfPage": map[string]any{
"@type": "WebPage",
"@id": baseURL + "/posts/" + post.Slug,
},
}
if post.Description != "" {
schema["description"] = post.Description
}
b, _ := json.Marshal(schema)
return string(b)
}
func (s *Server) recordPageView(q *tenant.Queries, r *http.Request, path, postSlug string) {
referrer := r.Header.Get("Referer")
userAgent := r.Header.Get("User-Agent")
go func() {
q.RecordPageView(context.Background(), path, postSlug, referrer, userAgent)
}()
}
func timeOrZero(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
}

176
internal/server/build.go Normal file
View file

@ -0,0 +1,176 @@
package server
import (
"context"
"database/sql"
"html/template"
"log/slog"
"sync"
"time"
"github.com/writekitapp/writekit/internal/build/templates"
"github.com/writekitapp/writekit/internal/markdown"
"github.com/writekitapp/writekit/internal/tenant"
)
type renderedPage struct {
path string
html []byte
etag string
}
func (s *Server) rebuildSite(ctx context.Context, tenantID string, db *sql.DB, host string) {
q := tenant.NewQueries(db)
settings, err := q.GetSettings(ctx)
if err != nil {
slog.Error("rebuildSite: get settings", "error", err, "tenantID", tenantID)
return
}
posts, err := q.ListPosts(ctx, false)
if err != nil {
slog.Error("rebuildSite: list posts", "error", err, "tenantID", tenantID)
return
}
baseURL := getBaseURL(host)
siteName := getSettingOr(settings, "site_name", "My Blog")
siteDesc := getSettingOr(settings, "site_description", "")
codeTheme := getSettingOr(settings, "code_theme", "github")
fontKey := getSettingOr(settings, "font", "system")
isDemo := getSettingOr(settings, "is_demo", "") == "true"
pageData := templates.PageData{
SiteName: siteName,
Year: time.Now().Year(),
FontURL: templates.GetFontURL(fontKey),
FontFamily: templates.GetFontFamily(fontKey),
Settings: settingsToMap(settings),
NoIndex: isDemo,
}
var pages []renderedPage
var mu sync.Mutex
var wg sync.WaitGroup
addPage := func(path string, html []byte) {
mu.Lock()
pages = append(pages, renderedPage{path, html, computeETag(html)})
mu.Unlock()
}
postSummaries := make([]templates.PostSummary, len(posts))
for i, p := range posts {
postSummaries[i] = templates.PostSummary{
Slug: p.Slug,
Title: p.Title,
Description: p.Description,
Date: timeOrZero(p.PublishedAt),
}
}
wg.Add(1)
go func() {
defer wg.Done()
homePosts := postSummaries
if len(homePosts) > 10 {
homePosts = homePosts[:10]
}
data := templates.HomeData{
PageData: pageData,
Posts: homePosts,
HasMore: len(postSummaries) > 10,
}
data.Title = siteName
data.Description = siteDesc
data.CanonicalURL = baseURL + "/"
data.OGType = "website"
html, err := templates.RenderHome(data)
if err != nil {
slog.Error("rebuildSite: render home", "error", err)
return
}
addPage("/", html)
}()
wg.Add(1)
go func() {
defer wg.Done()
data := templates.BlogData{
PageData: pageData,
Posts: postSummaries,
}
data.Title = "Posts - " + siteName
data.Description = "All posts"
data.CanonicalURL = baseURL + "/posts"
data.OGType = "website"
html, err := templates.RenderBlog(data)
if err != nil {
slog.Error("rebuildSite: render blog", "error", err)
return
}
addPage("/posts", html)
}()
for _, p := range posts {
wg.Add(1)
go func(post tenant.Post) {
defer wg.Done()
contentHTML := post.ContentHTML
if contentHTML == "" && post.ContentMD != "" {
contentHTML, _ = markdown.RenderWithTheme(post.ContentMD, codeTheme)
}
interactionConfig := q.GetInteractionConfig(ctx)
structuredData := buildArticleSchema(&post, siteName, baseURL)
data := templates.PostData{
PageData: pageData,
Post: templates.PostDetail{
Slug: post.Slug,
Title: post.Title,
Description: post.Description,
Date: timeOrZero(post.PublishedAt),
Tags: post.Tags,
},
ContentHTML: template.HTML(contentHTML),
InteractionConfig: interactionConfig,
}
data.Title = post.Title + " - " + siteName
data.Description = post.Description
data.CanonicalURL = baseURL + "/posts/" + post.Slug
data.OGType = "article"
data.StructuredData = template.JS(structuredData)
html, err := templates.RenderPost(data)
if err != nil {
slog.Error("rebuildSite: render post", "error", err, "slug", post.Slug)
return
}
addPage("/posts/"+post.Slug, html)
}(p)
}
wg.Wait()
for _, p := range pages {
if err := q.SetPage(ctx, p.path, p.html, p.etag); err != nil {
slog.Error("rebuildSite: save page", "error", err, "path", p.path)
}
}
if s.cloudflare.IsConfigured() {
urls := make([]string, len(pages))
for i, p := range pages {
urls[i] = baseURL + p.path
}
if err := s.cloudflare.PurgeURLs(ctx, urls); err != nil {
slog.Error("rebuildSite: purge cache", "error", err)
}
}
slog.Info("rebuildSite: complete", "tenantID", tenantID, "pages", len(pages))
}

208
internal/server/demo.go Normal file
View file

@ -0,0 +1,208 @@
package server
import (
"bytes"
"math/rand"
"net/http"
"os"
"strconv"
"time"
"github.com/writekitapp/writekit/internal/auth"
"github.com/writekitapp/writekit/internal/db"
"github.com/writekitapp/writekit/internal/tenant"
)
type ctxKey string
const (
tenantIDKey ctxKey = "tenantID"
demoInfoKey ctxKey = "demoInfo"
)
type DemoInfo struct {
IsDemo bool
ExpiresAt time.Time
}
func GetDemoInfo(r *http.Request) DemoInfo {
if info, ok := r.Context().Value(demoInfoKey).(DemoInfo); ok {
return info
}
return DemoInfo{}
}
func demoAwareSessionMiddleware(database *db.DB) func(http.Handler) http.Handler {
sessionMW := auth.SessionMiddleware(database)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if GetDemoInfo(r).IsDemo {
next.ServeHTTP(w, r)
return
}
sessionMW(next).ServeHTTP(w, r)
})
}
}
func (s *Server) injectDemoBanner(html []byte, expiresAt time.Time) []byte {
scheme := "https"
if os.Getenv("ENV") != "prod" {
scheme = "http"
}
redirectURL := scheme + "://" + s.domain
banner := demoBannerHTML(expiresAt, redirectURL)
return bytes.Replace(html, []byte("</body>"), append([]byte(banner), []byte("</body>")...), 1)
}
func demoBannerHTML(expiresAt time.Time, redirectURL string) string {
expiresUnix := expiresAt.Unix()
return `<div id="demo-banner"><div class="demo-inner"><span class="demo-timer"></span><a class="demo-cta" target="_blank" href="/studio">Open Studio</a></div></div>
<style>
#demo-banner{position:fixed;bottom:0;left:0;right:0;background:rgba(220,38,38,0.95);color:#fff;font-family:system-ui,-apple-system,sans-serif;font-size:13px;z-index:99999;backdrop-filter:blur(8px);padding:10px 16px}
@media(min-width:640px){#demo-banner{bottom:auto;top:0;left:auto;right:16px;width:auto;padding:8px 16px}}
.demo-inner{display:flex;align-items:center;justify-content:center;gap:12px}
.demo-timer{font-variant-numeric:tabular-nums;font-weight:500}
.demo-timer.urgent{color:#fecaca;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.6}}
.demo-cta{background:#fff;color:#dc2626;padding:6px 12px;text-decoration:none;font-weight:600;font-size:12px;transition:transform .15s}
.demo-cta:hover{transform:scale(1.05)}
</style>
<script>
(function(){
const exp=` + strconv.FormatInt(expiresUnix, 10) + `*1000;
const timer=document.querySelector('.demo-timer');
const cta=document.querySelector('.demo-cta');
const update=()=>{
const left=Math.max(0,exp-Date.now());
const m=Math.floor(left/60000);
const s=Math.floor((left%60000)/1000);
timer.textContent=m+':'+(s<10?'0':'')+s+' remaining';
if(left<30000)timer.classList.add('urgent');
if(left<=0){
timer.textContent='Demo expired';
setTimeout(()=>{
const sub=location.hostname.split('.')[0];
location.href='` + redirectURL + `?expired=true&subdomain='+sub;
},2000);
return;
}
requestAnimationFrame(update);
};
update();
if(location.pathname.startsWith('/studio'))cta.textContent='View Site',cta.href='/posts';
})();
</script>`
}
func generateFakeAnalytics(days int) *tenant.AnalyticsSummary {
if days <= 0 {
days = 30
}
baseViews := 25 + rand.Intn(20)
var totalViews, totalVisitors int64
viewsByDay := make([]tenant.DailyStats, days)
for i := 0; i < days; i++ {
date := time.Now().AddDate(0, 0, -days+i+1)
weekday := date.Weekday()
multiplier := 1.0
if weekday == time.Saturday || weekday == time.Sunday {
multiplier = 0.6
} else if weekday == time.Monday {
multiplier = 1.2
}
dailyViews := int64(float64(baseViews+rand.Intn(15)) * multiplier)
dailyVisitors := dailyViews * int64(65+rand.Intn(15)) / 100
totalViews += dailyViews
totalVisitors += dailyVisitors
viewsByDay[i] = tenant.DailyStats{
Date: date.Format("2006-01-02"),
Views: dailyViews,
Visitors: dailyVisitors,
}
}
return &tenant.AnalyticsSummary{
TotalViews: totalViews,
TotalPageViews: totalViews,
UniqueVisitors: totalVisitors,
TotalBandwidth: totalViews * 45000,
ViewsChange: float64(rand.Intn(300)-100) / 10,
ViewsByDay: viewsByDay,
TopPages: []tenant.PageStats{
{Path: "/", Views: totalViews * 25 / 100},
{Path: "/posts/shipping-a-side-project", Views: totalViews * 22 / 100},
{Path: "/posts/debugging-production-like-a-detective", Views: totalViews * 18 / 100},
{Path: "/posts/sqlite-in-production", Views: totalViews * 15 / 100},
{Path: "/posts", Views: totalViews * 12 / 100},
{Path: "/posts/my-2024-reading-list", Views: totalViews * 8 / 100},
},
TopReferrers: []tenant.ReferrerStats{
{Referrer: "Google", Views: totalViews * 30 / 100},
{Referrer: "Twitter/X", Views: totalViews * 20 / 100},
{Referrer: "GitHub", Views: totalViews * 15 / 100},
{Referrer: "Hacker News", Views: totalViews * 12 / 100},
{Referrer: "LinkedIn", Views: totalViews * 10 / 100},
{Referrer: "YouTube", Views: totalViews * 8 / 100},
{Referrer: "Reddit", Views: totalViews * 5 / 100},
},
Browsers: []tenant.NamedStat{
{Name: "Chrome", Count: totalVisitors * 55 / 100},
{Name: "Safari", Count: totalVisitors * 25 / 100},
{Name: "Firefox", Count: totalVisitors * 12 / 100},
{Name: "Edge", Count: totalVisitors * 8 / 100},
},
OS: []tenant.NamedStat{
{Name: "macOS", Count: totalVisitors * 45 / 100},
{Name: "Windows", Count: totalVisitors * 30 / 100},
{Name: "iOS", Count: totalVisitors * 15 / 100},
{Name: "Linux", Count: totalVisitors * 7 / 100},
{Name: "Android", Count: totalVisitors * 3 / 100},
},
Devices: []tenant.NamedStat{
{Name: "Desktop", Count: totalVisitors * 70 / 100},
{Name: "Mobile", Count: totalVisitors * 25 / 100},
{Name: "Tablet", Count: totalVisitors * 5 / 100},
},
Countries: []tenant.NamedStat{
{Name: "United States", Count: totalVisitors * 40 / 100},
{Name: "United Kingdom", Count: totalVisitors * 12 / 100},
{Name: "Germany", Count: totalVisitors * 10 / 100},
{Name: "Canada", Count: totalVisitors * 8 / 100},
{Name: "France", Count: totalVisitors * 6 / 100},
{Name: "Australia", Count: totalVisitors * 5 / 100},
},
}
}
func generateFakePostAnalytics(days int) *tenant.AnalyticsSummary {
if days <= 0 {
days = 30
}
baseViews := 5 + rand.Intn(8)
var totalViews int64
viewsByDay := make([]tenant.DailyStats, days)
for i := 0; i < days; i++ {
date := time.Now().AddDate(0, 0, -days+i+1)
dailyViews := int64(baseViews + rand.Intn(6))
totalViews += dailyViews
viewsByDay[i] = tenant.DailyStats{
Date: date.Format("2006-01-02"),
Views: dailyViews,
}
}
return &tenant.AnalyticsSummary{
TotalViews: totalViews,
ViewsByDay: viewsByDay,
}
}

649
internal/server/platform.go Normal file
View file

@ -0,0 +1,649 @@
package server
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"regexp"
"strings"
"github.com/writekitapp/writekit/internal/auth"
)
//go:embed templates/*.html
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
var subdomainRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`)
func (s *Server) platformHome(w http.ResponseWriter, r *http.Request) {
content, err := templatesFS.ReadFile("templates/index.html")
if err != nil {
slog.Error("read index template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
html := string(content)
html = strings.ReplaceAll(html, "{{ACCENT}}", "#10b981")
html = strings.ReplaceAll(html, "{{VERSION}}", "v1.0.0")
html = strings.ReplaceAll(html, "{{COMMIT}}", "dev")
html = strings.ReplaceAll(html, "{{DEMO_MINUTES}}", "15")
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(html))
}
func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
content, err := templatesFS.ReadFile("templates/404.html")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusNotFound)
w.Write(content)
}
func (s *Server) platformLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/signup", http.StatusFound)
}
func (s *Server) platformSignup(w http.ResponseWriter, r *http.Request) {
content, err := templatesFS.ReadFile("templates/signup.html")
if err != nil {
slog.Error("read signup template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
html := string(content)
html = strings.ReplaceAll(html, "{{ACCENT}}", "#10b981")
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(html))
}
func (s *Server) platformDashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html>
<html>
<head><title>Dashboard - WriteKit</title></head>
<body>
<h1>Dashboard</h1>
<p>Create your blog or manage your existing one.</p>
</body>
</html>`))
}
func (s *Server) serveStaticAssets(w http.ResponseWriter, r *http.Request) {
sub, err := fs.Sub(staticFS, "static")
if err != nil {
http.NotFound(w, r)
return
}
http.StripPrefix("/assets/", http.FileServer(http.FS(sub))).ServeHTTP(w, r)
}
func (s *Server) checkSubdomain(w http.ResponseWriter, r *http.Request) {
subdomain := strings.ToLower(r.URL.Query().Get("subdomain"))
if subdomain == "" {
jsonError(w, http.StatusBadRequest, "subdomain required")
return
}
if !subdomainRegex.MatchString(subdomain) {
jsonResponse(w, http.StatusOK, map[string]any{
"available": false,
"reason": "invalid format",
})
return
}
available, err := s.database.IsSubdomainAvailable(r.Context(), subdomain)
if err != nil {
slog.Error("check subdomain", "error", err)
jsonError(w, http.StatusInternalServerError, "internal error")
return
}
jsonResponse(w, http.StatusOK, map[string]any{"available": available})
}
func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r)
var req struct {
Subdomain string `json:"subdomain"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
subdomain := strings.ToLower(req.Subdomain)
if !subdomainRegex.MatchString(subdomain) {
jsonError(w, http.StatusBadRequest, "invalid subdomain format")
return
}
existing, err := s.database.GetTenantByOwner(r.Context(), userID)
if err != nil {
slog.Error("check existing tenant", "error", err)
jsonError(w, http.StatusInternalServerError, "internal error")
return
}
if existing != nil {
jsonResponse(w, http.StatusConflict, map[string]any{
"error": "you already have a blog",
"url": s.buildURL(existing.Subdomain),
})
return
}
available, err := s.database.IsSubdomainAvailable(r.Context(), subdomain)
if err != nil {
slog.Error("check availability", "error", err)
jsonError(w, http.StatusInternalServerError, "internal error")
return
}
if !available {
jsonError(w, http.StatusConflict, "subdomain not available")
return
}
tenant, err := s.database.CreateTenant(r.Context(), userID, subdomain)
if err != nil {
slog.Error("create tenant", "error", err)
jsonError(w, http.StatusInternalServerError, "failed to create tenant")
return
}
if _, err := s.tenantPool.Get(tenant.ID); err != nil {
slog.Error("init tenant db", "tenant_id", tenant.ID, "error", err)
}
user, _ := s.database.GetUserByID(r.Context(), userID)
if user != nil {
if tenantDB, err := s.tenantPool.Get(tenant.ID); err == nil {
tenantDB.Exec(`INSERT INTO site_settings (key, value) VALUES ('author_name', ?) ON CONFLICT DO NOTHING`, user.Name)
tenantDB.Exec(`INSERT INTO site_settings (key, value) VALUES ('author_avatar', ?) ON CONFLICT DO NOTHING`, user.AvatarURL)
}
}
slog.Info("tenant created", "subdomain", subdomain, "user_id", userID, "tenant_id", tenant.ID)
jsonResponse(w, http.StatusCreated, map[string]any{
"subdomain": subdomain,
"url": s.buildURL(subdomain),
"tenant_id": tenant.ID,
})
}
func (s *Server) getTenant(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r)
tenant, err := s.database.GetTenantByOwner(r.Context(), userID)
if err != nil {
slog.Error("get tenant", "error", err)
jsonError(w, http.StatusInternalServerError, "internal error")
return
}
if tenant == nil {
jsonResponse(w, http.StatusOK, map[string]any{"has_tenant": false})
return
}
jsonResponse(w, http.StatusOK, map[string]any{
"has_tenant": true,
"tenant": map[string]any{
"id": tenant.ID,
"subdomain": tenant.Subdomain,
"url": s.buildURL(tenant.Subdomain),
"premium": tenant.Premium,
},
})
}
func jsonResponse(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func jsonError(w http.ResponseWriter, status int, msg string) {
jsonResponse(w, status, map[string]string{"error": msg})
}
func (s *Server) buildURL(subdomain string) string {
scheme := "https"
if env := os.Getenv("ENV"); env != "prod" {
scheme = "http"
}
return fmt.Sprintf("%s://%s.%s", scheme, subdomain, s.domain)
}
func (s *Server) createDemo(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
referer := r.Header.Get("Referer")
validOrigins := []string{
"https://" + s.domain,
"https://www." + s.domain,
}
if env := os.Getenv("ENV"); env != "prod" {
validOrigins = append(validOrigins,
"http://"+s.domain,
"http://www."+s.domain,
)
}
isValidOrigin := func(o string) bool {
for _, v := range validOrigins {
if o == v {
return true
}
}
return false
}
isValidReferer := func(ref string) bool {
for _, v := range validOrigins {
if strings.HasPrefix(ref, v) {
return true
}
}
return false
}
if origin != "" && !isValidOrigin(origin) {
jsonError(w, http.StatusForbidden, "forbidden")
return
}
if origin == "" && referer != "" && !isValidReferer(referer) {
jsonError(w, http.StatusForbidden, "forbidden")
return
}
var req struct {
Name string `json:"name"`
Color string `json:"color"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Name == "" {
req.Name = "Demo User"
}
if req.Color == "" {
req.Color = "#10b981"
}
demo, err := s.database.CreateDemo(r.Context())
if err != nil {
slog.Error("create demo", "error", err)
jsonError(w, http.StatusInternalServerError, "failed to create demo")
return
}
s.tenantPool.MarkAsDemo(demo.ID)
if _, err := s.tenantPool.Get(demo.ID); err != nil {
slog.Error("init demo db", "demo_id", demo.ID, "error", err)
}
if err := s.seedDemoContent(demo.ID, req.Name, req.Color); err != nil {
slog.Error("seed demo content", "demo_id", demo.ID, "error", err)
}
slog.Info("demo created", "subdomain", demo.Subdomain, "demo_id", demo.ID, "expires_at", demo.ExpiresAt)
jsonResponse(w, http.StatusCreated, map[string]any{
"subdomain": demo.Subdomain,
"url": s.buildURL(demo.Subdomain),
"expires_at": demo.ExpiresAt.Format("2006-01-02T15:04:05Z"),
})
}
func (s *Server) seedDemoContent(demoID, authorName, accentColor string) error {
db, err := s.tenantPool.Get(demoID)
if err != nil {
return fmt.Errorf("get tenant pool: %w", err)
}
result, err := db.Exec(`INSERT INTO site_settings (key, value) VALUES
('site_name', ?),
('site_description', 'Thoughts on building software, developer tools, and the craft of engineering.'),
('accent_color', ?),
('author_name', ?),
('is_demo', 'true')
ON CONFLICT DO NOTHING`, authorName+"'s Blog", accentColor, authorName)
if err != nil {
return fmt.Errorf("insert settings: %w", err)
}
rows, _ := result.RowsAffected()
slog.Info("seed demo settings", "demo_id", demoID, "rows_affected", rows)
_, err = db.Exec(`INSERT INTO posts (id, slug, title, description, content_md, tags, cover_image, is_published, published_at, created_at, modified_at) VALUES
('demo-post-1', 'shipping-a-side-project',
'I Finally Shipped My Side Project',
'After mass years of abandoned repos, here is what finally worked.',
'I have mass abandoned side projects. We all do. But last month, I actually shipped one.
Here''s what I did differently this time.
## The Graveyard
Before we get to what worked, let me be honest about what didn''t. My GitHub is full of:
- [ ] A "better" todo app (mass of features planned, mass built)
- [ ] A CLI tool I used once
- [ ] Three different blog engines (ironic, I know)
- [x] This project finally shipped
The pattern was always the same: mass enthusiasm for a week, mass silence forever.
## What Changed
This time, I set one rule: **ship in two weeks or kill it**.
> "If you''re not embarrassed by the first version of your product, you''ve launched too late." Reid Hoffman
I printed that quote and stuck it on my monitor. Every time I wanted to add "just one more feature," I looked at it.
## The Stack
I kept it minimal:
| Layer | Choice | Why |
|-------|--------|-----|
| Frontend | React | I know it well |
| Backend | Go | Fast, simple deploys |
| Database | SQLite | No ops overhead |
| Hosting | Fly.io | $0 to start |
~~I originally planned to use Kubernetes~~ glad I didn''t. SQLite on a single server handles more traffic than I''ll ever get.
## The Launch
I posted on Twitter, mass likes. mass signups. Then... mass complaints about bugs I hadn''t tested for.
But here''s the thing: **those bugs only exist because I shipped**. I fixed them in a day. If I''d waited for "perfect," I''d still be tweaking CSS.
---
Ship something this week. mass needs to be big. Your mass abandoned project could be mass person''s favorite tool.',
'["career","shipping"]',
'https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=1200&h=630&fit=crop',
1,
datetime('now', '-2 days'),
datetime('now', '-2 days'),
datetime('now', '-2 days')),
('demo-post-2', 'debugging-production-like-a-detective',
'Debugging Production Like a Detective',
'A systematic approach to finding bugs when console.log is not enough.',
'Last week, our API started returning 500 errors. Not always just enough to be infuriating. Here''s how I tracked it down.
## The Symptoms
Users reported "random failures." The logs showed:
` + "```" + `
error: connection refused
error: connection refused
error: context deadline exceeded
` + "```" + `
Helpful, right?
## Step 1: Gather Evidence
First, I needed data. I added structured logging:
` + "```go" + `
slog.Error("request failed",
"endpoint", r.URL.Path,
"user_id", userID,
"duration_ms", time.Since(start).Milliseconds(),
"error", err,
)
` + "```" + `
Within an hour, a pattern emerged:
| Time | Endpoint | Duration | Result |
|------|----------|----------|--------|
| 14:01 | /api/posts | 45ms | OK |
| 14:01 | /api/posts | 52ms | OK |
| 14:02 | /api/posts | 30,004ms | FAIL |
| 14:02 | /api/users | 48ms | OK |
The failures were always *exactly* 30 seconds our timeout value.
## Step 2: Form a Hypothesis
> The 30-second timeout suggested a connection hanging, not failing fast.
I suspected connection pool exhaustion. Our pool was set to 10 connections. Under load, requests would wait for a free connection, then timeout.
## Step 3: Test the Theory
I checked the pool stats:
` + "```sql" + `
SELECT count(*) FROM pg_stat_activity
WHERE application_name = ''myapp'';
` + "```" + `
Result: **10**. Exactly at the limit.
## The Fix
` + "```go" + `
db.SetMaxOpenConns(25) // was: 10
db.SetMaxIdleConns(10) // was: 2
db.SetConnMaxLifetime(time.Hour)
` + "```" + `
Three lines. Two days of debugging. Zero more timeouts.
---
The lesson: **when debugging production, be a detective, not a guesser**. Gather evidence, form hypotheses, test them.',
'["debugging","golang","databases"]',
'https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=1200&h=630&fit=crop',
1,
datetime('now', '-5 days'),
datetime('now', '-5 days'),
datetime('now', '-5 days')),
('demo-post-3', 'sqlite-in-production',
'Yes, You Can Use SQLite in Production',
'How to scale SQLite further than you think and when to finally migrate.',
'Every time I mention SQLite in production, someone says "that doesn''t scale." Let me share some numbers.
## The Reality
SQLite handles:
- **Millions** of reads per second
- **Thousands** of writes per second (with WAL mode)
- Databases up to **281 TB** (theoretical limit)
For context, that''s more than most apps will ever need.
## Configuration That Matters
The defaults are conservative. For production, I use:
` + "```sql" + `
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA cache_size = -64000; -- 64MB cache
PRAGMA busy_timeout = 5000;
` + "```" + `
This gives you:
| Setting | Default | Production | Impact |
|---------|---------|------------|--------|
| journal_mode | DELETE | WAL | Concurrent reads during writes |
| synchronous | FULL | NORMAL | 10x faster writes, still safe |
| cache_size | -2000 | -64000 | Fewer disk reads |
## When to Migrate
SQLite is **not** right when you need:
1. Multiple servers writing to the same database
2. ~~Horizontal scaling~~ (though read replicas now exist via Litestream)
3. Sub-millisecond writes under heavy contention
If you''re running a single server which is most apps SQLite is great.
> "I''ve never used a database that made me this happy." every SQLite user, probably
## Getting Started
Here''s my standard setup:
` + "```go" + `
db, err := sql.Open("sqlite3", "file:app.db?_journal=WAL&_timeout=5000")
if err != nil {
log.Fatal(err)
}
db.SetMaxOpenConns(1) // SQLite writes are serialized anyway
// Run migrations
if _, err := db.Exec(schema); err != nil {
log.Fatal(err)
}
` + "```" + `
---
Stop overengineering. Start with SQLite. Migrate when you *actually* hit limits you probably never will.',
'["sqlite","databases","architecture"]',
'https://images.unsplash.com/photo-1544383835-bda2bc66a55d?w=1200&h=630&fit=crop',
1,
datetime('now', '-8 days'),
datetime('now', '-8 days'),
datetime('now', '-8 days')),
('demo-post-4', 'my-2024-reading-list',
'My 2024 Reading List',
'',
'Here are the books that shaped my thinking this year.
## Technical Books
- **Designing Data-Intensive Applications** by Martin Kleppmann
- **The Pragmatic Programmer** by David Thomas
- **Staff Engineer** by Will Larson
## Non-Technical
- **The Mom Test** by Rob Fitzpatrick
- **Building a Second Brain** by Tiago Forte
More thoughts on each coming soon...',
'["books","learning"]',
'',
1,
datetime('now', '-12 days'),
datetime('now', '-12 days'),
datetime('now', '-12 days')),
('demo-draft-1', 'understanding-react-server-components',
'Understanding React Server Components',
'A deep dive into RSC architecture and when to use them.',
'React Server Components are confusing. Let me try to explain them.
## The Problem RSC Solves
Traditional React apps ship a lot of JavaScript to the browser. RSC lets you run components on the server and send only the HTML.
## Key Concepts
TODO: Add diagrams
## When to Use RSC
- Data fetching
- Heavy dependencies
- SEO-critical pages
## When NOT to Use RSC
- Interactive components
- Client state
- Event handlers
Still writing this one...',
'["react","architecture"]',
'',
0,
NULL,
datetime('now', '-1 days'),
datetime('now', '-2 hours')),
('demo-draft-2', 'vim-tricks-i-use-daily',
'Vim Tricks I Use Daily',
'The shortcuts that actually stuck after 5 years of using Vim.',
'After mass years of Vim, these are the commands I use daily.
## Navigation
- Ctrl+d and Ctrl+u for half page down/up
- zz to center current line
## Editing
Still collecting my favorites...',
'["vim","productivity"]',
'',
0,
NULL,
datetime('now', '-3 days'),
datetime('now', '-1 days'))
ON CONFLICT DO NOTHING`)
if err != nil {
return fmt.Errorf("insert posts: %w", err)
}
slog.Info("seed demo posts", "demo_id", demoID)
return nil
}
func (s *Server) ensureDemoSeeded(demoID string) {
db, err := s.tenantPool.Get(demoID)
if err != nil {
slog.Error("ensureDemoSeeded: get pool", "demo_id", demoID, "error", err)
return
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM posts").Scan(&count)
if err != nil {
slog.Error("ensureDemoSeeded: count posts", "demo_id", demoID, "error", err)
return
}
if count > 0 {
return
}
slog.Info("re-seeding demo content", "demo_id", demoID)
if err := s.seedDemoContent(demoID, "Demo User", "#10b981"); err != nil {
slog.Error("ensureDemoSeeded: seed failed", "demo_id", demoID, "error", err)
}
}

View file

@ -0,0 +1,126 @@
package server
import (
"net/http"
"sync"
"time"
"github.com/writekitapp/writekit/internal/config"
)
type bucket struct {
tokens float64
lastFill time.Time
rateLimit int
}
type RateLimiter struct {
mu sync.RWMutex
buckets map[string]*bucket
}
func NewRateLimiter() *RateLimiter {
rl := &RateLimiter{
buckets: make(map[string]*bucket),
}
go rl.cleanup()
return rl
}
func (rl *RateLimiter) Allow(tenantID string, limit int) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
b, ok := rl.buckets[tenantID]
if !ok {
b = &bucket{
tokens: float64(limit),
lastFill: now,
rateLimit: limit,
}
rl.buckets[tenantID] = b
}
if b.rateLimit != limit {
b.rateLimit = limit
b.tokens = float64(limit)
}
elapsed := now.Sub(b.lastFill)
tokensToAdd := elapsed.Hours() * float64(limit)
b.tokens = min(b.tokens+tokensToAdd, float64(limit))
b.lastFill = now
if b.tokens >= 1 {
b.tokens--
return true
}
return false
}
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
rl.mu.Lock()
threshold := time.Now().Add(-1 * time.Hour)
for k, b := range rl.buckets {
if b.lastFill.Before(threshold) {
delete(rl.buckets, k)
}
}
rl.mu.Unlock()
}
}
func (s *Server) apiRateLimitMiddleware(rl *RateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID, ok := r.Context().Value(tenantIDKey).(string)
if !ok || tenantID == "" {
next.ServeHTTP(w, r)
return
}
t, err := s.database.GetTenantByID(r.Context(), tenantID)
if err != nil {
next.ServeHTTP(w, r)
return
}
premium := t != nil && t.Premium
tierInfo := config.GetTierInfo(premium)
limit := tierInfo.Config.APIRateLimit
if !rl.Allow(tenantID, limit) {
w.Header().Set("X-RateLimit-Limit", itoa(limit))
w.Header().Set("X-RateLimit-Reset", "3600")
w.Header().Set("Retry-After", "60")
jsonError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
w.Header().Set("X-RateLimit-Limit", itoa(limit))
next.ServeHTTP(w, r)
})
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
s := ""
for n > 0 {
s = string(rune('0'+n%10)) + s
n /= 10
}
return s
}
func min(a, b float64) float64 {
if a < b {
return a
}
return b
}

634
internal/server/reader.go Normal file
View file

@ -0,0 +1,634 @@
package server
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/writekitapp/writekit/internal/tenant"
)
func (s *Server) readerRoutes() chi.Router {
r := chi.NewRouter()
r.Get("/login/{provider}", s.readerLogin)
r.Get("/auth/callback", s.readerAuthCallback)
r.Get("/auth/providers", s.readerAuthProviders)
r.Group(func(r chi.Router) {
r.Use(s.readerAuthMiddleware)
r.Get("/config", s.getInteractionConfig)
r.Get("/posts/{slug}/comments", s.listComments)
r.Post("/posts/{slug}/comments", s.createComment)
r.Delete("/comments/{id}", s.deleteComment)
r.Get("/posts/{slug}/reactions", s.getReactions)
r.Post("/posts/{slug}/reactions", s.toggleReaction)
r.Get("/me", s.getReaderMe)
r.Post("/logout", s.readerLogout)
r.Get("/search", s.search)
})
return r
}
func (s *Server) getInteractionConfig(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
config := q.GetInteractionConfig(r.Context())
jsonResponse(w, http.StatusOK, config)
}
func (s *Server) listComments(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
comments, err := q.ListComments(r.Context(), slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to list comments")
return
}
jsonResponse(w, http.StatusOK, comments)
}
func (s *Server) createComment(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
if q.GetSettingWithDefault(r.Context(), "comments_enabled") != "true" {
jsonError(w, http.StatusForbidden, "comments disabled")
return
}
var req struct {
Content string `json:"content"`
ParentID *int64 `json:"parent_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Content == "" {
jsonError(w, http.StatusBadRequest, "content required")
return
}
userID := getReaderUserID(r)
if userID == "" {
jsonError(w, http.StatusUnauthorized, "login required")
return
}
// Get user info for validation
user, _ := q.GetUserByID(r.Context(), userID)
authorName := "Anonymous"
authorEmail := ""
if user != nil {
authorName = user.Name
authorEmail = user.Email
}
// Validate comment via plugins
runner := s.getPluginRunner(tenantID, db)
allowed, reason, _ := runner.TriggerValidation(r.Context(), "comment.validate", map[string]any{
"content": req.Content,
"authorName": authorName,
"authorEmail": authorEmail,
"postSlug": slug,
})
runner.Close()
if !allowed {
msg := "Comment rejected"
if reason != "" {
msg = reason
}
jsonError(w, http.StatusForbidden, msg)
return
}
comment := &tenant.Comment{
UserID: userID,
PostSlug: slug,
Content: req.Content,
ParentID: req.ParentID,
}
if err := q.CreateComment(r.Context(), comment); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to create comment")
return
}
// Trigger comment.created hook
go func() {
runner := s.getPluginRunner(tenantID, db)
defer runner.Close()
// Get post info
post, _ := q.GetPost(r.Context(), slug)
postData := map[string]any{"slug": slug, "title": slug, "url": "/" + slug}
if post != nil {
postData["title"] = post.Title
}
runner.TriggerHook(r.Context(), "comment.created", map[string]any{
"comment": map[string]any{
"id": comment.ID,
"content": comment.Content,
"authorName": authorName,
"authorEmail": authorEmail,
"postSlug": slug,
"createdAt": comment.CreatedAt.Format(time.RFC3339),
},
"post": postData,
})
}()
jsonResponse(w, http.StatusCreated, comment)
}
func (s *Server) deleteComment(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
id := chi.URLParam(r, "id")
userID := getReaderUserID(r)
if userID == "" {
jsonError(w, http.StatusUnauthorized, "login required")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
var commentID int64
if _, err := json.Number(id).Int64(); err != nil {
jsonError(w, http.StatusBadRequest, "invalid comment id")
return
}
commentID, _ = json.Number(id).Int64()
comment, err := q.GetComment(r.Context(), commentID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get comment")
return
}
if comment == nil {
jsonError(w, http.StatusNotFound, "comment not found")
return
}
if comment.UserID != userID {
jsonError(w, http.StatusForbidden, "not your comment")
return
}
if err := q.DeleteComment(r.Context(), commentID); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to delete comment")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) getReactions(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
counts, err := q.GetReactionCounts(r.Context(), slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get reactions")
return
}
var userReactions []string
userID := getReaderUserID(r)
if userID != "" {
userReactions, _ = q.GetUserReactions(r.Context(), userID, slug)
} else if anonID := getAnonID(r); anonID != "" {
userReactions, _ = q.GetAnonReactions(r.Context(), anonID, slug)
}
jsonResponse(w, http.StatusOK, map[string]any{
"counts": counts,
"user": userReactions,
})
}
func (s *Server) toggleReaction(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
if q.GetSettingWithDefault(r.Context(), "reactions_enabled") != "true" {
jsonError(w, http.StatusForbidden, "reactions disabled")
return
}
var req struct {
Emoji string `json:"emoji"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Emoji == "" {
jsonError(w, http.StatusBadRequest, "emoji required")
return
}
userID := getReaderUserID(r)
anonID := ""
requireAuth := q.GetSettingWithDefault(r.Context(), "reactions_require_auth") == "true"
if userID == "" {
if requireAuth {
jsonError(w, http.StatusUnauthorized, "login required")
return
}
anonID = getOrCreateAnonID(w, r)
}
added, err := q.ToggleReaction(r.Context(), userID, anonID, slug, req.Emoji)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to toggle reaction")
return
}
jsonResponse(w, http.StatusOK, map[string]bool{"added": added})
}
func (s *Server) search(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
query := r.URL.Query().Get("q")
if query == "" {
jsonResponse(w, http.StatusOK, []any{})
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
results, err := q.Search(r.Context(), query, 20)
if err != nil {
jsonResponse(w, http.StatusOK, []any{})
return
}
jsonResponse(w, http.StatusOK, results)
}
func (s *Server) getReaderMe(w http.ResponseWriter, r *http.Request) {
userID := getReaderUserID(r)
if userID == "" {
jsonResponse(w, http.StatusOK, map[string]any{"logged_in": false})
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
user, err := q.GetUserByID(r.Context(), userID)
if err != nil || user == nil {
jsonResponse(w, http.StatusOK, map[string]any{"logged_in": false})
return
}
jsonResponse(w, http.StatusOK, map[string]any{
"logged_in": true,
"user": map[string]any{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"avatar_url": user.AvatarURL,
},
})
}
func (s *Server) readerLogout(w http.ResponseWriter, r *http.Request) {
token := extractReaderToken(r)
if token == "" {
w.WriteHeader(http.StatusNoContent)
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err == nil {
q := tenant.NewQueries(db)
q.DeleteSession(r.Context(), token)
}
http.SetCookie(w, &http.Cookie{
Name: "reader_session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.WriteHeader(http.StatusNoContent)
}
func getReaderUserID(r *http.Request) string {
if id, ok := r.Context().Value(readerUserIDKey).(string); ok {
return id
}
return ""
}
type readerCtxKey string
const readerUserIDKey readerCtxKey = "readerUserID"
func (s *Server) readerAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractReaderToken(r)
if token == "" {
next.ServeHTTP(w, r)
return
}
tenantID, ok := r.Context().Value(tenantIDKey).(string)
if !ok || tenantID == "" {
next.ServeHTTP(w, r)
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
next.ServeHTTP(w, r)
return
}
q := tenant.NewQueries(db)
session, err := q.ValidateSession(r.Context(), token)
if err != nil || session == nil {
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), readerUserIDKey, session.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func extractReaderToken(r *http.Request) string {
if cookie, err := r.Cookie("reader_session"); err == nil && cookie.Value != "" {
return cookie.Value
}
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return strings.TrimPrefix(auth, "Bearer ")
}
return ""
}
func (s *Server) readerLogin(w http.ResponseWriter, r *http.Request) {
provider := chi.URLParam(r, "provider")
if provider != "google" && provider != "github" && provider != "discord" {
jsonError(w, http.StatusBadRequest, "invalid provider")
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
redirect := r.URL.Query().Get("redirect")
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
baseURL = "https://writekit.dev"
}
tenantInfo, err := s.database.GetTenantByID(r.Context(), tenantID)
if err != nil || tenantInfo == nil {
jsonError(w, http.StatusNotFound, "tenant not found")
return
}
domain := tenantInfo.CustomDomain
if domain == "" {
domain = tenantInfo.Subdomain + "." + s.domain
}
callbackURL := fmt.Sprintf("https://%s/api/reader/auth/callback", domain)
if redirect != "" {
callbackURL += "?redirect=" + redirect
}
authURL := fmt.Sprintf("%s/auth/%s?tenant=%s&callback=%s",
baseURL, provider, tenantID, callbackURL)
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
}
func (s *Server) readerAuthCallback(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
redirect := r.URL.Query().Get("redirect")
if token == "" {
jsonError(w, http.StatusBadRequest, "missing token")
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
baseURL = "https://writekit.dev"
}
userURL := baseURL + "/auth/user?token=" + token
resp, err := http.Get(userURL)
if err != nil {
jsonError(w, http.StatusInternalServerError, "auth failed")
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
jsonError(w, http.StatusUnauthorized, "invalid token: "+string(body))
return
}
var platformUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
}
if err := json.NewDecoder(resp.Body).Decode(&platformUser); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to parse user")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
user, err := q.GetUserByID(r.Context(), platformUser.ID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
if user == nil {
user = &tenant.User{
ID: platformUser.ID,
Email: platformUser.Email,
Name: platformUser.Name,
AvatarURL: platformUser.AvatarURL,
}
if err := q.CreateUser(r.Context(), user); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to create user")
return
}
}
session, err := q.CreateSession(r.Context(), user.ID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to create session")
return
}
tenantInfo, _ := s.database.GetTenantByID(r.Context(), tenantID)
secure := tenantInfo != nil && !strings.HasPrefix(tenantInfo.Subdomain, "localhost")
http.SetCookie(w, &http.Cookie{
Name: "reader_session",
Value: session.Token,
Path: "/",
Expires: session.ExpiresAt,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
if redirect == "" || !strings.HasPrefix(redirect, "/") || strings.HasPrefix(redirect, "//") {
redirect = "/"
}
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
}
func (s *Server) readerAuthProviders(w http.ResponseWriter, r *http.Request) {
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
baseURL = "https://writekit.dev"
}
resp, err := http.Get(baseURL + "/auth/providers")
if err != nil {
jsonResponse(w, http.StatusOK, map[string]any{"providers": []any{}})
return
}
defer resp.Body.Close()
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
jsonResponse(w, http.StatusOK, map[string]any{"providers": []any{}})
return
}
jsonResponse(w, http.StatusOK, result)
}
func getOrCreateAnonID(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie("anon_id"); err == nil && cookie.Value != "" {
return cookie.Value
}
b := make([]byte, 16)
rand.Read(b)
anonID := hex.EncodeToString(b)
http.SetCookie(w, &http.Cookie{
Name: "anon_id",
Value: anonID,
Path: "/",
MaxAge: 365 * 24 * 60 * 60,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return anonID
}
func getAnonID(r *http.Request) string {
if cookie, err := r.Cookie("anon_id"); err == nil {
return cookie.Value
}
return ""
}

163
internal/server/server.go Normal file
View file

@ -0,0 +1,163 @@
package server
import (
"context"
"database/sql"
"log/slog"
"net/http"
"os"
"strings"
"time"
"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"
)
type Server struct {
router chi.Router
database *db.DB
tenantPool *tenant.Pool
tenantCache *tenant.Cache
storage storage.Client
imaginary *imaginary.Client
cloudflare *cloudflare.Client
rateLimiter *RateLimiter
domain string
jarvisURL string
stopCleanup chan struct{}
}
func New(database *db.DB, pool *tenant.Pool, cache *tenant.Cache, storageClient storage.Client) *Server {
domain := os.Getenv("DOMAIN")
if domain == "" {
domain = "writekit.dev"
}
jarvisURL := os.Getenv("JARVIS_URL")
if jarvisURL == "" {
jarvisURL = "http://localhost:8090"
}
var imgClient *imaginary.Client
if url := os.Getenv("IMAGINARY_URL"); url != "" {
imgClient = imaginary.New(url)
}
cfClient := cloudflare.NewClient()
s := &Server{
router: chi.NewRouter(),
database: database,
tenantPool: pool,
tenantCache: cache,
storage: storageClient,
imaginary: imgClient,
cloudflare: cfClient,
rateLimiter: NewRateLimiter(),
domain: domain,
jarvisURL: jarvisURL,
stopCleanup: make(chan struct{}),
}
s.router.Use(middleware.Logger)
s.router.Use(middleware.Recoverer)
s.router.Use(middleware.Compress(5))
s.routes()
go s.cleanupDemos()
return s
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}
func (s *Server) routes() {
s.router.HandleFunc("/*", s.route)
}
func (s *Server) route(w http.ResponseWriter, r *http.Request) {
host := r.Host
if idx := strings.Index(host, ":"); idx != -1 {
host = host[:idx]
}
if host == s.domain || host == "www."+s.domain {
s.servePlatform(w, r)
return
}
if strings.HasSuffix(host, "."+s.domain) {
subdomain := strings.TrimSuffix(host, "."+s.domain)
s.serveBlog(w, r, subdomain)
return
}
s.notFound(w, r)
}
func (s *Server) servePlatform(w http.ResponseWriter, r *http.Request) {
mux := chi.NewRouter()
mux.NotFound(s.notFound)
mux.Get("/", s.platformHome)
mux.Get("/login", s.platformLogin)
mux.Get("/signup", s.platformSignup)
mux.Get("/signup/complete", s.platformSignup)
mux.Get("/dashboard", s.platformDashboard)
mux.Handle("/assets/*", http.HandlerFunc(s.serveStaticAssets))
mux.Mount("/auth", auth.NewHandler(s.database).Routes())
mux.Route("/api", func(r chi.Router) {
r.Get("/tenant/check", s.checkSubdomain)
r.Post("/demo", s.createDemo)
r.Group(func(r chi.Router) {
r.Use(auth.SessionMiddleware(s.database))
r.Post("/tenant", s.createTenant)
r.Get("/tenant", s.getTenant)
})
})
mux.ServeHTTP(w, r)
}
func (s *Server) cleanupDemos() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
expired, err := s.database.CleanupExpiredDemos(context.Background())
if err != nil {
slog.Error("cleanup expired demos", "error", err)
continue
}
for _, d := range expired {
s.tenantPool.Evict(d.ID)
s.tenantCache.Delete(d.Subdomain)
slog.Info("cleaned up expired demo", "demo_id", d.ID, "subdomain", d.Subdomain)
}
case <-s.stopCleanup:
return
}
}
}
func (s *Server) Close() {
close(s.stopCleanup)
}
// getPluginRunner returns a PluginRunner for the given tenant
func (s *Server) getPluginRunner(tenantID string, db *sql.DB) *tenant.PluginRunner {
return tenant.NewPluginRunner(db, tenantID)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="writekit-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#10b981"/>
<stop offset="100%" style="stop-color:#06b6d4"/>
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="256" cy="256" r="256" fill="url(#writekit-gradient)"/>
<!-- Open book icon (scaled and centered) -->
<g transform="translate(96, 96) scale(13.33)">
<path
fill="none"
stroke="white"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 961 B

2052
internal/server/studio.go Normal file

File diff suppressed because it is too large Load diff

113
internal/server/sync.go Normal file
View file

@ -0,0 +1,113 @@
package server
import (
"context"
"log"
"time"
"github.com/writekitapp/writekit/internal/tenant"
)
func (s *Server) StartAnalyticsSync() {
if !s.cloudflare.IsConfigured() {
return
}
go s.runAnalyticsSync()
}
func (s *Server) runAnalyticsSync() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
s.syncYesterdayAnalytics()
for range ticker.C {
s.syncYesterdayAnalytics()
}
}
func (s *Server) syncYesterdayAnalytics() {
ctx := context.Background()
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
tenants, err := s.database.ListTenants(ctx)
if err != nil {
log.Printf("analytics sync: list tenants: %v", err)
return
}
for _, t := range tenants {
s.syncTenantAnalytics(ctx, t.ID, t.Subdomain, yesterday)
}
demos, err := s.database.ListActiveDemos(ctx)
if err != nil {
log.Printf("analytics sync: list demos: %v", err)
return
}
for _, d := range demos {
s.syncTenantAnalytics(ctx, d.ID, d.Subdomain, yesterday)
}
}
func (s *Server) syncTenantAnalytics(ctx context.Context, tenantID, subdomain, date string) {
hostname := subdomain + "." + s.domain
tenantDB, err := s.tenantPool.Get(tenantID)
if err != nil {
log.Printf("analytics sync: get tenant db %s: %v", tenantID, err)
return
}
q := tenant.NewQueries(tenantDB)
has, err := q.HasArchivedDate(ctx, date)
if err != nil {
log.Printf("analytics sync: check archived %s: %v", tenantID, err)
return
}
if has {
return
}
cfData, err := s.cloudflare.GetAnalytics(ctx, 1, hostname)
if err != nil {
log.Printf("analytics sync: fetch cf data %s: %v", tenantID, err)
return
}
if cfData == nil || len(cfData.Daily) == 0 {
return
}
day := cfData.Daily[0]
archived := &tenant.ArchivedDay{
Date: day.Date,
Requests: day.Requests,
PageViews: day.PageViews,
UniqueVisitors: day.Visitors,
Bandwidth: day.Bandwidth,
}
for _, b := range cfData.Browsers {
archived.Browsers = append(archived.Browsers, tenant.NamedStat{Name: b.Name, Count: b.Count})
}
for _, o := range cfData.OS {
archived.OS = append(archived.OS, tenant.NamedStat{Name: o.Name, Count: o.Count})
}
for _, d := range cfData.Devices {
archived.Devices = append(archived.Devices, tenant.NamedStat{Name: d.Name, Count: d.Count})
}
for _, c := range cfData.Countries {
archived.Countries = append(archived.Countries, tenant.NamedStat{Name: c.Name, Count: c.Count})
}
for _, p := range cfData.Paths {
archived.Paths = append(archived.Paths, tenant.PageStats{Path: p.Path, Views: p.Requests})
}
if err := q.SaveDailyAnalytics(ctx, archived); err != nil {
log.Printf("analytics sync: save archived %s: %v", tenantID, err)
}
}

View file

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404 — WriteKit</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" />
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#fafafa;--text:#0a0a0a;--muted:#737373;--border:#e5e5e5;--primary:#10b981}
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.6}
.mono{font-family:'SF Mono','Fira Code','Consolas',monospace}
.layout{display:grid;grid-template-columns:200px 1fr;min-height:100vh}
aside{padding:2rem 1.5rem;border-right:1px solid var(--border);position:sticky;top:0;height:100vh;display:flex;flex-direction:column}
.sidebar-logo{font-family:'SF Mono','Fira Code',monospace;font-weight:600;font-size:14px;letter-spacing:-0.02em;margin-bottom:0.25rem}
.sidebar-tagline{font-family:'SF Mono','Fira Code',monospace;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2.5rem}
.sidebar-nav{display:flex;flex-direction:column;gap:0.25rem}
.sidebar-nav a{font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted);text-decoration:none;padding:0.5rem 0;transition:color 0.15s}
.sidebar-nav a:hover{color:var(--text)}
.sidebar-footer{margin-top:auto}
.env-badge{font-family:'SF Mono','Fira Code',monospace;display:inline-block;padding:0.35rem 0.75rem;border:1px solid var(--primary);font-size:10px;text-transform:uppercase;letter-spacing:0.05em;color:var(--primary)}
main{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:2rem}
.error-content{text-align:center;max-width:400px}
.error-code{font-family:'SF Mono','Fira Code',monospace;font-size:120px;font-weight:600;letter-spacing:-0.05em;line-height:1;color:var(--border);margin-bottom:1rem}
.error-title{font-size:1.5rem;font-weight:500;letter-spacing:-0.02em;margin-bottom:0.75rem}
.error-message{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--muted);margin-bottom:2rem;line-height:1.7}
.error-actions{display:flex;gap:1rem;justify-content:center;flex-wrap:wrap}
.btn-primary{padding:12px 24px;background:var(--text);color:var(--bg);text-decoration:none;font-family:'SF Mono','Fira Code',monospace;font-size:13px;transition:all 0.2s}
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.15)}
.btn-secondary{padding:12px 24px;background:transparent;color:var(--text);border:1px solid var(--border);text-decoration:none;font-family:'SF Mono','Fira Code',monospace;font-size:13px;transition:all 0.15s}
.btn-secondary:hover{border-color:var(--text)}
@media(max-width:900px){.layout{grid-template-columns:1fr}aside{display:none}}
</style>
</head>
<body>
<div class="layout">
<aside>
<div>
<div class="sidebar-logo">WriteKit</div>
<div class="sidebar-tagline">Blogging Platform</div>
</div>
<nav class="sidebar-nav">
<a href="/">Home</a>
<a href="/signup" style="color:var(--primary)">Create Blog →</a>
</nav>
<div class="sidebar-footer">
<div class="env-badge">ALPHA</div>
</div>
</aside>
<main>
<div class="error-content">
<div class="error-code">404</div>
<h1 class="error-title">Page not found</h1>
<p class="error-message">The page you're looking for doesn't exist or may have been moved.</p>
<div class="error-actions">
<a href="/" class="btn-primary">Go Home</a>
<a href="/signup" class="btn-secondary">Create a Blog</a>
</div>
</div>
</main>
</div>
</body>
</html>

View file

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Demo Expired — WriteKit</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"/>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#fafafa;--text:#0a0a0a;--muted:#737373;--border:#e5e5e5;--primary:#10b981}
body{font-family:'SF Mono','Fira Code','Consolas',monospace;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:2rem}
.container{max-width:480px;width:100%}
.header{margin-bottom:3rem}
.logo{font-weight:600;font-size:14px;letter-spacing:-0.02em;margin-bottom:0.5rem}
.subdomain{font-size:12px;color:var(--muted)}
.title{font-family:system-ui,-apple-system,sans-serif;font-size:2rem;font-weight:400;letter-spacing:-0.03em;margin-bottom:1rem}
.description{color:var(--muted);line-height:1.6;margin-bottom:2.5rem}
.section{margin-bottom:2rem}
.signup-btn{display:block;width:100%;padding:1rem 1.5rem;background:linear-gradient(135deg,#10b981,#06b6d4);color:white;text-decoration:none;font-family:inherit;font-size:14px;font-weight:500;text-align:center;border:none;cursor:pointer;transition:transform 0.2s,box-shadow 0.2s}
.signup-btn:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(16,185,129,0.3)}
.signup-note{font-size:11px;color:var(--muted);margin-top:0.75rem;text-align:center}
.divider{display:flex;align-items:center;gap:1rem;margin:2rem 0;color:var(--muted);font-size:12px}
.divider::before,.divider::after{content:'';flex:1;height:1px;background:var(--border)}
.secondary-actions{display:flex;gap:1rem}
.secondary-btn{flex:1;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.875rem 1rem;background:transparent;color:var(--text);text-decoration:none;font-family:inherit;font-size:13px;border:1px solid var(--border);transition:all 0.15s}
.secondary-btn:hover{border-color:var(--text);background:var(--border)}
.secondary-btn svg{width:16px;height:16px}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">WriteKit</div>
<div class="subdomain">{{SUBDOMAIN_TEXT}}</div>
</div>
<h1 class="title">Demo Expired</h1>
<p class="description">Your demo session has ended. Want to keep your blog? Sign up to make it permanent — it's free.</p>
<div class="section">
<a href="/signup" class="signup-btn">Keep My Blog — Sign Up Free</a>
<p class="signup-note">No credit card required</p>
</div>
<div class="divider">or</div>
<div class="secondary-actions">
<a href="/discord" class="secondary-btn">
<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>
Join Discord
</a>
<a href="/" class="secondary-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Try Again
</a>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,484 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WriteKit — Full Blogging Platform. Lightweight. Yours.</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" />
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#fafafa;--text:#0a0a0a;--muted:#737373;--border:#e5e5e5;--accent:{{ACCENT}};--primary:#10b981}
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.6}
.mono{font-family:'SF Mono','Fira Code','Consolas',monospace}
.layout{display:grid;grid-template-columns:200px 1fr;min-height:100vh}
aside{padding:2rem 1.5rem;border-right:1px solid var(--border);position:sticky;top:0;height:100vh;display:flex;flex-direction:column}
.sidebar-logo{font-family:'SF Mono','Fira Code',monospace;font-weight:600;font-size:14px;letter-spacing:-0.02em;margin-bottom:0.25rem}
.sidebar-tagline{font-family:'SF Mono','Fira Code',monospace;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2.5rem}
.sidebar-nav{display:flex;flex-direction:column;gap:0.25rem}
.sidebar-nav a{font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted);text-decoration:none;padding:0.5rem 0;transition:color 0.15s}
.sidebar-nav a:hover{color:var(--text)}
.sidebar-divider{height:1px;background:var(--border);margin:1rem 0}
.sidebar-label{font-family:'SF Mono','Fira Code',monospace;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:0.5rem}
.sidebar-footer{margin-top:auto}
.env-badge{font-family:'SF Mono','Fira Code',monospace;display:inline-block;padding:0.35rem 0.75rem;border:1px solid var(--accent);font-size:10px;text-transform:uppercase;letter-spacing:0.05em;color:var(--accent)}
.version-badge{font-family:'SF Mono','Fira Code',monospace;font-size:10px;color:var(--muted);opacity:0.5;margin-top:0.5rem;cursor:default;transition:opacity 0.15s}
.version-badge:hover{opacity:1}
main{min-width:0}
.hero{position:relative;padding:5rem 3rem 4rem;border-bottom:1px solid var(--border);overflow:hidden}
.hero-canvas{position:absolute;top:0;right:0;width:45%;height:100%;opacity:0.6;mask-image:linear-gradient(to left,black,transparent);-webkit-mask-image:linear-gradient(to left,black,transparent)}
.hero>*:not(.hero-canvas){position:relative;z-index:1}
.hero-label{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.15em;margin-bottom:1.5rem}
.hero h1{font-family:system-ui,-apple-system,sans-serif;font-size:clamp(2rem,4vw,3rem);font-weight:500;letter-spacing:-0.03em;line-height:1.15;margin-bottom:1.5rem;max-width:600px}
.hero-sub{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--muted);max-width:550px;line-height:1.7}
.hero-sub code{color:var(--text);background:var(--border);padding:0.15em 0.4em;font-size:0.95em}
.section{padding:4rem 3rem;border-bottom:1px solid var(--border)}
.section-label{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.15em;margin-bottom:2rem}
.hero-cta{margin-top:2.5rem}
.hero-cta .demo-btn{width:auto;padding:14px 32px;font-size:14px}
.hero-cta .demo-note{text-align:left}
.section-features{border-top:1px solid var(--border);border-bottom:1px solid var(--border);margin-top:-1px}
.features-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--border)}
.feature{background:var(--bg);padding:2rem}
.feature-num{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);margin-bottom:0.75rem}
.feature h3{font-size:1rem;font-weight:500;margin-bottom:0.5rem;letter-spacing:-0.01em}
.feature p{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);line-height:1.6}
.demo-btn{width:100%;margin-top:16px;padding:12px 24px;background:var(--text);color:var(--bg);border:none;font-family:'SF Mono','Fira Code',monospace;font-size:13px;cursor:pointer;transition:all 0.2s}
.demo-btn:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.15)}
.demo-btn:disabled{opacity:0.4;cursor:not-allowed}
.demo-note{margin-top:1rem;font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);text-align:center}
.mission{max-width:650px}
.mission h2{font-size:1.5rem;font-weight:400;letter-spacing:-0.02em;line-height:1.5;margin-bottom:1.5rem}
.mission p{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--muted);line-height:1.7}
.section-cta{text-align:center;padding:5rem 3rem;margin-top:-1px}
.cta-content h2{font-size:1.75rem;font-weight:500;letter-spacing:-0.02em;margin-bottom:0.75rem}
.cta-content p{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--muted);margin-bottom:2rem}
.cta-buttons{display:flex;gap:1rem;justify-content:center;flex-wrap:wrap}
.cta-primary{padding:14px 32px;background:var(--text);color:var(--bg);text-decoration:none;font-family:'SF Mono','Fira Code',monospace;font-size:14px;transition:all 0.2s}
.cta-primary:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.15)}
.cta-secondary{padding:14px 32px;background:transparent;color:var(--text);border:1px solid var(--border);font-family:'SF Mono','Fira Code',monospace;font-size:14px;cursor:pointer;transition:all 0.2s}
.cta-secondary:hover{border-color:var(--text)}
footer{padding:4rem 3rem;background:var(--text);color:var(--bg);margin-top:-1px}
.footer-content{display:flex;justify-content:space-between;align-items:flex-start;gap:3rem;max-width:900px}
.footer-brand{flex:1}
.footer-logo{font-family:'SF Mono','Fira Code',monospace;font-weight:600;font-size:16px;margin-bottom:0.5rem}
.footer-tagline{font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted);margin-bottom:1.5rem}
.footer-copy{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted)}
.footer-links{display:flex;gap:3rem}
.footer-col h4{font-family:'SF Mono','Fira Code',monospace;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;color:var(--muted);margin-bottom:1rem;font-weight:normal}
.footer-col a{display:flex;align-items:center;gap:0.5rem;font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--bg);text-decoration:none;padding:0.35rem 0;transition:opacity 0.15s}
.footer-col a:hover{opacity:0.7}
.footer-col a svg{width:16px;height:16px}
.demo-modal{display:none;position:fixed;inset:0;z-index:1000;align-items:center;justify-content:center}
.demo-modal.active{display:flex}
.demo-modal-backdrop{position:absolute;inset:0;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)}
.demo-modal-content{position:relative;background:white;border:1px solid var(--border);width:100%;max-width:420px;margin:1rem;box-shadow:0 25px 50px -12px rgba(0,0,0,0.25)}
.demo-step{display:none;padding:2rem}
.demo-step.active{display:block;animation:fadeIn 0.3s ease}
.demo-step-header{margin-bottom:1.5rem}
.demo-step-indicator{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.1em;display:block;margin-bottom:0.75rem}
.demo-step-header h2{font-size:1.25rem;font-weight:500;margin-bottom:0.5rem}
.demo-step-header p{font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted)}
.demo-name-input{width:100%;padding:14px 16px;border:1px solid var(--border);font-family:inherit;font-size:15px;outline:none;transition:border-color 0.15s}
.demo-name-input:focus{border-color:var(--text)}
.demo-step-footer{display:flex;justify-content:flex-end;gap:0.75rem;margin-top:1.5rem}
.demo-next,.demo-launch{padding:12px 24px;background:var(--text);color:var(--bg);border:none;font-family:'SF Mono','Fira Code',monospace;font-size:13px;cursor:pointer;transition:all 0.2s}
.demo-next:hover:not(:disabled),.demo-launch:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.15)}
.demo-next:disabled,.demo-launch:disabled{opacity:0.4;cursor:not-allowed}
.demo-back{padding:12px 24px;background:transparent;color:var(--muted);border:1px solid var(--border);font-family:'SF Mono','Fira Code',monospace;font-size:13px;cursor:pointer;transition:all 0.15s}
.demo-back:hover{border-color:var(--text);color:var(--text)}
.demo-launch{background:linear-gradient(135deg,#10b981,#06b6d4)}
.demo-launch:hover:not(:disabled){box-shadow:0 8px 24px rgba(16,185,129,0.3)}
.color-picker{display:flex;gap:0.75rem;flex-wrap:wrap}
.color-swatch{width:48px;height:48px;border:2px solid transparent;cursor:pointer;transition:all 0.15s;position:relative}
.color-swatch:hover{transform:scale(1.1)}
.color-swatch.selected{border-color:var(--text)}
.color-swatch.selected::after{content:'✓';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:white;font-size:18px;text-shadow:0 1px 2px rgba(0,0,0,0.3)}
.launch-progress{margin-top:1.5rem;padding:1rem;background:#f5f5f5;border:1px solid var(--border)}
.launch-progress.active{display:block;animation:fadeIn 0.3s ease}
.progress-step{display:flex;align-items:center;gap:0.75rem;padding:0.5rem 0;font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted)}
.progress-step.active{color:var(--text)}
.progress-step.done{color:var(--primary)}
.progress-dot{width:8px;height:8px;background:var(--border);transition:all 0.3s}
.progress-step.active .progress-dot{background:var(--text);animation:pulse 1s infinite}
.progress-step.done .progress-dot{background:var(--primary)}
.launch-success{text-align:center;padding:2rem}
.launch-success.active{display:block;animation:successPop 0.5s cubic-bezier(0.34,1.56,0.64,1)}
.success-icon{width:48px;height:48px;background:linear-gradient(135deg,#10b981,#06b6d4);display:flex;align-items:center;justify-content:center;margin:0 auto 1rem}
.success-icon svg{width:24px;height:24px;color:white}
.success-url{font-family:'SF Mono','Fira Code',monospace;font-size:14px;font-weight:500;margin-bottom:0.5rem}
.success-redirect{font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted)}
.demo-modal .launch-progress{display:block;margin-top:0;padding:0;background:transparent;border:none}
.demo-modal .launch-success{display:block;padding:1rem 0}
@keyframes fadeIn{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
@keyframes pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.3)}}
@keyframes successPop{0%{transform:scale(0.8);opacity:0}100%{transform:scale(1);opacity:1}}
@media(max-width:1024px){.features-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:900px){.layout{grid-template-columns:1fr}aside{position:relative;height:auto;border-right:none;border-bottom:1px solid var(--border);flex-direction:row;align-items:center;justify-content:space-between;padding:1rem 1.5rem}.sidebar-tagline{margin-bottom:0}.sidebar-nav,.sidebar-divider,.sidebar-label,.sidebar-footer{display:none}.hero,.section{padding:3rem 2rem}footer{padding:3rem 2rem}.footer-content{flex-direction:column}}
@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>
<div class="layout">
<aside>
<div>
<div class="sidebar-logo">WriteKit</div>
<div class="sidebar-tagline">Blogging Platform</div>
</div>
<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>
</nav>
<div class="sidebar-divider"></div>
<div class="sidebar-label">Resources</div>
<nav class="sidebar-nav">
<a href="/docs">Documentation</a>
<a href="/discord">Discord</a>
</nav>
<div class="sidebar-footer">
<div class="env-badge">ALPHA</div>
<div class="version-badge" title="{{COMMIT}}">{{VERSION}}</div>
</div>
</aside>
<main>
<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>
<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>
</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>
</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>
</section>
<section class="section section-cta" id="cta">
<div class="cta-content">
<h2>Ready to start writing?</h2>
<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>
</div>
</div>
</section>
<div class="demo-modal" id="demo-modal">
<div class="demo-modal-backdrop"></div>
<div class="demo-modal-content">
<div class="demo-step active" data-step="1">
<div class="demo-step-header">
<span class="demo-step-indicator">1 / 2</span>
<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">
<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>
</div>
</div>
<div class="demo-step" data-step="2">
<div class="demo-step-header">
<span class="demo-step-indicator">2 / 2</span>
<h2>Pick a color</h2>
<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>
</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>
</div>
</div>
<div class="demo-step" data-step="3">
<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>
</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-url" id="success-url"></div>
<div class="success-redirect">Redirecting you now...</div>
</div>
</div>
</div>
</div>
<footer>
<div class="footer-content">
<div class="footer-brand">
<div class="footer-logo">WriteKit</div>
<div class="footer-tagline">Your Words, Your Platform</div>
<div class="footer-copy">&copy; 2025 WriteKit. All rights reserved.</div>
</div>
<div class="footer-links">
<div class="footer-col">
<h4>Product</h4>
<a href="#features">Features</a>
<a href="/signup">Create Blog</a>
<a href="/docs">Documentation</a>
</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>
</div>
</div>
</div>
</footer>
</main>
</div>
<script>
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')
let demoName = ''
let demoColor = '#10b981'
const openDemoModal = () => {
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'
}
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 launchDemo = async () => {
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()
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
} else {
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')
}
}
$('#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()
}
}
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
})
nextBtn.addEventListener('click', () => goToModalStep(2))
backBtn.addEventListener('click', () => goToModalStep(1))
launchBtn.addEventListener('click', 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
})
})
$$('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); }`
const fs = `#version 300 es
precision highp float;
in vec2 v_uv;
out vec4 fragColor;
uniform vec2 u_resolution;
uniform float u_time, u_pixelSize, u_gridSize, u_speed, u_colorShift;
uniform vec3 u_color1, u_color2, u_color3, u_bgColor;
float bayer8(vec2 p) { ivec2 P = ivec2(mod(floor(p), 8.0)); int i = P.x + P.y * 8; int b[64] = int[64](0,32,8,40,2,34,10,42,48,16,56,24,50,18,58,26,12,44,4,36,14,46,6,38,60,28,52,20,62,30,54,22,3,35,11,43,1,33,9,41,51,19,59,27,49,17,57,25,15,47,7,39,13,45,5,37,63,31,55,23,61,29,53,21); return float(b[i]) / 64.0; }
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) { vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f); return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y); }
float fbm(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; } return v; }
void main() {
vec2 pixelUV = floor(v_uv * u_resolution / u_pixelSize) * u_pixelSize / u_resolution;
vec2 p = pixelUV * 3.0; float t = u_time * u_speed;
float pattern = fbm(p + vec2(t * 0.5, t * 0.3)) * 0.5 + fbm(p * 1.5 - vec2(t * 0.4, -t * 0.2)) * 0.3 + fbm(p * 0.5 + vec2(-t * 0.3, t * 0.5)) * 0.2 + 0.1 * sin(p.x * 2.0 + t) * sin(p.y * 2.0 - t * 0.7);
float luma = clamp(smoothstep(0.1, 0.9, pow(pattern, 0.7)) + u_colorShift * sin(u_time * 0.3) * 0.3, 0.0, 1.0);
float threshold = bayer8(floor(v_uv * u_resolution / u_gridSize));
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 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') }
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 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>
</html>

View file

@ -0,0 +1,472 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Sign Up — WriteKit</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"/>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0a0a0a;--bg-elevated:#111111;--bg-subtle:#1a1a1a;--text:#fafafa;--text-muted:#737373;--text-dim:#525252;--border:#262626;--border-focus:#404040;--accent:{{ACCENT}};--emerald:#10b981;--cyan:#06b6d4;--red:#ef4444}
html,body{height:100%}
body{font-family:'SF Mono','Fira Code','Consolas',monospace;background:var(--bg);color:var(--text);line-height:1.6;overflow:hidden}
.layout{display:grid;grid-template-columns:280px 1fr;height:100vh}
.sidebar{background:var(--bg);border-right:1px solid var(--border);padding:2.5rem 2rem;display:flex;flex-direction:column}
.sidebar-header{margin-bottom:3rem}
.logo{font-size:15px;font-weight:600;letter-spacing:-0.02em;margin-bottom:0.35rem}
.tagline{font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.1em}
.sidebar-content{flex:1;display:flex;flex-direction:column;justify-content:center}
.step-indicator{display:flex;flex-direction:column;gap:1rem}
.step-item{display:flex;align-items:center;gap:1rem;font-size:12px;color:var(--text-dim);transition:all 0.4s ease}
.step-item.active{color:var(--text)}
.step-item.completed{color:var(--emerald)}
.step-dot{width:8px;height:8px;border:1px solid var(--border);background:transparent;transition:all 0.4s ease}
.step-item.active .step-dot{background:var(--text);border-color:var(--text);box-shadow:0 0 12px rgba(250,250,250,0.3)}
.step-item.completed .step-dot{background:var(--emerald);border-color:var(--emerald)}
.sidebar-footer{margin-top:auto}
.env-badge{display:inline-block;padding:0.35rem 0.75rem;border:1px solid var(--accent);font-size:10px;text-transform:uppercase;letter-spacing:0.08em;color:var(--accent)}
.main{display:flex;align-items:center;justify-content:center;padding:2rem;position:relative;overflow:hidden}
.main::before{content:'';position:absolute;inset:0;background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:60px 60px;opacity:0.3;mask-image:radial-gradient(ellipse at center,black 0%,transparent 70%)}
.step-container{position:relative;width:100%;max-width:480px;z-index:1}
.step{position:absolute;width:100%;opacity:0;visibility:hidden;transform:translateY(30px);transition:all 0.5s cubic-bezier(0.16,1,0.3,1)}
.step.active{position:relative;opacity:1;visibility:visible;transform:translateY(0)}
.step.exit-up{transform:translateY(-30px)}
.step-header{margin-bottom:2.5rem}
.step-label{font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.15em;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem}
.step-label::before{content:'>';color:var(--emerald)}
.step-title{font-size:clamp(1.75rem,3vw,2.25rem);font-weight:500;letter-spacing:-0.03em;line-height:1.2;margin-bottom:0.75rem}
.step-desc{font-size:13px;color:var(--text-muted);line-height:1.7}
.auth-buttons{display:flex;flex-direction:column;gap:0.75rem}
.auth-btn{display:flex;align-items:center;justify-content:center;gap:0.75rem;padding:1rem 1.5rem;border:1px solid var(--border);background:var(--bg-elevated);color:var(--text);font-family:inherit;font-size:13px;font-weight:500;cursor:pointer;transition:all 0.2s ease;text-decoration:none}
.auth-btn:hover{border-color:var(--border-focus);background:var(--bg-subtle)}
.auth-btn.primary{background:var(--text);color:var(--bg);border-color:var(--text)}
.auth-btn.primary:hover{background:#e5e5e5;border-color:#e5e5e5;transform:translateY(-2px);box-shadow:0 8px 24px rgba(250,250,250,0.15)}
.auth-btn svg{width:20px;height:20px;flex-shrink:0}
.auth-divider{display:flex;align-items:center;gap:1rem;margin:0.5rem 0}
.auth-divider::before,.auth-divider::after{content:'';flex:1;height:1px;background:var(--border)}
.auth-divider span{font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.1em}
.user-greeting{display:flex;flex-direction:column}
.user-avatar{width:64px;height:64px;border-radius:50%;margin-bottom:1.5rem;border:2px solid var(--border);display:none;object-fit:cover}
.user-avatar.loaded{display:block;animation:avatarPop 0.5s cubic-bezier(0.34,1.56,0.64,1)}
@keyframes avatarPop{0%{transform:scale(0);opacity:0}100%{transform:scale(1);opacity:1}}
.subdomain-form{margin-bottom:1.5rem}
.input-row{display:flex;align-items:stretch;border:1px solid var(--border);background:var(--bg-elevated);transition:all 0.2s ease}
.input-row:focus-within{border-color:var(--border-focus)}
.input-row.valid{border-color:var(--emerald)}
.input-row.invalid{border-color:var(--red)}
.subdomain-input{flex:1;padding:1rem 1.25rem;background:transparent;border:none;color:var(--text);font-family:inherit;font-size:15px;outline:none}
.subdomain-input::placeholder{color:var(--text-dim)}
.subdomain-suffix{padding:1rem 1.25rem;background:var(--bg-subtle);color:var(--text-muted);font-size:15px;display:flex;align-items:center;border-left:1px solid var(--border)}
.input-status{height:1.5rem;margin-top:0.75rem;font-size:12px;display:flex;align-items:center;gap:0.5rem}
.input-status.available{color:var(--emerald)}
.input-status.unavailable{color:var(--red)}
.input-status.checking{color:var(--text-muted)}
.input-status .dot{width:6px;height:6px;background:currentColor;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
.btn-row{display:flex;gap:0.75rem}
.btn{padding:1rem 2rem;font-family:inherit;font-size:13px;font-weight:500;cursor:pointer;transition:all 0.2s ease;border:1px solid var(--border);background:var(--bg-elevated);color:var(--text)}
.btn:hover:not(:disabled){border-color:var(--border-focus);background:var(--bg-subtle)}
.btn:disabled{opacity:0.4;cursor:not-allowed}
.btn.primary{flex:1;background:linear-gradient(135deg,var(--emerald),var(--cyan));border:none;color:white}
.btn.primary:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 8px 24px rgba(16,185,129,0.3)}
.btn.primary:disabled{background:var(--border);transform:none;box-shadow:none}
.btn-back{width:48px;padding:1rem;display:flex;align-items:center;justify-content:center}
.btn-back svg{width:16px;height:16px}
.progress-steps{background:var(--bg-elevated);border:1px solid var(--border);padding:1.5rem}
.progress-step{display:flex;align-items:center;gap:1rem;padding:0.75rem 0;font-size:13px;color:var(--text-dim);transition:all 0.4s ease}
.progress-step.active{color:var(--text)}
.progress-step.done{color:var(--emerald)}
.progress-step .dot{width:8px;height:8px;background:var(--border);flex-shrink:0;transition:all 0.3s ease}
.progress-step.active .dot{background:var(--text);animation:progressPulse 1s infinite}
.progress-step.done .dot{background:var(--emerald)}
@keyframes progressPulse{0%,100%{transform:scale(1);box-shadow:0 0 0 0 rgba(250,250,250,0.4)}50%{transform:scale(1.2);box-shadow:0 0 12px 2px rgba(250,250,250,0.2)}}
.success-content{text-align:center}
.success-icon{width:72px;height:72px;margin:0 auto 2rem;background:linear-gradient(135deg,var(--emerald),var(--cyan));display:flex;align-items:center;justify-content:center;animation:successPop 0.6s cubic-bezier(0.34,1.56,0.64,1)}
@keyframes successPop{0%{transform:scale(0);opacity:0}50%{transform:scale(1.1)}100%{transform:scale(1);opacity:1}}
.success-icon svg{width:32px;height:32px;color:white;animation:checkDraw 0.4s 0.3s ease-out both}
@keyframes checkDraw{0%{stroke-dashoffset:24;opacity:0}100%{stroke-dashoffset:0;opacity:1}}
.success-icon svg path{stroke-dasharray:24;stroke-dashoffset:24;animation:checkDraw 0.4s 0.3s ease-out forwards}
.success-url{font-size:18px;font-weight:600;margin-bottom:0.5rem;background:linear-gradient(135deg,var(--emerald),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.success-redirect{font-size:12px;color:var(--text-muted);display:flex;align-items:center;justify-content:center;gap:0.5rem}
.success-redirect .dot{width:6px;height:6px;background:var(--text-muted);animation:pulse 1s infinite}
.back-link{position:absolute;top:2rem;left:2rem;font-size:12px;color:var(--text-muted);text-decoration:none;display:flex;align-items:center;gap:0.5rem;transition:color 0.2s;z-index:10}
.back-link:hover{color:var(--text)}
.back-link svg{width:14px;height:14px}
.keyboard-hint{position:absolute;bottom:2rem;left:50%;transform:translateX(-50%);font-size:11px;color:var(--text-dim);display:flex;align-items:center;gap:0.5rem}
.key{padding:0.25rem 0.5rem;background:var(--bg-subtle);border:1px solid var(--border);font-size:10px}
.color-picker{margin-bottom:2rem}
.color-options{display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:1.5rem}
.color-option{width:48px;height:48px;border:2px solid transparent;background:var(--color);cursor:pointer;transition:all 0.2s ease;position:relative}
.color-option:hover{transform:scale(1.1)}
.color-option.selected{border-color:var(--text);box-shadow:0 0 0 2px var(--bg),0 0 0 4px var(--color)}
.color-option.selected::after{content:'✓';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:white;font-size:18px;text-shadow:0 1px 2px rgba(0,0,0,0.3)}
.color-preview{display:flex;align-items:center;gap:1rem}
.preview-label{font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.1em}
.preview-box{flex:1;height:8px;background:var(--bg-subtle);border:1px solid var(--border);overflow:hidden}
.preview-accent{height:100%;width:60%;background:var(--emerald);transition:background 0.3s ease,width 0.3s ease}
@media(max-width:900px){.layout{grid-template-columns:1fr}.sidebar{display:none}.main{padding:1.5rem}.back-link{position:relative;top:auto;left:auto;margin-bottom:2rem}.keyboard-hint{display:none}}
@media(max-width:480px){.step-title{font-size:1.5rem}.auth-btn{padding:0.875rem 1.25rem}}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">WriteKit</div>
<div class="tagline">Blogging Platform</div>
</div>
<div class="sidebar-content">
<div class="step-indicator">
<div class="step-item active" data-step="1"><span class="step-dot"></span><span>Sign in</span></div>
<div class="step-item" data-step="2"><span class="step-dot"></span><span>Personalize</span></div>
<div class="step-item" data-step="3"><span class="step-dot"></span><span>Choose subdomain</span></div>
<div class="step-item" data-step="4"><span class="step-dot"></span><span>Launch</span></div>
</div>
</div>
<div class="sidebar-footer">
<div class="env-badge">ALPHA</div>
</div>
</aside>
<main class="main">
<a href="/" class="back-link">
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
Back to home
</a>
<div class="step-container">
<div class="step active" id="step-auth">
<div class="step-header">
<div class="step-label">Step 1</div>
<h1 class="step-title">Start your blog</h1>
<p class="step-desc">Sign in to create your WriteKit instance. Your blog will be ready in seconds.</p>
</div>
<div class="auth-buttons">
<a href="/auth/github?callback=/signup/complete" class="auth-btn primary">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
Continue with GitHub
</a>
<div class="auth-divider"><span>or</span></div>
<a href="/auth/google?callback=/signup/complete" class="auth-btn">
<svg viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Continue with Google
</a>
<a href="/auth/discord?callback=/signup/complete" class="auth-btn">
<svg viewBox="0 0 24 24" fill="#5865F2"><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>
Continue with Discord
</a>
</div>
</div>
<div class="step" id="step-personalize">
<div class="step-header">
<div class="user-greeting">
<img class="user-avatar" id="personalize-avatar" alt=""/>
<div class="step-label">Step 2</div>
<h1 class="step-title" id="personalize-title">Pick your style</h1>
</div>
<p class="step-desc">Choose an accent color for your blog. You can change this anytime in settings.</p>
</div>
<div class="color-picker">
<div class="color-options">
<button type="button" class="color-option" data-color="#10b981" style="--color:#10b981" title="Emerald"></button>
<button type="button" class="color-option" data-color="#06b6d4" style="--color:#06b6d4" title="Cyan"></button>
<button type="button" class="color-option" data-color="#8b5cf6" style="--color:#8b5cf6" title="Violet"></button>
<button type="button" class="color-option" data-color="#ec4899" style="--color:#ec4899" title="Pink"></button>
<button type="button" class="color-option" data-color="#f97316" style="--color:#f97316" title="Orange"></button>
<button type="button" class="color-option" data-color="#eab308" style="--color:#eab308" title="Yellow"></button>
<button type="button" class="color-option" data-color="#ef4444" style="--color:#ef4444" title="Red"></button>
<button type="button" class="color-option" data-color="#64748b" style="--color:#64748b" title="Slate"></button>
</div>
<div class="color-preview">
<span class="preview-label">Preview</span>
<div class="preview-box" id="color-preview"><div class="preview-accent"></div></div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-back" id="btn-personalize-back" type="button" title="Use different account">
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
</button>
<button class="btn primary" id="btn-personalize-next">Continue</button>
</div>
</div>
<div class="step" id="step-subdomain">
<div class="step-header">
<div class="user-greeting" id="user-greeting">
<img class="user-avatar" id="user-avatar" alt=""/>
<div class="step-label">Step 3</div>
<h1 class="step-title" id="greeting-title">Choose your subdomain</h1>
</div>
<p class="step-desc">Pick a memorable address for your blog. You can add a custom domain later.</p>
</div>
<div class="subdomain-form">
<div class="input-row" id="input-row">
<input type="text" class="subdomain-input" id="subdomain" placeholder="myblog" autocomplete="off" spellcheck="false" autofocus/>
<span class="subdomain-suffix" id="domain-suffix">.writekit.dev</span>
</div>
<div class="input-status" id="status"></div>
</div>
<div class="btn-row">
<button class="btn btn-back" id="btn-back" type="button">
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
</button>
<button class="btn primary" id="btn-launch" disabled>Create my blog</button>
</div>
</div>
<div class="step" id="step-provisioning">
<div class="step-header">
<div class="step-label">Step 4</div>
<h1 class="step-title">Launching your blog</h1>
<p class="step-desc">Setting everything up. This only takes a few seconds.</p>
</div>
<div class="progress-steps" id="progress-steps">
<div class="progress-step" data-step="1"><span class="dot"></span><span>Reserving subdomain...</span></div>
<div class="progress-step" data-step="2"><span class="dot"></span><span>Spinning up container...</span></div>
<div class="progress-step" data-step="3"><span class="dot"></span><span>Configuring SSL...</span></div>
<div class="progress-step" data-step="4"><span class="dot"></span><span>Almost ready...</span></div>
</div>
</div>
<div class="step" id="step-success">
<div class="success-content">
<div class="success-icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
</div>
<div class="step-title">You're all set!</div>
<div class="success-url" id="success-url"></div>
<div class="success-redirect"><span class="dot"></span><span>Redirecting to your studio...</span></div>
</div>
</div>
</div>
<div class="keyboard-hint">Press <span class="key">Enter</span> to continue</div>
</main>
</div>
<script>
const $ = s => document.getElementById(s)
const $$ = s => document.querySelectorAll(s)
const steps = { auth: $('step-auth'), personalize: $('step-personalize'), subdomain: $('step-subdomain'), provisioning: $('step-provisioning'), success: $('step-success') }
const stepIndicators = $$('.step-item')
const subdomainInput = $('subdomain')
const inputRow = $('input-row')
const status = $('status')
const btnLaunch = $('btn-launch')
const btnBack = $('btn-back')
const successUrl = $('success-url')
const userAvatar = $('user-avatar')
const greetingTitle = $('greeting-title')
const personalizeAvatar = $('personalize-avatar')
const personalizeTitle = $('personalize-title')
const colorOptions = $$('.color-option')
const previewAccent = document.querySelector('.preview-accent')
const btnPersonalizeNext = $('btn-personalize-next')
const btnPersonalizeBack = $('btn-personalize-back')
let currentStep = 'auth'
let currentSubdomain = ''
let isAvailable = false
let debounceTimer
let currentUser = null
let selectedColor = '#10b981'
const sleep = ms => new Promise(r => setTimeout(r, ms))
const fetchUserInfo = async token => {
try {
const res = await fetch(`/api/auth/user?token=${encodeURIComponent(token)}`)
if (!res.ok) {
sessionStorage.removeItem('signup_token')
return false
}
const user = await res.json()
currentUser = user
const firstName = user.name?.split(' ')[0] ?? ''
if (user.avatar_url) {
userAvatar.src = user.avatar_url
userAvatar.onload = () => userAvatar.classList.add('loaded')
personalizeAvatar.src = user.avatar_url
personalizeAvatar.onload = () => personalizeAvatar.classList.add('loaded')
}
if (firstName) {
greetingTitle.textContent = `Hey ${firstName}!`
personalizeTitle.textContent = `Pick your style, ${firstName}`
}
return true
} catch {
sessionStorage.removeItem('signup_token')
return false
}
}
const goToStep = stepName => {
const currentEl = steps[currentStep]
const nextEl = steps[stepName]
const stepOrder = ['auth', 'personalize', 'subdomain', 'provisioning', 'success']
const nextIndex = stepOrder.indexOf(stepName)
stepIndicators.forEach((indicator, i) => {
indicator.classList.remove('active', 'completed')
if (i < nextIndex) indicator.classList.add('completed')
else if (i === nextIndex) indicator.classList.add('active')
})
currentEl.classList.add('exit-up')
currentEl.classList.remove('active')
setTimeout(() => {
currentEl.classList.remove('exit-up')
nextEl.classList.add('active')
currentStep = stepName
if (stepName === 'subdomain') setTimeout(() => subdomainInput.focus(), 100)
}, 150)
}
const checkAvailability = async subdomain => {
if (subdomain !== currentSubdomain) return
try {
const res = await fetch(`/api/demo/check?subdomain=${encodeURIComponent(subdomain)}`)
const data = await res.json()
if (subdomain !== currentSubdomain) return
if (data.domain) $('domain-suffix').textContent = '.' + data.domain
if (data.available) {
status.textContent = `${subdomain}.${data.domain} is available`
status.className = 'input-status available'
inputRow.className = 'input-row valid'
btnLaunch.disabled = false
isAvailable = true
} else {
status.textContent = data.reason || 'Not available'
status.className = 'input-status unavailable'
inputRow.className = 'input-row invalid'
btnLaunch.disabled = true
isAvailable = false
}
} catch {
status.textContent = 'Error checking availability'
status.className = 'input-status unavailable'
btnLaunch.disabled = true
isAvailable = false
}
}
const animateProgress = async url => {
const progressSteps = $$('#progress-steps .progress-step')
let ready = false
let redirecting = false
const pollUrl = async () => {
const start = Date.now()
while (!ready && Date.now() - start < 30000) {
try {
const res = await fetch(url + '/health', { method: 'HEAD' })
if (res.ok || res.status === 307 || res.status === 302) ready = true
} catch { await sleep(300) }
}
}
const doRedirect = () => {
if (redirecting) return
redirecting = true
progressSteps.forEach(s => { s.classList.remove('active'); s.classList.add('done') })
successUrl.textContent = url.replace('https://', '')
goToStep('success')
const token = sessionStorage.getItem('signup_token')
setTimeout(() => { window.location.href = url + '/auth/callback?token=' + encodeURIComponent(token) + '&redirect=/studio' }, 400)
}
pollUrl().then(() => { if (ready) doRedirect() })
for (let i = 0; i < progressSteps.length; i++) {
if (ready) return doRedirect()
progressSteps[i].classList.add('active')
await sleep(300)
progressSteps[i].classList.remove('active')
progressSteps[i].classList.add('done')
}
while (!ready) await sleep(100)
doRedirect()
}
const launchBlog = async () => {
if (!isAvailable || !currentSubdomain) return
const token = sessionStorage.getItem('signup_token')
if (!token) { goToStep('auth'); return }
btnLaunch.disabled = true
goToStep('provisioning')
try {
const res = await fetch('/api/signup/tenant', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ subdomain: currentSubdomain, accent_color: selectedColor })
})
if (res.status === 401) { sessionStorage.removeItem('signup_token'); goToStep('auth'); return }
const data = await res.json()
if (res.ok && data.url) {
await animateProgress(data.url)
} else {
goToStep('subdomain')
status.textContent = data.error || 'Failed to create blog'
status.className = 'input-status unavailable'
}
} catch {
goToStep('subdomain')
status.textContent = 'Error creating blog'
status.className = 'input-status unavailable'
}
}
;(async () => {
const urlParams = new URLSearchParams(window.location.search)
const urlToken = urlParams.get('token')
const storedToken = sessionStorage.getItem('signup_token')
if (urlToken) {
sessionStorage.setItem('signup_token', urlToken)
window.history.replaceState({}, '', '/signup')
if (await fetchUserInfo(urlToken)) goToStep('personalize')
} else if (storedToken) {
if (await fetchUserInfo(storedToken)) goToStep('personalize')
}
colorOptions[0]?.classList.add('selected')
})()
subdomainInput.addEventListener('input', () => {
const value = subdomainInput.value.toLowerCase().replace(/[^a-z0-9-]/g, '')
if (value !== subdomainInput.value) subdomainInput.value = value
currentSubdomain = value
if (!value) {
status.textContent = ''
status.className = 'input-status'
inputRow.className = 'input-row'
btnLaunch.disabled = true
isAvailable = false
return
}
clearTimeout(debounceTimer)
status.innerHTML = '<span class="dot"></span> Checking...'
status.className = 'input-status checking'
inputRow.className = 'input-row'
btnLaunch.disabled = true
debounceTimer = setTimeout(() => checkAvailability(value), 300)
})
colorOptions.forEach(option => {
option.addEventListener('click', () => {
colorOptions.forEach(o => o.classList.remove('selected'))
option.classList.add('selected')
selectedColor = option.dataset.color
previewAccent.style.background = selectedColor
})
})
btnPersonalizeBack.addEventListener('click', () => { sessionStorage.removeItem('signup_token'); goToStep('auth') })
btnPersonalizeNext.addEventListener('click', () => goToStep('subdomain'))
btnBack.addEventListener('click', () => goToStep('personalize'))
btnLaunch.addEventListener('click', launchBlog)
document.addEventListener('keydown', e => { if (e.key === 'Enter' && currentStep === 'subdomain' && isAvailable) launchBlog() })
</script>
</body>
</html>