diff --git a/.gitignore b/.gitignore index 4da40ba..6f4ad01 100644 --- a/.gitignore +++ b/.gitignore @@ -362,3 +362,4 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd /Notemark/data/shares +/Notemark/data/sessions/b5acffbe-706e-405e-b006-3415508a59a7.md diff --git a/Notemark/Components/Pages/Editor.razor b/Notemark/Components/Pages/Editor.razor index eb46d67..6aa804d 100644 --- a/Notemark/Components/Pages/Editor.razor +++ b/Notemark/Components/Pages/Editor.razor @@ -24,6 +24,9 @@
+
@@ -128,7 +131,6 @@ { markdownContent = e.Value?.ToString() ?? ""; hasUnsavedChanges = true; - saveStatus = "Saving..."; // Update preview if visible if (showPreview) @@ -136,25 +138,24 @@ renderedHtml = MarkdownService.RenderToHtml(markdownContent); } - // Debounce auto-save + // Debounced autosave - cancel previous timer and start new one autoSaveTimer?.Dispose(); - autoSaveTimer = new Timer(async _ => await AutoSave(), null, 1000, Timeout.Infinite); - - await InvokeAsync(StateHasChanged); - } - - private async Task OnKeyDown(KeyboardEventArgs e) - { - // Ctrl+S or Cmd+S for manual save - if ((e.CtrlKey || e.MetaKey) && e.Key == "s") + autoSaveTimer = new Timer(async _ => { - await SaveContent(); + await InvokeAsync(async () => await AutoSave()); + }, null, 2000, Timeout.Infinite); // Increased to 2 seconds for production + + // Don't show "Saving..." immediately, wait for actual save + if (saveStatus == "Saved") + { + saveStatus = "Modified"; + await InvokeAsync(StateHasChanged); } } private async Task AutoSave() { - if (hasUnsavedChanges) + if (hasUnsavedChanges && !string.IsNullOrEmpty(sessionId)) { await SaveContent(); } @@ -162,18 +163,46 @@ private async Task SaveContent() { - if (string.IsNullOrEmpty(sessionId)) + if (string.IsNullOrEmpty(sessionId) || !hasUnsavedChanges) return; + + // Set saving status only when actually starting to save + saveStatus = "Saving..."; + await InvokeAsync(StateHasChanged); try { + // Longer timeout for large content (30 seconds) + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await SessionService.SaveSessionContentAsync(sessionId, markdownContent); + hasUnsavedChanges = false; saveStatus = "Saved"; } - catch + catch (OperationCanceledException) + { + saveStatus = "Timeout"; + // Retry after 5 seconds + _ = Task.Delay(5000).ContinueWith(async _ => + { + if (hasUnsavedChanges) + { + await InvokeAsync(async () => await SaveContent()); + } + }); + } + catch (Exception) { saveStatus = "Error"; + // Retry after 3 seconds + _ = Task.Delay(3000).ContinueWith(async _ => + { + if (hasUnsavedChanges) + { + await InvokeAsync(async () => await SaveContent()); + } + }); } await InvokeAsync(StateHasChanged); @@ -189,6 +218,20 @@ } } + private async Task OnKeyDown(KeyboardEventArgs e) + { + // Ctrl+S or Cmd+S for manual save + if ((e.CtrlKey || e.MetaKey) && e.Key == "s") + { + await ManualSave(); + } + } + + private async Task ManualSave() + { + await SaveContent(); + } + private async Task ToggleShareDropdown() { if (!showShareDropdown) diff --git a/Notemark/Components/Pages/Editor.razor.css b/Notemark/Components/Pages/Editor.razor.css index f583412..5365ff9 100644 --- a/Notemark/Components/Pages/Editor.razor.css +++ b/Notemark/Components/Pages/Editor.razor.css @@ -35,6 +35,7 @@ /* Buttons */ .btn-toggle, .btn-share, +.btn-save, .btn-copy, .btn-close { background: none; @@ -48,6 +49,33 @@ transition: all 0.2s ease; } +.btn-save { + padding: 0.5rem; + min-width: 2.5rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; +} + +.btn-save:enabled:hover { + background-color: var(--success); + color: white; + border-color: var(--success); + transform: translateY(-1px); +} + +.btn-save:disabled { + opacity: 0.5; + cursor: not-allowed; + background-color: transparent; +} + +.btn-save:disabled:hover { + background-color: transparent; + transform: none; +} + .btn-toggle:hover, .btn-share:hover, .btn-copy:hover { @@ -87,14 +115,22 @@ color: var(--success); } -.save-status.saving { +.save-status.modified { color: var(--secondary-text); } +.save-status.saving { + color: var(--accent); +} + .save-status.error { color: var(--error); } +.save-status.timeout { + color: var(--error); +} + /* Editor Layout */ .editor-layout { flex: 1; @@ -396,11 +432,18 @@ } .btn-toggle, - .btn-share { + .btn-share, + .btn-save { font-size: 0.75rem; padding: 0.375rem 0.75rem; } + .btn-save { + padding: 0.375rem; + min-width: 2rem; + font-size: 0.875rem; + } + .markdown-editor, .preview-content { padding: 1rem; diff --git a/Notemark/Components/Pages/SharedEditor.razor b/Notemark/Components/Pages/SharedEditor.razor index eefbefc..8d0cecf 100644 --- a/Notemark/Components/Pages/SharedEditor.razor +++ b/Notemark/Components/Pages/SharedEditor.razor @@ -141,7 +141,7 @@ else renderedHtml = MarkdownService.RenderToHtml(markdownContent); } } - catch (Exception ex) + catch (Exception) { errorMessage = "An error occurred while loading the document."; } diff --git a/Notemark/Components/Pages/SharedPreview.razor b/Notemark/Components/Pages/SharedPreview.razor index 7821b04..7ab8b05 100644 --- a/Notemark/Components/Pages/SharedPreview.razor +++ b/Notemark/Components/Pages/SharedPreview.razor @@ -113,7 +113,7 @@ else // Render to HTML renderedHtml = MarkdownService.RenderToHtml(markdownContent); } - catch (Exception ex) + catch (Exception) { errorMessage = "An error occurred while loading the document."; } diff --git a/Notemark/Services/SessionService.cs b/Notemark/Services/SessionService.cs index f159fa9..5fdecd7 100644 --- a/Notemark/Services/SessionService.cs +++ b/Notemark/Services/SessionService.cs @@ -1,3 +1,5 @@ +using System.Text; + namespace Notemark.Services; public class SessionService : ISessionService @@ -50,7 +52,49 @@ public class SessionService : ISessionService public async Task SaveSessionContentAsync(string sessionId, string content) { var filePath = GetSessionFilePath(sessionId); - await File.WriteAllTextAsync(filePath, content ?? string.Empty); + + // Create directory if it doesn't exist + var directory = Path.GetDirectoryName(filePath)!; + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + // For large content, use streaming to avoid memory issues + try + { + if (string.IsNullOrEmpty(content)) + { + await File.WriteAllTextAsync(filePath, string.Empty, Encoding.UTF8); + return; + } + + // Use FileStream for large content to avoid memory pressure + await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 8192, useAsync: true); + await using var writer = new StreamWriter(fileStream, Encoding.UTF8); + + // Write in chunks for very large content + if (content.Length > 50000) // > 50KB + { + const int chunkSize = 8192; + for (int i = 0; i < content.Length; i += chunkSize) + { + int length = Math.Min(chunkSize, content.Length - i); + await writer.WriteAsync(content.AsMemory(i, length)); + await writer.FlushAsync(); // Ensure data is written + } + } + else + { + await writer.WriteAsync(content); + } + } + catch (DirectoryNotFoundException) + { + // Retry after creating directory + Directory.CreateDirectory(directory); + await File.WriteAllTextAsync(filePath, content ?? string.Empty, Encoding.UTF8); + } } public async Task SessionExistsAsync(string sessionId) diff --git a/Notemark/data/sessions/b5acffbe-706e-405e-b006-3415508a59a7.md b/Notemark/data/sessions/b5acffbe-706e-405e-b006-3415508a59a7.md deleted file mode 100644 index 992cd91..0000000 --- a/Notemark/data/sessions/b5acffbe-706e-405e-b006-3415508a59a7.md +++ /dev/null @@ -1,22 +0,0 @@ -# Notemark — .NET 10 Blazor Server Specification (Markdown Editor + Preview + Share) - -## 0) Goal -Notemark is a minimalist Markdown editor web app built with **.NET 10 Blazor Server**. - -- First visit generates a **GUID sessionId** -- Markdown is saved as a **file on disk** (no database) -- Fullscreen editor, optional preview split view -- Share links: - - **Readonly** → preview only - - **Editable** → preview only + Edit button - ---- - -## 1) Tech Stack -- .NET 10 -- Blazor Server -- Markdown rendering: Markdig -- HTML sanitization: Ganss.XSS (HtmlSanitizer) -- Persistence: File system only - -