diff --git a/apps/admin/src/components/EditorShell.tsx b/apps/admin/src/components/EditorShell.tsx
index 3447c6f..c54efbb 100644
--- a/apps/admin/src/components/EditorShell.tsx
+++ b/apps/admin/src/components/EditorShell.tsx
@@ -46,6 +46,8 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
publishToGhost,
refreshPreview,
toggleGenImage,
+ triggerAutoSave,
+ triggerImmediateAutoSave,
} = usePostEditor(initialPostId);
// Keyboard shortcut: Ctrl/Cmd+S to save
@@ -128,6 +130,7 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
onSetFeature={(url: string) => setMeta(m => ({ ...m, featureImage: url }))}
selectedKeys={genImageKeys}
onToggleSelect={toggleGenImage}
+ onAudioRemoved={triggerImmediateAutoSave}
/>
)}
@@ -169,13 +172,18 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
generatedDraft={generatedDraft}
imagePlaceholders={imagePlaceholders}
selectedImageKeys={genImageKeys}
+ onAutoSave={triggerImmediateAutoSave}
/>
)}
{activeStep === 4 && (
-
+
)}
diff --git a/apps/admin/src/components/steps/StepAssets.tsx b/apps/admin/src/components/steps/StepAssets.tsx
index 940f0ab..8f5bdfb 100644
--- a/apps/admin/src/components/steps/StepAssets.tsx
+++ b/apps/admin/src/components/steps/StepAssets.tsx
@@ -14,6 +14,7 @@ export default function StepAssets({
onSetFeature,
selectedKeys,
onToggleSelect,
+ onAudioRemoved,
}: {
postId?: string | null;
postClips: Clip[];
@@ -22,6 +23,7 @@ export default function StepAssets({
onSetFeature: (url: string) => void;
selectedKeys?: string[];
onToggleSelect?: (key: string) => void;
+ onAudioRemoved?: () => void;
}) {
return (
@@ -35,6 +37,7 @@ export default function StepAssets({
postId={postId ?? undefined}
onInsertAtCursor={onInsertAtCursor}
initialClips={postClips}
+ onAudioRemoved={onAudioRemoved}
/>
diff --git a/apps/admin/src/components/steps/StepEdit.tsx b/apps/admin/src/components/steps/StepEdit.tsx
index 1a3dba2..a317d5e 100644
--- a/apps/admin/src/components/steps/StepEdit.tsx
+++ b/apps/admin/src/components/steps/StepEdit.tsx
@@ -11,6 +11,7 @@ export default function StepEdit({
generatedDraft,
imagePlaceholders,
selectedImageKeys,
+ onAutoSave,
}: {
editorRef: ForwardedRef | any;
draftHtml: string;
@@ -18,9 +19,11 @@ export default function StepEdit({
generatedDraft?: string;
imagePlaceholders?: string[];
selectedImageKeys?: string[];
+ onAutoSave?: () => void;
}) {
const [showImagePicker, setShowImagePicker] = useState(false);
const [currentPlaceholder, setCurrentPlaceholder] = useState('');
+
const loadGeneratedDraft = () => {
if (!generatedDraft) return;
if (draftHtml && draftHtml !== '' && !confirm('Replace current content with generated draft?')) {
@@ -36,6 +39,10 @@ export default function StepEdit({
const newHtml = draftHtml.replace(placeholderPattern, replacement);
onChangeDraft(newHtml);
setShowImagePicker(false);
+ // Trigger auto-save after replacing placeholder
+ if (onAutoSave) {
+ setTimeout(() => onAutoSave(), 0);
+ }
};
const detectPlaceholders = () => {
diff --git a/apps/admin/src/components/steps/StepMetadata.tsx b/apps/admin/src/components/steps/StepMetadata.tsx
index 4318fef..61f2a94 100644
--- a/apps/admin/src/components/steps/StepMetadata.tsx
+++ b/apps/admin/src/components/steps/StepMetadata.tsx
@@ -2,14 +2,30 @@ import { Box } from '@mui/material';
import MetadataPanel, { type Metadata } from '../MetadataPanel';
import StepHeader from './StepHeader';
-export default function StepMetadata({ value, onChange }: { value: Metadata; onChange: (v: Metadata) => void }) {
+export default function StepMetadata({
+ value,
+ onChange,
+ onAutoSave
+}: {
+ value: Metadata;
+ onChange: (v: Metadata) => void;
+ onAutoSave?: () => void;
+}) {
+ const handleChange = (v: Metadata) => {
+ onChange(v);
+ // Trigger auto-save after metadata change
+ if (onAutoSave) {
+ setTimeout(() => onAutoSave(), 0);
+ }
+ };
+
return (
-
+
);
}
diff --git a/apps/admin/src/features/recorder/Recorder.tsx b/apps/admin/src/features/recorder/Recorder.tsx
index ac9c9c7..02bcc34 100644
--- a/apps/admin/src/features/recorder/Recorder.tsx
+++ b/apps/admin/src/features/recorder/Recorder.tsx
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { Box, Button, Stack, Typography, Paper } from '@mui/material';
-export default function Recorder({ postId, initialClips, onInsertAtCursor, onTranscript }: { postId?: string; initialClips?: Array<{ id: string; bucket: string; key: string; mime: string; transcript?: string }>; onInsertAtCursor?: (html: string) => void; onTranscript?: (t: string) => void }) {
+export default function Recorder({ postId, initialClips, onInsertAtCursor, onTranscript, onAudioRemoved }: { postId?: string; initialClips?: Array<{ id: string; bucket: string; key: string; mime: string; transcript?: string }>; onInsertAtCursor?: (html: string) => void; onTranscript?: (t: string) => void; onAudioRemoved?: () => void }) {
const mediaRecorderRef = useRef(null);
const chunksRef = useRef([]);
const mimeRef = useRef('audio/webm');
@@ -200,6 +200,11 @@ export default function Recorder({ postId, initialClips, onInsertAtCursor, onTra
if (item?.url) URL.revokeObjectURL(item.url);
return arr;
});
+
+ // Notify parent that audio was removed (triggers auto-save)
+ if (onAudioRemoved) {
+ setTimeout(() => onAudioRemoved(), 0);
+ }
};
const applyTranscriptsToDraft = () => {
diff --git a/apps/admin/src/hooks/usePostEditor.ts b/apps/admin/src/hooks/usePostEditor.ts
index f5c60c3..2ad8ace 100644
--- a/apps/admin/src/hooks/usePostEditor.ts
+++ b/apps/admin/src/hooks/usePostEditor.ts
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useState, useCallback, useRef } from 'react';
import { getPost, savePost as savePostApi, deletePost as deletePostApi, type SavePostPayload } from '../services/posts';
import { ghostPreview as ghostPreviewApi, ghostPublish as ghostPublishApi, type GhostPostStatus } from '../services/ghost';
import type { Metadata } from '../components/MetadataPanel';
@@ -18,6 +18,7 @@ export function usePostEditor(initialPostId?: string | null) {
const [genImageKeys, setGenImageKeys] = useState([]);
const [generatedDraft, setGeneratedDraft] = useState('');
const [imagePlaceholders, setImagePlaceholders] = useState([]);
+ const autoSaveTimeoutRef = useRef | null>(null);
useEffect(() => {
const savedId = initialPostId || localStorage.getItem('voxblog_post_id');
@@ -49,7 +50,7 @@ export function usePostEditor(initialPostId?: string | null) {
}
}, [initialPostId]);
- const savePost = async (overrides?: Partial) => {
+ const savePost = useCallback(async (overrides?: Partial, silent = false) => {
localStorage.setItem('voxblog_draft', draft);
const payload: SavePostPayload = {
id: postId ?? undefined,
@@ -70,9 +71,11 @@ export function usePostEditor(initialPostId?: string | null) {
setPostId(data.id);
localStorage.setItem('voxblog_post_id', data.id);
}
- setToast({ open: true, message: 'Post saved', severity: 'success' });
+ if (!silent) {
+ setToast({ open: true, message: 'Post saved', severity: 'success' });
+ }
return data;
- };
+ }, [postId, draft, meta, postStatus, promptText, genImageKeys, generatedDraft, imagePlaceholders]);
const deletePost = async () => {
if (!postId) return;
@@ -118,9 +121,44 @@ export function usePostEditor(initialPostId?: string | null) {
if (activeStep === 5) refreshPreview();
}, [activeStep]);
- const toggleGenImage = (key: string) => {
- setGenImageKeys(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]);
- };
+ // Auto-save with debouncing (silent save)
+ const triggerAutoSave = useCallback((delay = 500) => {
+ if (autoSaveTimeoutRef.current) {
+ clearTimeout(autoSaveTimeoutRef.current);
+ }
+ autoSaveTimeoutRef.current = setTimeout(() => {
+ savePost({}, true).catch(err => {
+ console.error('Auto-save failed:', err);
+ });
+ }, delay);
+ }, [savePost]);
+
+ // Immediate auto-save (no debounce, silent)
+ const triggerImmediateAutoSave = useCallback(async () => {
+ try {
+ await savePost({}, true);
+ } catch (err) {
+ console.error('Immediate auto-save failed:', err);
+ }
+ }, [savePost]);
+
+ const toggleGenImage = useCallback((key: string) => {
+ setGenImageKeys(prev => {
+ const newKeys = prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key];
+ // Trigger immediate auto-save after state update
+ setTimeout(() => triggerImmediateAutoSave(), 0);
+ return newKeys;
+ });
+ }, [triggerImmediateAutoSave]);
+
+ // Cleanup timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (autoSaveTimeoutRef.current) {
+ clearTimeout(autoSaveTimeoutRef.current);
+ }
+ };
+ }, []);
return {
// state
@@ -154,5 +192,7 @@ export function usePostEditor(initialPostId?: string | null) {
publishToGhost,
refreshPreview,
toggleGenImage,
+ triggerAutoSave,
+ triggerImmediateAutoSave,
} as const;
}