init
This commit is contained in:
commit
d69342b2e9
160 changed files with 28681 additions and 0 deletions
10
studio/src/stores/analytics.ts
Normal file
10
studio/src/stores/analytics.ts
Normal 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,
|
||||
])
|
||||
34
studio/src/stores/apiKeys.ts
Normal file
34
studio/src/stores/apiKeys.ts
Normal 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
19
studio/src/stores/app.ts
Normal 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))
|
||||
}
|
||||
33
studio/src/stores/assets.ts
Normal file
33
studio/src/stores/assets.ts
Normal 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')
|
||||
}
|
||||
)
|
||||
4
studio/src/stores/billing.ts
Normal file
4
studio/src/stores/billing.ts
Normal 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
467
studio/src/stores/editor.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
24
studio/src/stores/fetcher.ts
Normal file
24
studio/src/stores/fetcher.ts
Normal 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
|
||||
})
|
||||
}
|
||||
35
studio/src/stores/hooks.ts
Normal file
35
studio/src/stores/hooks.ts
Normal 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}`] || ''
|
||||
}
|
||||
9
studio/src/stores/index.ts
Normal file
9
studio/src/stores/index.ts
Normal 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'
|
||||
74
studio/src/stores/interactions.ts
Normal file
74
studio/src/stores/interactions.ts
Normal 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
|
||||
}
|
||||
)
|
||||
102
studio/src/stores/plugins.ts
Normal file
102
studio/src/stores/plugins.ts
Normal 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' },
|
||||
]
|
||||
41
studio/src/stores/posts.ts
Normal file
41
studio/src/stores/posts.ts
Normal 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')
|
||||
}
|
||||
)
|
||||
18
studio/src/stores/router.ts
Normal file
18
studio/src/stores/router.ts
Normal 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',
|
||||
})
|
||||
36
studio/src/stores/secrets.ts
Normal file
36
studio/src/stores/secrets.ts
Normal 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))
|
||||
}
|
||||
})
|
||||
93
studio/src/stores/settings.ts
Normal file
93
studio/src/stores/settings.ts
Normal 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
|
||||
}
|
||||
)
|
||||
69
studio/src/stores/webhooks.ts
Normal file
69
studio/src/stores/webhooks.ts
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue