From 9dcf25f6b86bf1f26e39173582819d21d9aae4f2 Mon Sep 17 00:00:00 2001 From: Michal Vrba Date: Fri, 30 Jan 2026 16:42:26 +0100 Subject: [PATCH] Add shared document editor and preview components Introduced SharedEditor.razor and SharedPreview.razor for editing and viewing shared documents via tokenized links. Includes auto-save, markdown preview, permission checks, and dedicated responsive styles for both editable and view-only modes. Enables secure sharing of documents with user-friendly interfaces. --- Notemark/Components/Pages/SharedEditor.razor | 233 ++++++++++++++ .../Components/Pages/SharedEditor.razor.css | 73 +++++ Notemark/Components/Pages/SharedPreview.razor | 138 +++++++++ .../Components/Pages/SharedPreview.razor.css | 283 ++++++++++++++++++ 4 files changed, 727 insertions(+) create mode 100644 Notemark/Components/Pages/SharedEditor.razor create mode 100644 Notemark/Components/Pages/SharedEditor.razor.css create mode 100644 Notemark/Components/Pages/SharedPreview.razor create mode 100644 Notemark/Components/Pages/SharedPreview.razor.css diff --git a/Notemark/Components/Pages/SharedEditor.razor b/Notemark/Components/Pages/SharedEditor.razor new file mode 100644 index 0000000..7ca26c8 --- /dev/null +++ b/Notemark/Components/Pages/SharedEditor.razor @@ -0,0 +1,233 @@ +@page "/e/{token}" +@rendermode InteractiveServer +@using Notemark.Services +@inject IShareService ShareService +@inject ISessionService SessionService +@inject IMarkdownService MarkdownService +@inject NavigationManager Navigation +@inject IJSRuntime JSRuntime + +Notemark - Edit Shared Document + +@if (isLoading) +{ +
+
+

Loading shared document...

+
+
+} +else if (errorMessage != null) +{ +
+
+

⚠️ Unable to Load Document

+

@errorMessage

+ +
+
+} +else +{ +
+ +
+
+

Notemark

+ + +
+ +
+ @saveStatus +
+ +
+ + +
+
+ + +
+ +
+ +
+ + + @if (showPreview) + { +
+
+ @((MarkupString)renderedHtml) +
+
+ } +
+
+} + +@code { + [Parameter] public string Token { get; set; } = ""; + + private bool isLoading = true; + private string? errorMessage = null; + private ShareToken? shareToken = null; + private string markdownContent = ""; + private string renderedHtml = ""; + private bool showPreview = false; + private string saveStatus = "Saved"; + + // Auto-save timer + private Timer? autoSaveTimer; + private bool hasUnsavedChanges = false; + + protected override async Task OnInitializedAsync() + { + await LoadSharedDocument(); + } + + private async Task LoadSharedDocument() + { + try + { + isLoading = true; + + // Validate token + if (string.IsNullOrEmpty(Token)) + { + errorMessage = "Invalid share link."; + return; + } + + // Get share token info + shareToken = await ShareService.GetShareTokenAsync(Token); + if (shareToken == null) + { + errorMessage = "Share link not found or has expired."; + return; + } + + // Check if token is editable and valid + if (shareToken.Mode != "editable") + { + errorMessage = "This share link is view-only. You cannot edit this document."; + return; + } + + if (!await ShareService.IsValidTokenAsync(Token)) + { + errorMessage = "This share link has been revoked or has expired."; + return; + } + + // Load the document content + markdownContent = await SessionService.LoadSessionContentAsync(shareToken.SessionId); + + // Render initial preview if needed + if (showPreview) + { + renderedHtml = MarkdownService.RenderToHtml(markdownContent); + } + } + catch (Exception ex) + { + errorMessage = "An error occurred while loading the document."; + } + finally + { + isLoading = false; + await InvokeAsync(StateHasChanged); + } + } + + private async Task OnContentChanged(ChangeEventArgs e) + { + markdownContent = e.Value?.ToString() ?? ""; + hasUnsavedChanges = true; + saveStatus = "Saving..."; + + // Update preview if visible + if (showPreview) + { + renderedHtml = MarkdownService.RenderToHtml(markdownContent); + } + + // Debounce auto-save + 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") + { + await SaveContent(); + } + } + + private async Task AutoSave() + { + if (hasUnsavedChanges && shareToken != null) + { + await SaveContent(); + } + } + + private async Task SaveContent() + { + if (shareToken == null) + return; + + try + { + await SessionService.SaveSessionContentAsync(shareToken.SessionId, markdownContent); + hasUnsavedChanges = false; + saveStatus = "Saved"; + } + catch + { + saveStatus = "Error"; + } + + await InvokeAsync(StateHasChanged); + } + + private void TogglePreview() + { + showPreview = !showPreview; + + if (showPreview) + { + renderedHtml = MarkdownService.RenderToHtml(markdownContent); + } + } + + private void GoToPreview() + { + Navigation.NavigateTo($"/s/{Token}"); + } + + private void GoHome() + { + Navigation.NavigateTo("/"); + } + + public void Dispose() + { + autoSaveTimer?.Dispose(); + } +} \ No newline at end of file diff --git a/Notemark/Components/Pages/SharedEditor.razor.css b/Notemark/Components/Pages/SharedEditor.razor.css new file mode 100644 index 0000000..f32c08f --- /dev/null +++ b/Notemark/Components/Pages/SharedEditor.razor.css @@ -0,0 +1,73 @@ +/* Shared Editor Styles - Extends main editor styles */ + +.shared-title { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + color: var(--accent); +} + +.top-bar-left { + display: flex; + align-items: center; + gap: 1rem; +} + +.share-badge { + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.share-badge.editable { + background-color: var(--success); + color: white; +} + +.btn-view { + background: none; + border: 1px solid var(--border); + color: var(--primary-text); + padding: 0.5rem 1rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; + font-family: inherit; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.btn-view:hover { + background-color: var(--accent); + color: white; + border-color: var(--accent); +} + +/* Mobile adjustments for shared editor */ +@media (max-width: 768px) { + .top-bar-left { + flex-wrap: wrap; + gap: 0.5rem; + } + + .shared-title { + font-size: 1rem; + } + + .share-badge { + font-size: 0.6rem; + padding: 0.2rem 0.5rem; + } + + .btn-toggle, + .btn-view, + .btn-home { + font-size: 0.75rem; + padding: 0.375rem 0.75rem; + } +} \ No newline at end of file diff --git a/Notemark/Components/Pages/SharedPreview.razor b/Notemark/Components/Pages/SharedPreview.razor new file mode 100644 index 0000000..9713437 --- /dev/null +++ b/Notemark/Components/Pages/SharedPreview.razor @@ -0,0 +1,138 @@ +@page "/s/{token}" +@rendermode InteractiveServer +@using Notemark.Services +@inject IShareService ShareService +@inject ISessionService SessionService +@inject IMarkdownService MarkdownService +@inject NavigationManager Navigation + +Notemark - Shared Document + +@if (isLoading) +{ +
+
+

Loading shared document...

+
+
+} +else if (errorMessage != null) +{ +
+
+

⚠️ Unable to Load Document

+

@errorMessage

+ +
+
+} +else +{ +
+ +
+
+

Notemark

+ +
+ +
+ @if (shareToken?.Mode == "editable") + { + + } + +
+
+ + +
+ @if (string.IsNullOrEmpty(markdownContent)) + { +
+

Empty Document

+

This document doesn't have any content yet.

+
+ } + else + { +
+ @((MarkupString)renderedHtml) +
+ } +
+
+} + +@code { + [Parameter] public string Token { get; set; } = ""; + + private bool isLoading = true; + private string? errorMessage = null; + private ShareToken? shareToken = null; + private string markdownContent = ""; + private string renderedHtml = ""; + + protected override async Task OnInitializedAsync() + { + await LoadSharedDocument(); + } + + private async Task LoadSharedDocument() + { + try + { + isLoading = true; + + // Validate token + if (string.IsNullOrEmpty(Token)) + { + errorMessage = "Invalid share link."; + return; + } + + // Get share token info + shareToken = await ShareService.GetShareTokenAsync(Token); + if (shareToken == null) + { + errorMessage = "Share link not found or has expired."; + return; + } + + // Check if token is still valid + if (!await ShareService.IsValidTokenAsync(Token)) + { + errorMessage = "This share link has been revoked or has expired."; + return; + } + + // Load the document content + markdownContent = await SessionService.LoadSessionContentAsync(shareToken.SessionId); + + // Render to HTML + renderedHtml = MarkdownService.RenderToHtml(markdownContent); + } + catch (Exception ex) + { + errorMessage = "An error occurred while loading the document."; + } + finally + { + isLoading = false; + await InvokeAsync(StateHasChanged); + } + } + + private void GoToEditor() + { + if (shareToken?.Mode == "editable") + { + Navigation.NavigateTo($"/e/{Token}"); + } + } + + private void GoHome() + { + Navigation.NavigateTo("/"); + } +} \ No newline at end of file diff --git a/Notemark/Components/Pages/SharedPreview.razor.css b/Notemark/Components/Pages/SharedPreview.razor.css new file mode 100644 index 0000000..d05cc5f --- /dev/null +++ b/Notemark/Components/Pages/SharedPreview.razor.css @@ -0,0 +1,283 @@ +/* Shared Document Styles */ + +.share-loading, +.share-error { + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bg); +} + +.loading-content, +.error-content { + text-align: center; + color: var(--primary-text); +} + +.loading-content h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + color: var(--secondary-text); +} + +.error-content h2 { + margin: 0 0 1rem 0; + font-size: 1.5rem; + font-weight: 600; + color: var(--error); +} + +.error-content p { + margin: 0 0 2rem 0; + color: var(--secondary-text); + font-size: 1.1rem; +} + +.shared-container { + height: 100vh; + display: flex; + flex-direction: column; + background-color: var(--bg); +} + +/* Header */ +.shared-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 2rem; + background-color: var(--surface); + border-bottom: 2px solid var(--border); + flex-shrink: 0; +} + +.header-left { + display: flex; + align-items: center; + gap: 1rem; +} + +.header-left h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: var(--accent); +} + +.share-badge { + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.share-badge.readonly { + background-color: var(--secondary-text); + color: white; +} + +.share-badge.editable { + background-color: var(--success); + color: white; +} + +.header-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* Buttons */ +.btn-edit, +.btn-home { + padding: 0.5rem 1rem; + border: 1px solid var(--border); + border-radius: 6px; + background-color: var(--surface); + color: var(--primary-text); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.btn-edit { + background-color: var(--accent); + color: white; + border-color: var(--accent); +} + +.btn-edit:hover { + background-color: var(--accent-hover); + border-color: var(--accent-hover); +} + +.btn-home:hover { + background-color: var(--accent); + color: white; + border-color: var(--accent); +} + +/* Content Area */ +.shared-content { + flex: 1; + overflow-y: auto; + background-color: var(--surface); +} + +.empty-content { + padding: 4rem 2rem; + text-align: center; + color: var(--secondary-text); +} + +.empty-content h3 { + margin: 0 0 1rem 0; + font-size: 1.5rem; + font-weight: 600; +} + +.empty-content p { + margin: 0; + font-size: 1.1rem; +} + +.preview-content { + max-width: 800px; + margin: 0 auto; + padding: 3rem 2rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + line-height: 1.6; +} + +/* Markdown Preview Styles (same as editor) */ +.preview-content h1, +.preview-content h2, +.preview-content h3, +.preview-content h4, +.preview-content h5, +.preview-content h6 { + color: var(--primary-text); + margin-top: 2em; + margin-bottom: 1em; + font-weight: 600; +} + +.preview-content h1 { font-size: 2.5em; margin-top: 0; } +.preview-content h2 { font-size: 2em; } +.preview-content h3 { font-size: 1.5em; } + +.preview-content p { + margin-bottom: 1.5em; + color: var(--primary-text); + font-size: 1.1em; +} + +.preview-content code { + background-color: var(--bg); + padding: 0.2em 0.4em; + border-radius: 3px; + font-family: 'JetBrains Mono', 'SF Mono', 'Monaco', monospace; + font-size: 0.9em; + color: var(--accent); +} + +.preview-content pre { + background-color: var(--bg); + padding: 1.5em; + border-radius: 8px; + overflow-x: auto; + margin-bottom: 1.5em; + border: 1px solid var(--border); +} + +.preview-content pre code { + background: none; + padding: 0; + color: var(--primary-text); +} + +.preview-content blockquote { + border-left: 4px solid var(--accent); + padding-left: 1.5em; + margin-left: 0; + margin-bottom: 1.5em; + color: var(--secondary-text); + font-style: italic; +} + +.preview-content ul, +.preview-content ol { + margin-bottom: 1.5em; + padding-left: 2em; +} + +.preview-content li { + margin-bottom: 0.5em; +} + +.preview-content table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1.5em; +} + +.preview-content th, +.preview-content td { + border: 1px solid var(--border); + padding: 0.75em 1em; + text-align: left; +} + +.preview-content th { + background-color: var(--bg); + font-weight: 600; +} + +.preview-content img { + max-width: 100%; + height: auto; + border-radius: 6px; +} + +.preview-content hr { + border: none; + border-top: 2px solid var(--border); + margin: 3em 0; +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .shared-header { + padding: 1rem; + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .header-left { + justify-content: center; + } + + .header-right { + justify-content: center; + } + + .preview-content { + padding: 2rem 1rem; + } + + .btn-edit, + .btn-home { + flex: 1; + justify-content: center; + } +} \ No newline at end of file