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

View file

@ -0,0 +1,10 @@
import { atom } from 'nanostores'
import { createFetcherStore } from './fetcher'
import type { AnalyticsSummary } from '../types'
export const $days = atom(30)
export const $analytics = createFetcherStore<AnalyticsSummary>([
'/api/studio/analytics?days=',
$days,
])

View file

@ -0,0 +1,34 @@
import { atom } from 'nanostores'
import { createFetcherStore, createMutatorStore, invalidateKeys } from './fetcher'
import type { APIKey } from '../types'
export const $apiKeys = createFetcherStore<APIKey[]>(['/api/studio/api-keys'])
export const $creating = atom(false)
export const $newKey = atom<string | null>(null)
export async function createAPIKey(name: string): Promise<APIKey> {
$creating.set(true)
try {
const res = await fetch('/api/studio/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!res.ok) throw new Error('Failed to create API key')
const key = await res.json()
$newKey.set(key.key)
invalidateKeys('/api/studio/api-keys')
return key
} finally {
$creating.set(false)
}
}
export const $deleteAPIKey = createMutatorStore<string>(
async ({ data: key, invalidate }) => {
const res = await fetch(`/api/studio/api-keys/${key}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete API key')
invalidate('/api/studio/api-keys')
}
)

19
studio/src/stores/app.ts Normal file
View file

@ -0,0 +1,19 @@
import { atom } from 'nanostores'
export interface Toast {
id: string
message: string
type: 'success' | 'error'
}
export const $toasts = atom<Toast[]>([])
export const addToast = (message: string, type: 'success' | 'error' = 'success') => {
const id = Math.random().toString(36).slice(2)
$toasts.set([...$toasts.get(), { id, message, type }])
setTimeout(() => removeToast(id), 4000)
}
export const removeToast = (id: string) => {
$toasts.set($toasts.get().filter(t => t.id !== id))
}

View file

@ -0,0 +1,33 @@
import { atom } from 'nanostores'
import { createFetcherStore, createMutatorStore, invalidateKeys } from './fetcher'
import type { Asset } from '../types'
export const $assets = createFetcherStore<Asset[]>(['/api/studio/assets'])
export const $uploading = atom(false)
export async function uploadAsset(file: File): Promise<Asset> {
$uploading.set(true)
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/studio/assets', {
method: 'POST',
body: formData,
})
if (!res.ok) throw new Error('Failed to upload')
const asset = await res.json()
invalidateKeys('/api/studio/assets')
return asset
} finally {
$uploading.set(false)
}
}
export const $deleteAsset = createMutatorStore<string>(
async ({ data: id, invalidate }) => {
const res = await fetch(`/api/studio/assets/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete asset')
invalidate('/api/studio/assets')
}
)

View file

@ -0,0 +1,4 @@
import { createFetcherStore } from './fetcher'
import type { BillingInfo } from '../types'
export const $billing = createFetcherStore<BillingInfo>('/api/studio/billing')

467
studio/src/stores/editor.ts Normal file
View file

@ -0,0 +1,467 @@
import { atom, map, computed } from 'nanostores'
import { createMutatorStore } from './fetcher'
import type { Post } from '../types'
export type EditorMode = 'edit' | 'source'
export interface EditorPost {
id?: string
slug: string
title: string
description: string
content: string
date: string
draft: boolean
members_only: boolean
tags: string[]
cover_image?: string
}
export interface PostVersion {
id: number
title: string
created_at: string
}
export interface DraftState {
slug: string
title: string
description: string
content: string
tags: string[]
cover_image: string
members_only: boolean
modified_at: string
}
const defaultPost: EditorPost = {
slug: '',
title: '',
description: '',
content: '',
date: new Date().toISOString().split('T')[0],
draft: true,
members_only: false,
tags: [],
cover_image: '',
}
export const $editorMode = atom<EditorMode>('edit')
export const $editorPost = map<EditorPost>({ ...defaultPost })
export const $initialPost = atom<EditorPost | null>(null)
export const $isNewPost = atom(true)
export const $hasDraft = atom(false)
export const $versions = atom<PostVersion[]>([])
export const $isSaving = atom(false)
export const $isPublishing = atom(false)
export const $hasChanges = computed(
[$editorPost, $initialPost],
(current, initial) => initial !== null && JSON.stringify(current) !== JSON.stringify(initial)
)
export const $changedFields = computed(
[$editorPost, $initialPost],
(current, initial) => {
if (!initial) return []
const changes: string[] = []
const labels: Record<keyof EditorPost, string> = {
id: 'ID',
slug: 'Slug',
title: 'Title',
description: 'Description',
content: 'Content',
date: 'Date',
draft: 'Draft',
members_only: 'Members only',
tags: 'Tags',
cover_image: 'Cover image',
}
for (const key of Object.keys(current) as (keyof EditorPost)[]) {
const currentVal = JSON.stringify(current[key])
const initialVal = JSON.stringify(initial[key])
if (currentVal !== initialVal) {
changes.push(labels[key] || key)
}
}
return changes
}
)
export function initNewPost() {
const newPost = {
...defaultPost,
date: new Date().toISOString().split('T')[0],
}
$editorPost.set(newPost)
$initialPost.set({ ...newPost })
$isNewPost.set(true)
$hasDraft.set(false)
$versions.set([])
$editorMode.set('edit')
}
export async function loadPost(slug: string): Promise<boolean> {
try {
const res = await fetch(`/api/studio/posts/${slug}`)
if (!res.ok) return false
const post: Post = await res.json()
const editorPost: EditorPost = {
id: post.id,
slug: post.slug,
title: post.title,
description: post.description,
content: post.content || '',
date: post.date,
draft: post.draft,
members_only: post.members_only,
tags: post.tags || [],
cover_image: '',
}
$editorPost.set(editorPost)
$initialPost.set({ ...editorPost })
$isNewPost.set(false)
$editorMode.set('edit')
if (post.id) {
loadDraft(post.id)
loadVersions(post.id)
}
return true
} catch {
return false
}
}
async function loadDraft(postId: string) {
try {
const res = await fetch(`/api/studio/posts/${postId}/draft`)
if (!res.ok) {
$hasDraft.set(false)
return
}
const draft = await res.json()
if (draft) {
$hasDraft.set(true)
$editorPost.set({
...$editorPost.get(),
slug: draft.slug,
title: draft.title,
description: draft.description,
content: draft.content,
tags: draft.tags || [],
cover_image: draft.cover_image || '',
members_only: draft.members_only,
})
$initialPost.set({ ...$editorPost.get() })
} else {
$hasDraft.set(false)
}
} catch {
$hasDraft.set(false)
}
}
async function loadVersions(postId: string) {
try {
const res = await fetch(`/api/studio/posts/${postId}/versions`)
if (res.ok) {
const versions = await res.json()
$versions.set(versions || [])
}
} catch {
$versions.set([])
}
}
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '')
}
export function updateTitle(title: string) {
const current = $editorPost.get()
const isNew = $isNewPost.get()
const initial = $initialPost.get()
$editorPost.setKey('title', title)
if (isNew || (initial && current.slug === slugify(initial.title))) {
$editorPost.setKey('slug', slugify(title))
}
}
export const $savePost = createMutatorStore<EditorPost>(
async ({ data: post, invalidate }) => {
const isNew = $isNewPost.get()
const url = isNew ? '/api/studio/posts' : `/api/studio/posts/${post.slug}`
const method = isNew ? 'POST' : 'PUT'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(post),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(err.error || 'Failed to save post')
}
const saved: Post = await res.json()
const editorPost: EditorPost = {
id: saved.id,
slug: saved.slug,
title: saved.title,
description: saved.description,
content: saved.content || post.content,
date: saved.date,
draft: saved.draft,
members_only: saved.members_only,
tags: saved.tags || [],
cover_image: post.cover_image,
}
$editorPost.set(editorPost)
$initialPost.set({ ...editorPost })
$isNewPost.set(false)
invalidate('/api/studio/posts')
return editorPost
}
)
export async function saveDraft(): Promise<boolean> {
const post = $editorPost.get()
if (!post.id) return false
$isSaving.set(true)
try {
const res = await fetch(`/api/studio/posts/${post.id}/draft`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: post.slug,
title: post.title,
description: post.description,
content: post.content,
tags: post.tags,
cover_image: post.cover_image,
members_only: post.members_only,
}),
})
if (!res.ok) {
throw new Error('Failed to save draft')
}
$hasDraft.set(true)
$initialPost.set({ ...post })
return true
} catch {
return false
} finally {
$isSaving.set(false)
}
}
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
export function triggerAutoSave() {
if ($isNewPost.get()) return
if (!$hasChanges.get()) return
if (autoSaveTimer) clearTimeout(autoSaveTimer)
autoSaveTimer = setTimeout(async () => {
await saveDraft()
}, 2000)
}
export async function discardDraft(): Promise<boolean> {
const post = $editorPost.get()
if (!post.id) return false
try {
const res = await fetch(`/api/studio/posts/${post.id}/draft`, {
method: 'DELETE',
})
if (!res.ok) {
throw new Error('Failed to discard draft')
}
$hasDraft.set(false)
await loadPost(post.slug)
return true
} catch {
return false
}
}
export async function publishPost(): Promise<boolean> {
const post = $editorPost.get()
if (!post.id) return false
$isPublishing.set(true)
try {
if ($hasChanges.get()) {
await saveDraft()
}
const res = await fetch(`/api/studio/posts/${post.id}/publish`, {
method: 'POST',
})
if (!res.ok) {
throw new Error('Failed to publish')
}
const saved: Post = await res.json()
const editorPost: EditorPost = {
id: saved.id,
slug: saved.slug,
title: saved.title,
description: saved.description,
content: saved.content || post.content,
date: saved.date,
draft: saved.draft,
members_only: saved.members_only,
tags: saved.tags || [],
cover_image: post.cover_image,
}
$editorPost.set(editorPost)
$initialPost.set({ ...editorPost })
$hasDraft.set(false)
if (post.id) {
loadVersions(post.id)
}
return true
} catch {
return false
} finally {
$isPublishing.set(false)
}
}
export async function unpublishPost(): Promise<boolean> {
const post = $editorPost.get()
if (!post.id) return false
$isPublishing.set(true)
try {
const res = await fetch(`/api/studio/posts/${post.id}/unpublish`, {
method: 'POST',
})
if (!res.ok) {
throw new Error('Failed to unpublish')
}
const saved: Post = await res.json()
$editorPost.setKey('draft', saved.draft)
$initialPost.set({ ...$editorPost.get() })
return true
} catch {
return false
} finally {
$isPublishing.set(false)
}
}
export async function restoreVersion(versionId: number): Promise<boolean> {
const post = $editorPost.get()
if (!post.id) return false
try {
const res = await fetch(`/api/studio/posts/${post.id}/versions/${versionId}/restore`, {
method: 'POST',
})
if (!res.ok) {
throw new Error('Failed to restore version')
}
await loadPost(post.slug)
return true
} catch {
return false
}
}
export function getPreviewUrl(): string {
const post = $editorPost.get()
if (!post.slug) return ''
return `/posts/${post.slug}?preview=true`
}
const previewChannel = new BroadcastChannel('writekit-preview')
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let pendingMarkdown: string | null = null
async function sendPreview(markdown: string) {
const post = $editorPost.get()
if (!post.id) return
try {
const res = await fetch(`/api/studio/posts/${post.id}/render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ markdown }),
})
if (!res.ok) return
const { html } = await res.json()
previewChannel.postMessage({
type: 'content-update',
slug: post.slug,
title: post.title,
description: post.description,
html,
})
} catch {
// Silently fail - preview is non-critical
}
}
export function broadcastPreview(markdown: string) {
pendingMarkdown = markdown
previewChannel.postMessage({
type: 'rebuilding',
slug: $editorPost.get().slug,
})
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
if (pendingMarkdown) {
sendPreview(pendingMarkdown)
pendingMarkdown = null
}
}, 500)
}
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.hidden && pendingMarkdown) {
if (debounceTimer) clearTimeout(debounceTimer)
sendPreview(pendingMarkdown)
pendingMarkdown = null
}
})
}

View file

@ -0,0 +1,24 @@
import { nanoquery } from '@nanostores/query'
export const [createFetcherStore, createMutatorStore, { invalidateKeys }] = nanoquery({
fetcher: async (...keys: unknown[]) => {
const url = keys.map(k => String(k)).join('')
const res = await fetch(url)
if (!res.ok) throw new Error(`${res.status}`)
return res.json()
},
dedupeTime: 60_000,
cacheLifetime: 300_000,
})
const blogChannel = new BroadcastChannel('writekit-studio')
export function createBlogMutatorStore<T>(
mutator: (args: { data: T }) => Promise<T>
) {
return createMutatorStore<T>(async (args) => {
const result = await mutator(args)
blogChannel.postMessage({ type: 'settings-changed' })
return result
})
}

View file

@ -0,0 +1,35 @@
import { map } from 'nanostores'
import { createFetcherStore } from './fetcher'
export interface HookInfo {
name: string
label: string
description: string
pattern: string
test_data: Record<string, unknown>
}
export const $hooks = createFetcherStore<HookInfo[]>(['/api/studio/hooks'])
export const $templates = map<Record<string, string>>({})
export async function fetchTemplate(hook: string, language: string): Promise<string> {
const key = `${hook}:${language}`
const cached = $templates.get()[key]
if (cached) return cached
try {
const res = await fetch(`/api/studio/template?hook=${encodeURIComponent(hook)}&language=${encodeURIComponent(language)}`)
if (!res.ok) return ''
const data = await res.json()
const template = data.template || ''
$templates.setKey(key, template)
return template
} catch {
return ''
}
}
export function getTemplate(hook: string, language: string): string {
return $templates.get()[`${hook}:${language}`] || ''
}

View file

@ -0,0 +1,9 @@
export * from './router'
export * from './fetcher'
export * from './app'
export * from './posts'
export * from './settings'
export * from './analytics'
export * from './interactions'
export * from './assets'
export * from './apiKeys'

View file

@ -0,0 +1,74 @@
import { atom, map, computed, onMount } from 'nanostores'
import { createFetcherStore, createBlogMutatorStore } from './fetcher'
import type { InteractionConfig } from '../types'
const defaultConfig: InteractionConfig = {
comments_enabled: false,
reactions_enabled: false,
reaction_mode: 'emoji',
reaction_emojis: '👍,❤️,🎉,🚀',
upvote_icon: 'arrow',
reactions_require_auth: false,
}
export const $interactionsData = createFetcherStore<InteractionConfig>(['/api/studio/interaction-config'])
export const $interactions = map<InteractionConfig>({ ...defaultConfig })
export const $initialInteractions = atom<InteractionConfig | null>(null)
export const $hasInteractionChanges = computed(
[$interactions, $initialInteractions],
(current, initial) => initial !== null && JSON.stringify(current) !== JSON.stringify(initial)
)
export const $changedInteractionFields = computed(
[$interactions, $initialInteractions],
(current, initial) => {
if (!initial) return []
const changes: string[] = []
const labels: Record<string, string> = {
comments_enabled: 'Comments',
reactions_enabled: 'Reactions',
reaction_mode: 'Reaction mode',
reaction_emojis: 'Emojis',
upvote_icon: 'Upvote icon',
reactions_require_auth: 'Auth required',
}
for (const key of Object.keys(current) as (keyof InteractionConfig)[]) {
if (current[key] !== initial[key]) {
changes.push(labels[key] || key)
}
}
return changes
}
)
onMount($interactionsData, () => {
$interactionsData.listen(({ data }) => {
if (data) {
const merged = { ...defaultConfig, ...data }
$interactions.set(merged)
$initialInteractions.set(merged)
}
})
})
export const $saveInteractions = createBlogMutatorStore<InteractionConfig>(
async ({ data: config }) => {
const previous = $initialInteractions.get()
$initialInteractions.set({ ...config })
const res = await fetch('/api/studio/interaction-config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
if (!res.ok) {
$initialInteractions.set(previous)
throw new Error('Failed to save interactions')
}
return config
}
)

View file

@ -0,0 +1,102 @@
import { atom, map } from 'nanostores'
import { createFetcherStore, createMutatorStore } from './fetcher'
export interface Plugin {
id: string
name: string
language: 'typescript' | 'go'
source: string
hooks: string[]
enabled: boolean
wasm?: string
wasm_size?: number
created_at: string
updated_at: string
}
export interface CompileResult {
success: boolean
wasm?: string
size?: number
errors?: string[]
time_ms?: number
}
export const $plugins = createFetcherStore<Plugin[]>(['/api/studio/plugins'])
export const $currentPlugin = map<Partial<Plugin>>({
name: '',
language: 'typescript',
source: '',
hooks: [],
enabled: true,
})
export const $compileResult = atom<CompileResult | null>(null)
export const $isCompiling = atom(false)
export const $savePlugin = createMutatorStore(async ({ data, invalidate }: { data: Plugin; invalidate: (key: string) => void }) => {
const res = await fetch('/api/studio/plugins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to save plugin')
invalidate('/api/studio/plugins')
return res.json()
})
export const $deletePlugin = createMutatorStore(async ({ data: id, invalidate }: { data: string; invalidate: (key: string) => void }) => {
const res = await fetch(`/api/studio/plugins/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete plugin')
invalidate('/api/studio/plugins')
})
export const $togglePlugin = createMutatorStore(async ({ data, invalidate }: { data: { id: string; enabled: boolean }; invalidate: (key: string) => void }) => {
const res = await fetch(`/api/studio/plugins/${data.id}/toggle`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: data.enabled }),
})
if (!res.ok) throw new Error('Failed to toggle plugin')
invalidate('/api/studio/plugins')
})
export async function compilePlugin(language: string, source: string): Promise<CompileResult> {
$isCompiling.set(true)
$compileResult.set(null)
try {
const res = await fetch('/api/studio/plugins/compile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language, source }),
})
const result = await res.json()
$compileResult.set(result)
return result
} finally {
$isCompiling.set(false)
}
}
export async function testPlugin(pluginId: string, hook: string, testData: object): Promise<any> {
const res = await fetch(`/api/studio/plugins/${pluginId}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hook, data: testData }),
})
return res.json()
}
export const AVAILABLE_HOOKS = [
{ value: 'post.published', label: 'Post Published', description: 'Triggered when a post is published' },
{ value: 'post.updated', label: 'Post Updated', description: 'Triggered when a published post is updated' },
{ value: 'comment.created', label: 'Comment Created', description: 'Triggered when a new comment is posted' },
{ value: 'subscriber.created', label: 'Subscriber Created', description: 'Triggered when someone subscribes' },
]
export const LANGUAGES = [
{ value: 'typescript', label: 'TypeScript', extension: '.ts' },
{ value: 'go', label: 'Go', extension: '.go' },
]

View file

@ -0,0 +1,41 @@
import { atom, computed } from 'nanostores'
import { createFetcherStore, createMutatorStore } from './fetcher'
import type { Post } from '../types'
export const $posts = createFetcherStore<Post[]>(['/api/studio/posts'])
export const $search = atom('')
export const $filterStatus = atom<'all' | 'published' | 'draft'>('all')
export const $filteredPosts = computed(
[$posts, $search, $filterStatus],
(postsStore, search, status) => {
const posts = postsStore.data ?? []
return posts.filter(p => {
const searchLower = search.toLowerCase()
if (search && !p.title.toLowerCase().includes(searchLower) && !p.slug.toLowerCase().includes(searchLower)) {
return false
}
if (status === 'published' && p.draft) return false
if (status === 'draft' && !p.draft) return false
return true
})
}
)
export const $postCounts = computed([$posts], (postsStore) => {
const posts = postsStore.data ?? []
return {
all: posts.length,
published: posts.filter(p => !p.draft).length,
draft: posts.filter(p => p.draft).length,
}
})
export const $deletePost = createMutatorStore<string>(
async ({ data: slug, invalidate }) => {
const res = await fetch(`/api/studio/posts/${slug}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete post')
invalidate('/api/studio/posts')
}
)

