Skip to content

Commit 41bb41c

Browse files
committed
feat: Draft feature
1 parent 0294e1b commit 41bb41c

File tree

10 files changed

+635
-1
lines changed

10 files changed

+635
-1
lines changed
Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
@using LinkDotNet.Blog.Web.Features.Services
2+
@using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components
3+
@using System.Threading
4+
@inject IDraftService DraftService
5+
@inject IJSRuntime JSRuntime
6+
@implements IDisposable
7+
8+
@if (showSaveStatus)
9+
{
10+
<div class="position-fixed top-0 end-0 m-3 bg-white rounded shadow-sm border p-2 @StatusClass" style="z-index: 1050; font-size: 0.875rem;" title="@StatusTitle">
11+
<i class="@StatusIcon"></i>
12+
<span class="ms-1">@StatusText</span>
13+
</div>
14+
}
15+
16+
@if (showDraftRecovery && availableDrafts.Any())
17+
{
18+
<div class="alert alert-info d-flex align-items-center justify-content-between" role="alert">
19+
<div>
20+
<i class="info-circle me-2"></i>
21+
<strong>Draft Recovery:</strong> Found @availableDrafts.Count saved draft(s). Would you like to recover your work?
22+
</div>
23+
<div class="btn-group" role="group">
24+
<button type="button" class="btn btn-sm btn-primary" @onclick="ShowDraftRecoveryDialog">
25+
<i class="arrow-clockwise me-1"></i>View Drafts
26+
</button>
27+
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="DismissDraftRecovery">
28+
<i class="x me-1"></i>Dismiss
29+
</button>
30+
</div>
31+
</div>
32+
}
33+
34+
@if (showDraftDialog)
35+
{
36+
<div class="modal show d-block" style="background-color: rgba(0,0,0,0.5);" tabindex="-1">
37+
<div class="modal-dialog modal-lg">
38+
<div class="modal-content">
39+
<div class="modal-header">
40+
<h5 class="modal-title">
41+
<i class="bi bi-file-earmark-text me-2"></i>Recover Draft
42+
</h5>
43+
<button type="button" class="btn-close" @onclick="CloseDraftDialog"></button>
44+
</div>
45+
<div class="modal-body">
46+
@if (availableDrafts.Any())
47+
{
48+
<p class="text-muted mb-3">Select a draft to recover:</p>
49+
<div class="list-group">
50+
@foreach (var draft in availableDrafts.OrderByDescending(d => d.LastSavedAt))
51+
{
52+
<div class="list-group-item list-group-item-action">
53+
<div class="d-flex justify-content-between align-items-start">
54+
<div class="flex-grow-1">
55+
<h6 class="mb-1">@(string.IsNullOrWhiteSpace(draft.Title) ? "[Untitled Draft]" : draft.Title)</h6>
56+
<p class="mb-1 text-muted small">
57+
@if (!string.IsNullOrWhiteSpace(draft.ShortDescription))
58+
{
59+
<span>@(draft.ShortDescription.Length > 100 ? draft.ShortDescription[..100] + "..." : draft.ShortDescription)</span>
60+
}
61+
else
62+
{
63+
<em>No description</em>
64+
}
65+
</p>
66+
<small class="text-muted">
67+
<i class="bi bi-clock me-1"></i>
68+
Saved @draft.LastSavedAt.ToString("MMM dd, yyyy HH:mm")
69+
@if (draft.DraftType == DraftType.Edit)
70+
{
71+
<span class="badge bg-secondary ms-2">Editing</span>
72+
}
73+
else
74+
{
75+
<span class="badge bg-primary ms-2">New Post</span>
76+
}
77+
</small>
78+
</div>
79+
<div class="btn-group" role="group">
80+
<button type="button" class="btn btn-sm btn-primary" @onclick="() => RestoreDraft(draft)">
81+
<i class="bi bi-arrow-clockwise me-1"></i>Restore
82+
</button>
83+
<button type="button" class="btn btn-sm btn-outline-danger" @onclick="() => DeleteDraft(draft)">
84+
<i class="bi bi-trash me-1"></i>Delete
85+
</button>
86+
</div>
87+
</div>
88+
</div>
89+
}
90+
</div>
91+
}
92+
else
93+
{
94+
<div class="text-center py-4">
95+
<i class="bi bi-file-earmark-x display-1 text-muted"></i>
96+
<p class="text-muted mt-3">No drafts available</p>
97+
</div>
98+
}
99+
</div>
100+
<div class="modal-footer">
101+
<button type="button" class="btn btn-secondary" @onclick="CloseDraftDialog">Close</button>
102+
@if (availableDrafts.Any())
103+
{
104+
<button type="button" class="btn btn-outline-danger" @onclick="DeleteAllDrafts">
105+
<i class="bi bi-trash me-1"></i>Delete All Drafts
106+
</button>
107+
}
108+
</div>
109+
</div>
110+
</div>
111+
</div>
112+
}
113+
114+
@code {
115+
private Timer? autoSaveTimer;
116+
private string? currentDraftId;
117+
private SaveStatus currentStatus = SaveStatus.None;
118+
private bool showSaveStatus;
119+
private bool showDraftRecovery;
120+
private bool showDraftDialog;
121+
private List<BlogPostDraft> availableDrafts = new();
122+
123+
private const int AutoSaveDelaySeconds = 3;
124+
private const int StatusDisplayDurationMs = 3000;
125+
126+
[Parameter, EditorRequired]
127+
public CreateNewModel Model { get; set; } = default!;
128+
129+
[Parameter]
130+
public string? BlogPostId { get; set; }
131+
132+
private string StatusClass => currentStatus switch
133+
{
134+
SaveStatus.Saving => "text-warning",
135+
SaveStatus.Saved => "text-success",
136+
SaveStatus.Error => "text-danger",
137+
_ => "text-muted"
138+
};
139+
140+
private string StatusIcon => currentStatus switch
141+
{
142+
SaveStatus.Saving => "bi bi-arrow-repeat",
143+
SaveStatus.Saved => "bi bi-check-circle-fill",
144+
SaveStatus.Error => "bi bi-exclamation-triangle-fill",
145+
_ => ""
146+
};
147+
148+
private string StatusText => currentStatus switch
149+
{
150+
SaveStatus.Saving => "Saving draft...",
151+
SaveStatus.Saved => "Draft saved",
152+
SaveStatus.Error => "Save failed",
153+
_ => ""
154+
};
155+
156+
private string StatusTitle => currentStatus switch
157+
{
158+
SaveStatus.Saving => "Automatically saving your changes",
159+
SaveStatus.Saved => $"Draft saved at {DateTime.Now:HH:mm:ss}",
160+
SaveStatus.Error => "Failed to save draft to local storage",
161+
_ => ""
162+
};
163+
164+
protected override async Task OnInitializedAsync()
165+
{
166+
Model.PropertyChanged += OnModelPropertyChanged;
167+
168+
await LoadAvailableDraftsAsync();
169+
170+
showDraftRecovery = availableDrafts.Any();
171+
172+
autoSaveTimer = new Timer(AutoSaveCallback, null, Timeout.Infinite, Timeout.Infinite);
173+
}
174+
175+
private async Task LoadAvailableDraftsAsync()
176+
{
177+
try
178+
{
179+
if (!string.IsNullOrEmpty(BlogPostId))
180+
{
181+
var drafts = await DraftService.GetDraftsForBlogPostAsync(BlogPostId);
182+
availableDrafts = drafts.ToList();
183+
}
184+
else
185+
{
186+
var drafts = await DraftService.GetNewPostDraftsAsync();
187+
availableDrafts = drafts.ToList();
188+
}
189+
}
190+
catch (Exception ex)
191+
{
192+
await JSRuntime.InvokeVoidAsync("console.error", "Failed to load drafts", ex.Message);
193+
}
194+
}
195+
196+
private void OnModelPropertyChanged(object? sender, EventArgs e)
197+
{
198+
autoSaveTimer?.Change(TimeSpan.FromSeconds(AutoSaveDelaySeconds), Timeout.InfiniteTimeSpan);
199+
}
200+
201+
private async void AutoSaveCallback(object? state)
202+
{
203+
try
204+
{
205+
if (!Model.HasSubstantialContent || !Model.IsDirty)
206+
return;
207+
208+
await InvokeAsync(async () =>
209+
{
210+
await UpdateSaveStatus(SaveStatus.Saving);
211+
212+
try
213+
{
214+
var draft = BlogPostDraft.FromCreateNewModel(Model, BlogPostId);
215+
216+
if (!string.IsNullOrEmpty(currentDraftId))
217+
{
218+
draft.Id = currentDraftId;
219+
}
220+
else
221+
{
222+
currentDraftId = draft.Id;
223+
}
224+
225+
await DraftService.SaveDraftAsync(draft);
226+
await UpdateSaveStatus(SaveStatus.Saved);
227+
}
228+
catch (Exception ex)
229+
{
230+
await JSRuntime.InvokeVoidAsync("console.error", "Auto-save failed", ex.Message);
231+
await UpdateSaveStatus(SaveStatus.Error);
232+
}
233+
});
234+
}
235+
catch (Exception ex)
236+
{
237+
await JSRuntime.InvokeVoidAsync("console.error", "Auto-save callback failed", ex.Message);
238+
}
239+
}
240+
241+
private async Task UpdateSaveStatus(SaveStatus status)
242+
{
243+
currentStatus = status;
244+
showSaveStatus = status != SaveStatus.None;
245+
StateHasChanged();
246+
247+
if (status != SaveStatus.Saving)
248+
{
249+
await Task.Delay(StatusDisplayDurationMs);
250+
if (currentStatus == status)
251+
{
252+
showSaveStatus = false;
253+
StateHasChanged();
254+
}
255+
}
256+
}
257+
258+
private void ShowDraftRecoveryDialog()
259+
{
260+
showDraftDialog = true;
261+
StateHasChanged();
262+
}
263+
264+
private void CloseDraftDialog()
265+
{
266+
showDraftDialog = false;
267+
StateHasChanged();
268+
}
269+
270+
private void DismissDraftRecovery()
271+
{
272+
showDraftRecovery = false;
273+
StateHasChanged();
274+
}
275+
276+
private void RestoreDraft(BlogPostDraft draft)
277+
{
278+
draft.UpdateCreateNewModel(Model);
279+
Model.MarkAsClean();
280+
currentDraftId = draft.Id;
281+
282+
showDraftDialog = false;
283+
showDraftRecovery = false;
284+
StateHasChanged();
285+
}
286+
287+
private async Task DeleteDraft(BlogPostDraft draft)
288+
{
289+
await DraftService.DeleteDraftAsync(draft.Id);
290+
availableDrafts.Remove(draft);
291+
292+
if (!availableDrafts.Any())
293+
{
294+
showDraftDialog = false;
295+
showDraftRecovery = false;
296+
}
297+
298+
StateHasChanged();
299+
}
300+
301+
private async Task DeleteAllDrafts()
302+
{
303+
var draftsToDelete = availableDrafts.ToList();
304+
foreach (var draft in draftsToDelete)
305+
{
306+
await DraftService.DeleteDraftAsync(draft.Id);
307+
}
308+
309+
availableDrafts.Clear();
310+
showDraftDialog = false;
311+
showDraftRecovery = false;
312+
StateHasChanged();
313+
}
314+
315+
public async Task SaveDraftAsync()
316+
{
317+
if (!Model.HasSubstantialContent)
318+
{
319+
return;
320+
}
321+
322+
await UpdateSaveStatus(SaveStatus.Saving);
323+
324+
try
325+
{
326+
var draft = BlogPostDraft.FromCreateNewModel(Model, BlogPostId);
327+
328+
if (!string.IsNullOrEmpty(currentDraftId))
329+
{
330+
draft.Id = currentDraftId;
331+
}
332+
else
333+
{
334+
currentDraftId = draft.Id;
335+
}
336+
337+
await DraftService.SaveDraftAsync(draft);
338+
await UpdateSaveStatus(SaveStatus.Saved);
339+
}
340+
catch (Exception)
341+
{
342+
await UpdateSaveStatus(SaveStatus.Error);
343+
}
344+
}
345+
346+
public async Task ClearDraftAsync()
347+
{
348+
if (!string.IsNullOrEmpty(currentDraftId))
349+
{
350+
await DraftService.DeleteDraftAsync(currentDraftId);
351+
currentDraftId = null;
352+
}
353+
}
354+
355+
public void Dispose()
356+
{
357+
Model.PropertyChanged -= OnModelPropertyChanged;
358+
autoSaveTimer?.Dispose();
359+
}
360+
361+
private enum SaveStatus
362+
{
363+
None,
364+
Saving,
365+
Saved,
366+
Error
367+
}
368+
}

