Skip to content

Commit 04e1c19

Browse files
committed
feat: add draft article support
1 parent daec5b1 commit 04e1c19

File tree

7 files changed

+301
-12
lines changed

7 files changed

+301
-12
lines changed

src/components/BlogForm.vue

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,73 @@
145145
>
146146
Cancel
147147
</UButton>
148+
<UPopover
149+
v-model:open="popoverOpen"
150+
dismissible
151+
>
152+
<UButton
153+
icon="mdi:content-save-cog"
154+
color="neutral"
155+
:disabled="loading"
156+
:loading="loadingDraft"
157+
@click="handleDrafts"
158+
>
159+
Load Draft
160+
</UButton>
161+
162+
<template #content="{ close }">
163+
<div class="p-4 max-h-96 overflow-y-auto min-w-64">
164+
<h3 class="text-lg font-semibold mb-2">Available Drafts</h3>
165+
<div
166+
v-if="!drafts || drafts.length === 0"
167+
class="text-gray-500 text-center py-4"
168+
>
169+
No drafts found
170+
</div>
171+
<div
172+
v-else
173+
class="space-y-2"
174+
>
175+
<div
176+
v-for="draft in drafts"
177+
:key="draft.slug"
178+
class="p-2 border rounded hover:opacity-70 cursor-pointer transition-opacity"
179+
@mouseenter="previewDraft(draft)"
180+
@click="
181+
() => {
182+
loadDraft(draft);
183+
close();
184+
}
185+
"
186+
>
187+
<div class="font-medium">
188+
{{ draft.title || draft.slug }}
189+
</div>
190+
<div
191+
v-if="draft.title && draft.slug"
192+
class="text-sm text-gray-500"
193+
>
194+
{{ draft.slug }}
195+
</div>
196+
</div>
197+
</div>
198+
</div>
199+
</template>
200+
</UPopover>
201+
<UButton
202+
icon="mdi:content-save-edit"
203+
color="secondary"
204+
:disabled="loading || !state.slug"
205+
:loading="savingDraft"
206+
@click="handleSave"
207+
>
208+
Save Draft
209+
</UButton>
148210
<UButton
149211
type="submit"
150212
icon="mdi:content-save"
151213
:loading="loading"
152-
:disabled="loading || state.content.length < 50"
214+
:disabled="loading || !state.slug || state.content.length < 50"
153215
>
154216
{{ mode === 'edit' ? 'Update Post' : 'Create Post' }}
155217
</UButton>
@@ -173,6 +235,7 @@ const emit = defineEmits<{
173235
}>();
174236
175237
const { addPost, updatePost } = useBlogPosts();
238+
const { drafts, fetchDrafts, saveDraft } = useDrafts();
176239
177240
const state = reactive<BlogPostInput>({
178241
title: props.initialData?.title || '',
@@ -182,6 +245,8 @@ const state = reactive<BlogPostInput>({
182245
tags: props.initialData?.tags || []
183246
});
184247
248+
const previousState = ref<BlogPostInput | null>(null);
249+
185250
const tagInput = ref('');
186251
const loading = ref(false);
187252
const error = ref('');
@@ -288,4 +353,116 @@ const handleSubmit = async (event: FormSubmitEvent<BlogPostInput>) => {
288353
const handleCancel = () => {
289354
emit('cancel');
290355
};
356+
357+
const loadingDraft = ref(false);
358+
const draftSelected = ref(false);
359+
const popoverOpen = ref(false);
360+
361+
watch(popoverOpen, (isOpen) => {
362+
if (!isOpen) {
363+
if (!draftSelected.value) {
364+
clearPreview();
365+
}
366+
draftSelected.value = false;
367+
}
368+
});
369+
370+
const previewDraft = (draft: Partial<BlogPostInput>) => {
371+
if (!previousState.value) {
372+
previousState.value = { ...state };
373+
}
374+
375+
Object.assign(state, {
376+
title: draft.title || '',
377+
slug: draft.slug || '',
378+
content: draft.content || '',
379+
thumbnail_url: draft.thumbnail_url || '',
380+
tags: draft.tags || []
381+
});
382+
};
383+
384+
const clearPreview = () => {
385+
if (previousState.value) {
386+
Object.assign(state, previousState.value);
387+
previousState.value = null;
388+
}
389+
};
390+
391+
const loadDraft = (draft: Partial<BlogPostInput>) => {
392+
const hasExistingData =
393+
previousState.value &&
394+
(previousState.value.title ||
395+
previousState.value.slug ||
396+
previousState.value.content ||
397+
previousState.value.thumbnail_url ||
398+
previousState.value.tags.length > 0);
399+
400+
if (hasExistingData) {
401+
const confirmed = confirm('Loading this draft will replace your current form data. Continue?');
402+
if (!confirmed) {
403+
clearPreview();
404+
draftSelected.value = false;
405+
return;
406+
}
407+
}
408+
409+
Object.assign(state, {
410+
title: draft.title || '',
411+
slug: draft.slug || '',
412+
content: draft.content || '',
413+
thumbnail_url: draft.thumbnail_url || '',
414+
tags: draft.tags || []
415+
});
416+
417+
previousState.value = null;
418+
draftSelected.value = true;
419+
420+
toast.add({
421+
title: 'Draft Loaded',
422+
description: 'The draft has been loaded into the form.',
423+
icon: 'mdi:content-save-cog',
424+
color: 'info'
425+
});
426+
};
427+
428+
const handleDrafts = async () => {
429+
loadingDraft.value = true;
430+
try {
431+
await fetchDrafts();
432+
} catch (err: any) {
433+
toast.add({
434+
title: 'Error',
435+
description: err.message || 'Failed to load drafts',
436+
icon: 'mdi:alert-circle',
437+
color: 'error'
438+
});
439+
} finally {
440+
loadingDraft.value = false;
441+
}
442+
};
443+
444+
const savingDraft = ref(false);
445+
446+
const handleSave = async () => {
447+
savingDraft.value = true;
448+
try {
449+
await saveDraft({ ...state, slug: state.slug });
450+
451+
toast.add({
452+
title: 'Draft Saved',
453+
description: 'Your draft has been saved successfully.',
454+
icon: 'mdi:content-save-edit',
455+
color: 'success'
456+
});
457+
} catch (err: any) {
458+
toast.add({
459+
title: 'Error',
460+
description: err.message || 'Failed to save draft',
461+
icon: 'mdi:alert-circle',
462+
color: 'error'
463+
});
464+
} finally {
465+
savingDraft.value = false;
466+
}
467+
};
291468
</script>

src/composables/useDatabase.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { BlogPost } from '~/shared/types';
1+
import type { BlogPost, BlogPostData } from '~/shared/types';
22

33
export function useBlogPosts() {
44
const posts = useState<BlogPost[]>('blog-posts', () => []);
@@ -43,9 +43,7 @@ export function useBlogPosts() {
4343
};
4444
};
4545

46-
const addPost = async (
47-
post: Omit<BlogPost, 'id' | 'created_at' | 'updated_at'>
48-
): Promise<BlogPost> => {
46+
const addPost = async (post: BlogPostData): Promise<BlogPost> => {
4947
const res = await $fetch<BlogPost>('/api/blog/create', {
5048
method: 'POST',
5149
body: { post }
@@ -55,7 +53,7 @@ export function useBlogPosts() {
5553
return res;
5654
};
5755

58-
const updatePost = async (post: Omit<BlogPost, 'created_at' | 'updated_at'>) => {
56+
const updatePost = async (post: BlogPostData & { id: string }) => {
5957
const res = await $fetch<BlogPost>('/api/blog/update', {
6058
method: 'PATCH',
6159
body: { post }
@@ -141,3 +139,30 @@ export function useSettings() {
141139
fetchSettings
142140
};
143141
}
142+
143+
export function useDrafts() {
144+
const drafts = useState<Partial<BlogPostData>[]>('blog-drafts', () => []);
145+
146+
const fetchDrafts = async () => {
147+
const res = await $fetch<{ drafts: Partial<BlogPostData>[] }>('/api/blog/draft', {
148+
method: 'GET'
149+
});
150+
151+
drafts.value = res.drafts;
152+
return res.drafts;
153+
};
154+
155+
const saveDraft = async (draft: Partial<BlogPostData> & { slug: string }) => {
156+
await $fetch('/api/blog/draft', {
157+
method: 'POST',
158+
body: { post: draft }
159+
});
160+
await fetchDrafts();
161+
};
162+
163+
return {
164+
drafts,
165+
fetchDrafts,
166+
saveDraft
167+
};
168+
}

src/server/api/blog/create.post.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,13 @@ import { blogPosts } from '~/server/db/schema';
44
import { ensureLoggedIn } from '~/server/utils';
55
import { ensureDatabase } from '~/server/utils/db';
66
import { blogPostCreateSchema } from '~/shared/schemas';
7-
import { type BlogPost } from '~/shared/types';
7+
import { BlogPostData, type BlogPost } from '~/shared/types';
88

99
export default defineEventHandler(async (event) => {
1010
await ensureLoggedIn(event);
1111
await ensureDatabase();
1212

13-
const { post } = await readBody<{ post: Omit<BlogPost, 'id' | 'created_at' | 'updated_at'> }>(
14-
event
15-
);
13+
const { post } = await readBody<{ post: BlogPostData }>(event);
1614

1715
if (!post) {
1816
throw createError({

src/server/api/blog/draft.get.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { kv } from 'hub:kv';
2+
import { BlogPostData } from '~/shared/types';
3+
4+
export default defineEventHandler(async (event) => {
5+
await ensureDatabase();
6+
7+
const { slug } = getQuery(event);
8+
9+
if (!slug) {
10+
// return list of all drafts
11+
const keys = await kv.keys('nuxtpress:blog_draft:');
12+
const drafts = [];
13+
for (const key of keys) {
14+
const draft = await kv.get<Partial<BlogPostData>>(key);
15+
if (draft) {
16+
drafts.push(draft);
17+
}
18+
}
19+
20+
return { drafts };
21+
} else {
22+
// return specific draft by slug
23+
if (typeof slug !== 'string' || slug.trim() === '') {
24+
throw createError({
25+
statusCode: 400,
26+
statusMessage: 'Invalid slug provided'
27+
});
28+
}
29+
30+
const draft = await kv.get<Partial<BlogPostData>>(`nuxtpress:blog_draft:${slug}`);
31+
if (!draft) {
32+
throw createError({
33+
statusCode: 404,
34+
statusMessage: 'Draft not found'
35+
});
36+
}
37+
38+
return { draft };
39+
}
40+
});

src/server/api/blog/draft.post.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { kv } from 'hub:kv';
2+
import { blogPostCreateSchema } from '~/shared/schemas';
3+
import { BlogPostData } from '~/shared/types';
4+
5+
export default defineEventHandler(async (event) => {
6+
await ensureDatabase();
7+
8+
const { post } = await readBody<{
9+
post: Partial<BlogPostData> & { slug: string };
10+
}>(event);
11+
12+
if (!post) {
13+
throw createError({
14+
statusCode: 400,
15+
statusMessage: 'No post object provided'
16+
});
17+
}
18+
19+
if (!post.slug || typeof post.slug !== 'string' || post.slug.trim() === '') {
20+
throw createError({
21+
statusCode: 400,
22+
statusMessage: 'Invalid slug provided'
23+
});
24+
}
25+
26+
const validation = blogPostCreateSchema.safeParse(post);
27+
if (!validation.success) {
28+
throw createError({
29+
statusCode: 400,
30+
statusMessage: 'Invalid post data',
31+
data: validation.error.issues
32+
});
33+
}
34+
35+
await kv.set(`nuxtpress:blog_draft:${post.slug}`, JSON.stringify(post), {
36+
ttl: 60 * 60 * 24 * 7
37+
}); // 7 days TTL
38+
39+
return { success: true };
40+
});

src/server/api/blog/update.patch.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import { blogPosts } from '~/server/db/schema';
44
import { ensureLoggedIn } from '~/server/utils';
55
import { ensureDatabase } from '~/server/utils/db';
66
import { blogPostUpdateSchema } from '~/shared/schemas';
7-
import { type BlogPost } from '~/shared/types';
7+
import { BlogPostData } from '~/shared/types';
88

99
export default defineEventHandler(async (event) => {
1010
await ensureLoggedIn(event);
1111
await ensureDatabase();
1212

13-
const { post } = await readBody<{ post: Omit<BlogPost, 'created_at' | 'updated_at'> }>(event);
13+
const { post } = await readBody<{ post: BlogPostData & { id: string } }>(event);
1414

1515
if (!post) {
1616
throw createError({
@@ -19,6 +19,13 @@ export default defineEventHandler(async (event) => {
1919
});
2020
}
2121

22+
if (!post.id || typeof post.id !== 'string' || post.id.trim() === '') {
23+
throw createError({
24+
statusCode: 400,
25+
statusMessage: 'Invalid post ID provided'
26+
});
27+
}
28+
2229
const validation = blogPostUpdateSchema.safeParse(post);
2330
if (!validation.success) {
2431
throw createError({

0 commit comments

Comments
 (0)