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.
This commit is contained in:
Michal Vrba
2026-01-30 16:42:26 +01:00
parent 07d7eb7647
commit 9dcf25f6b8
4 changed files with 727 additions and 0 deletions
@@ -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
<PageTitle>Notemark - Edit Shared Document</PageTitle>
@if (isLoading)
{
<div class="share-loading">
<div class="loading-content">
<h2>Loading shared document...</h2>
</div>
</div>
}
else if (errorMessage != null)
{
<div class="share-error">
<div class="error-content">
<h2>⚠️ Unable to Load Document</h2>
<p>@errorMessage</p>
<button class="btn-home" @onclick="GoHome">Create New Document</button>
</div>
</div>
}
else
{
<div class="editor-container">
<!-- Top Bar -->
<div class="top-bar">
<div class="top-bar-left">
<h2 class="shared-title">Notemark</h2>
<span class="share-badge editable">Shared - Editable</span>
<button class="btn-toggle @(showPreview ? "active" : "")"
@onclick="TogglePreview">
@(showPreview ? "Hide Preview" : "Show Preview")
</button>
</div>
<div class="top-bar-center">
<span class="save-status @saveStatus.ToLower()">@saveStatus</span>
</div>
<div class="top-bar-right">
<button class="btn-view" @onclick="GoToPreview">👁️ View</button>
<button class="btn-home" @onclick="GoHome">New Doc</button>
</div>
</div>
<!-- Editor Layout -->
<div class="editor-layout @(showPreview ? "split-view" : "full-view")">
<!-- Editor Pane -->
<div class="editor-pane">
<textarea class="markdown-editor"
@bind="markdownContent"
@oninput="OnContentChanged"
@onkeydown="OnKeyDown"
placeholder="Start editing this shared document..."
spellcheck="true">
</textarea>
</div>
<!-- Preview Pane -->
@if (showPreview)
{
<div class="preview-pane">
<div class="preview-content">
@((MarkupString)renderedHtml)
</div>
</div>
}
</div>
</div>
}
@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();
}
}
@@ -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;
}
}
@@ -0,0 +1,138 @@
@page "/s/{token}"
@rendermode InteractiveServer
@using Notemark.Services
@inject IShareService ShareService
@inject ISessionService SessionService
@inject IMarkdownService MarkdownService
@inject NavigationManager Navigation
<PageTitle>Notemark - Shared Document</PageTitle>
@if (isLoading)
{
<div class="share-loading">
<div class="loading-content">
<h2>Loading shared document...</h2>
</div>
</div>
}
else if (errorMessage != null)
{
<div class="share-error">
<div class="error-content">
<h2>⚠️ Unable to Load Document</h2>
<p>@errorMessage</p>
<button class="btn-home" @onclick="GoHome">Create New Document</button>
</div>
</div>
}
else
{
<div class="shared-container">
<!-- Header Bar -->
<div class="shared-header">
<div class="header-left">
<h1>Notemark</h1>
<span class="share-badge readonly">View Only</span>
</div>
<div class="header-right">
@if (shareToken?.Mode == "editable")
{
<button class="btn-edit" @onclick="GoToEditor">✏️ Edit</button>
}
<button class="btn-home" @onclick="GoHome">Create New</button>
</div>
</div>
<!-- Content Area -->
<div class="shared-content">
@if (string.IsNullOrEmpty(markdownContent))
{
<div class="empty-content">
<h3>Empty Document</h3>
<p>This document doesn't have any content yet.</p>
</div>
}
else
{
<div class="preview-content">
@((MarkupString)renderedHtml)
</div>
}
</div>
</div>
}
@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("/");
}
}
@@ -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;
}
}