View file

@ -0,0 +1,18 @@
import { createRouter } from '@nanostores/router'
export const $router = createRouter({
postNew: '/studio/posts/new',
postEdit: '/studio/posts/:slug/edit',
posts: '/studio/posts',
analytics: '/studio/analytics',
general: '/studio/general',
design: '/studio/design',
domain: '/studio/domain',
engagement: '/studio/engagement',
monetization: '/studio/monetization',
plugins: '/studio/plugins',
api: '/studio/api',
data: '/studio/data',
billing: '/studio/billing',
home: '/studio',
})

View file

@ -0,0 +1,36 @@
import { atom } from 'nanostores'
import { createFetcherStore, createMutatorStore } from './fetcher'
export interface Secret {
key: string
created_at: string
updated_at: string
}
export const $secrets = createFetcherStore<Secret[]>(['/api/studio/secrets'])
export const $createSecret = createMutatorStore(async ({ data, invalidate }: { data: { key: string; value: string }; invalidate: (key: string) => void }) => {
const res = await fetch('/api/studio/secrets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to create secret')
invalidate('/api/studio/secrets')
})
export const $deleteSecret = createMutatorStore(async ({ data: key, invalidate }: { data: string; invalidate: (key: string) => void }) => {
const res = await fetch(`/api/studio/secrets/${encodeURIComponent(key)}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error('Failed to delete secret')
invalidate('/api/studio/secrets')
})
export const $secretKeys = atom<string[]>([])
$secrets.subscribe(state => {
if (state.data) {
$secretKeys.set(state.data.map(s => s.key))
}
})

View file

@ -0,0 +1,93 @@
import { atom, map, computed, onMount } from 'nanostores'
import { createFetcherStore, createBlogMutatorStore } from './fetcher'
import type { Settings } from '../types'
const defaultSettings: Settings = {
site_name: '',
site_description: '',
author_name: '',
author_role: '',
author_bio: '',
author_photo: '',
twitter_handle: '',
github_handle: '',
linkedin_handle: '',
email: '',
show_powered_by: 'true',
accent_color: '#10b981',
code_theme: 'github',
font: 'system',
layout: 'default',
compactness: 'cozy',
custom_css: '',
}
export const $settingsData = createFetcherStore<Settings>(['/api/studio/settings'])
export const $settings = map<Settings>({ ...defaultSettings })
export const $initialSettings = atom<Settings | null>(null)
export const $hasChanges = computed(
[$settings, $initialSettings],
(current, initial) => initial !== null && JSON.stringify(current) !== JSON.stringify(initial)
)
export const $changedFields = computed(
[$settings, $initialSettings],
(current, initial) => {
if (!initial) return []
const changes: string[] = []
const labels: Record<string, string> = {
site_name: 'Site name',
site_description: 'Description',
accent_color: 'Accent color',
font: 'Font',
code_theme: 'Code theme',
layout: 'Layout',
compactness: 'Density',
author_name: 'Author name',
author_bio: 'Bio',
custom_css: 'Custom CSS',
}
for (const key of Object.keys(current) as (keyof Settings)[]) {
if (current[key] !== initial[key]) {
changes.push(labels[key] || key)
}
}
return changes
}
)
onMount($settingsData, () => {
$settingsData.listen(({ data }) => {
if (data) {
const merged = { ...defaultSettings, ...data }
$settings.set(merged)
$initialSettings.set(merged)
}
})
})
export const $saveSettings = createBlogMutatorStore<Settings>(
async ({ data: settings }) => {
const previous = $initialSettings.get()
$initialSettings.set({ ...settings })
if (settings.accent_color) {
document.documentElement.style.setProperty('--accent', settings.accent_color)
}
const res = await fetch('/api/studio/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
})
if (!res.ok) {
$initialSettings.set(previous)
throw new Error('Failed to save settings')
}
return settings
}
)

View file

@ -0,0 +1,69 @@
import { atom } from 'nanostores'
import { createFetcherStore, createMutatorStore, invalidateKeys } from './fetcher'
import type { Webhook, WebhookDelivery } from '../types'
export const $webhooks = createFetcherStore<Webhook[]>(['/api/studio/webhooks'])
export const $creating = atom(false)
export async function createWebhook(data: {
name: string
url: string
events: string[]
secret?: string
}): Promise<Webhook> {
$creating.set(true)
try {
const res = await fetch('/api/studio/webhooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to create webhook')
const webhook = await res.json()
invalidateKeys('/api/studio/webhooks')
return webhook
} finally {
$creating.set(false)
}
}
export async function updateWebhook(
id: string,
data: {
name: string
url: string
events: string[]
secret?: string
enabled: boolean
}
): Promise<Webhook> {
const res = await fetch(`/api/studio/webhooks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to update webhook')
const webhook = await res.json()
invalidateKeys('/api/studio/webhooks')
return webhook
}
export const $deleteWebhook = createMutatorStore<string>(
async ({ data: id, invalidate }) => {
const res = await fetch(`/api/studio/webhooks/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete webhook')
invalidate('/api/studio/webhooks')
}
)
export async function testWebhook(id: string): Promise<void> {
const res = await fetch(`/api/studio/webhooks/${id}/test`, { method: 'POST' })
if (!res.ok) throw new Error('Failed to test webhook')
}
export async function fetchWebhookDeliveries(id: string): Promise<WebhookDelivery[]> {
const res = await fetch(`/api/studio/webhooks/${id}/deliveries`)
if (!res.ok) throw new Error('Failed to fetch deliveries')
return res.json()
}