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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user