src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@
116116

117117
<FeatureInfoDialog @ref="FeatureDialog"></FeatureInfoDialog>
118118
<ShortCodeDialog @ref="ShortCodeDialog" ShortCodes="shortCodes"></ShortCodeDialog>
119+
<AutoSave @ref="AutoSaveComponent" Model="@model" BlogPostId="@BlogPost?.Id"></AutoSave>
119120

120121
<NavigationLock ConfirmExternalNavigation="@model.IsDirty" OnBeforeInternalNavigation="PreventNavigationWhenDirty"></NavigationLock>
121122
@code {
@@ -133,6 +134,7 @@
133134

134135
private FeatureInfoDialog FeatureDialog { get; set; } = default!;
135136
private ShortCodeDialog ShortCodeDialog { get; set; } = default!;
137+
private AutoSave AutoSaveComponent { get; set; } = default!;
136138

137139
private CreateNewModel model = new();
138140

@@ -163,6 +165,10 @@
163165
private async Task OnValidBlogPostCreatedAsync()
164166
{
165167
canSubmit = false;
168+
169+
// Clear the draft since we're successfully submitting
170+
await AutoSaveComponent.ClearDraftAsync();
171+
166172
await OnBlogPostCreated.InvokeAsync(model.ToBlogPost());
167173
if (model.ShouldInvalidateCache)
168174
{

0 commit comments

Comments
 (0)