fixes and improvements

This commit is contained in:
Michal Vrba
2026-09-22 12:07:05 +02:00
parent efc31f643d
commit 29696cea9f
25 changed files with 4099 additions and 747 deletions
+35 -17
View File
@@ -1,5 +1,20 @@
<!DOCTYPE html>
<html lang="en">
@inject IHttpContextAccessor HttpContextAccessor
@{
var initialTheme = "light";
try
{
var cookie = HttpContextAccessor.HttpContext?.Request.Cookies["notemark_theme"];
if (!string.IsNullOrEmpty(cookie) && (cookie == "dark" || cookie == "light"))
{
initialTheme = cookie;
}
}
catch { }
}
<!DOCTYPE html>
<html lang="en" data-theme="@initialTheme">
<head>
<meta charset="utf-8" />
@@ -9,8 +24,24 @@
<meta name="apple-mobile-web-app-title" content="Notemark" />
<meta name="mobile-web-app-capable" content="yes" />
<base href="/" />
<!-- Immediate theme application to prevent FOUC -->
<script>
(function() {
try {
var cookieMatch = document.cookie.match(/(?:^|; )notemark_theme=([^;]*)/);
var saved = (cookieMatch ? cookieMatch[1] : null) || localStorage.getItem('notemark-theme');
var theme = saved || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="lib/prism/prism.min.css" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["Notemark.styles.css"]" />
<ImportMap />
@@ -32,22 +63,9 @@
<Routes />
<ReconnectModal />
<script src="js/theme.js"></script>
<script src="lib/prism/prism.min.js"></script>
<script src="js/editor.js"></script>
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script>
// Initialize theme on page load
document.addEventListener('DOMContentLoaded', function() {
notemarkTheme.init();
});
// Force Long Polling if WebSockets fail (temporary fix)
Blazor.start({
configureSignalR: function (builder) {
builder.withUrl("/_blazor", {
transport: 1 // LongPolling = 1, WebSockets = 4
});
}
});
</script>
</body>
</html>
+594 -178
View File
@@ -1,38 +1,142 @@
@page "/"
@page "/editor"
@page "/editor/{DocumentId}"
@page "/doc/{DocumentId}"
@rendermode InteractiveServer
@implements IDisposable
@using Notemark.Services
@using Notemark.Models
@inject ISessionService SessionService
@inject IDocumentService DocumentService
@inject IMarkdownService MarkdownService
@inject IShareService ShareService
@inject IJSRuntime JSRuntime
@inject IHttpContextAccessor HttpContextAccessor
@inject NavigationManager Navigation
<PageTitle>Notemark</PageTitle>
<PageTitle>@(string.IsNullOrWhiteSpace(documentTitle) ? "Notemark" : $"{documentTitle} - Notemark")</PageTitle>
<div class="editor-container">
<!-- Top Bar -->
<div class="top-bar">
<div class="top-bar-left">
<button class="btn-new" @onclick="NewDocument" title="Create new document (Ctrl+N)">
📄 New
<button class="btn-topbar btn-home-nav" @onclick="GoToHome" title="Back to Home Dashboard">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
<span class="btn-text">Home</span>
</button>
<button class="btn-toggle @(showPreview ? "active" : "")"
@onclick="TogglePreview">
👁️ @(showPreview ? "Hide Preview" : "Show Preview")
<button class="btn-topbar btn-notes" @onclick="ToggleNotesDrawer" title="View all & flagged notes">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
<span class="btn-text">Notes</span>
@if (flaggedCount > 0)
{
<span class="notes-badge">@flaggedCount</span>
}
</button>
<button class="btn-topbar btn-new" @onclick="NewDocument" title="Create new document (Ctrl+N)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span class="btn-text">New</span>
</button>
<button class="btn-topbar btn-flag @(isFlagged ? "flagged" : "")" @onclick="ToggleFlag" title="@(isFlagged ? "Flagged (Click to unflag)" : "Flag this note")">
<span>@(isFlagged ? "🚩" : "🏳️")</span>
</button>
<div class="doc-title-wrapper" title="Document Title">
<input class="doc-title-input"
type="text"
placeholder="Untitled Note..."
@bind="documentTitle"
@bind:event="oninput"
@onchange="OnTitleChanged" />
</div>
<!-- Tags in top bar -->
@if (currentTags.Any())
{
<div class="topbar-tags">
@foreach (var tag in currentTags.Take(3))
{
<span class="topbar-tag-pill">#@tag</span>
}
</div>
}
</div>
<div class="top-bar-center">
<span class="save-status @saveStatus.ToLower()">@saveStatus</span>
<span class="doc-stats" title="Words • Characters • Reading Time">
@wordCount words &bull; @charCount chars &bull; @readingTime min read
</span>
</div>
<div class="top-bar-right">
<button class="btn-save" @onclick="ManualSave" title="Save document (Ctrl+S)" disabled="@(!hasUnsavedChanges)">
💾
<button class="btn-topbar btn-toggle @(showPreview ? "active" : "")" @onclick="TogglePreview" title="Toggle Live Preview">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span class="btn-text">@(showPreview ? "Hide Preview" : "Preview")</span>
</button>
<button class="btn-topbar btn-save" @onclick="ManualSave" title="Save document (Ctrl+S)" disabled="@(!hasUnsavedChanges)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
</button>
<!-- Export / Actions Dropdown -->
<div class="export-container">
<button class="btn-topbar btn-export" @onclick="ToggleExportDropdown" title="Export & Print Options">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span class="btn-text">Export</span>
</button>
@if (showExportDropdown)
{
<div class="dropdown-backdrop" @onclick="() => showExportDropdown = false"></div>
<div class="export-dropdown">
<button class="export-menu-item" @onclick="ExportMarkdown">
<span>📄 Download Markdown (.md)</span>
</button>
<button class="export-menu-item" @onclick="ExportHtml">
<span>🌐 Download Styled HTML</span>
</button>
<button class="export-menu-item" @onclick="PrintDocument">
<span>🖨️ Print / Save as PDF</span>
</button>
</div>
}
</div>
<!-- Share Dropdown -->
<div class="share-container">
<button class="btn-share" @onclick="ToggleShareDropdown">Share</button>
<button class="btn-topbar btn-share" @onclick="ToggleShareDropdown">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="18" cy="5" r="3"></circle>
<circle cx="6" cy="12" r="3"></circle>
<circle cx="18" cy="19" r="3"></circle>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
</svg>
<span class="btn-text">Share</span>
</button>
@if (showShareDropdown)
{
<div class="dropdown-backdrop" @onclick="CloseShareDropdown"></div>
@@ -47,123 +151,361 @@
<label>View Only</label>
<div class="link-group">
<input type="text" readonly value="@readonlyLink" class="link-field" />
<button @onclick="() => CopyToClipboard(readonlyLink)" class="btn-copy-small">📋</button>
<button @onclick="() => CopyToClipboard(readonlyLink)" class="btn-copy-small" title="Copy view link">
@(copiedLink == readonlyLink ? "✓" : "📋")
</button>
</div>
</div>
<div class="share-item">
<label>Can Edit</label>
<div class="link-group">
<input type="text" readonly value="@editableLink" class="link-field" />
<button @onclick="() => CopyToClipboard(editableLink)" class="btn-copy-small">📋</button>
<button @onclick="() => CopyToClipboard(editableLink)" class="btn-copy-small" title="Copy edit link">
@(copiedLink == editableLink ? "✓" : "📋")
</button>
</div>
</div>
}
</div>
}
</div>
<ThemeToggle />
</div>
</div>
<!-- Format Toolbar with Undo / Redo -->
<div class="format-toolbar">
<button type="button" class="toolbar-btn" @onclick="Undo" disabled="@(!canUndo)" title="Undo (Ctrl+Z)">
↩️
</button>
<button type="button" class="toolbar-btn" @onclick="Redo" disabled="@(!canRedo)" title="Redo (Ctrl+Y)">
↪️
</button>
<span class="toolbar-divider"></span>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("**", "**", "bold text")' title="Bold (Ctrl+B)">
<strong>B</strong>
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("*", "*", "italic text")' title="Italic (Ctrl+I)">
<em>I</em>
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("### ", "", "Heading")' title="Heading 3">
H3
</button>
<span class="toolbar-divider"></span>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("`", "`", "code")' title="Inline Code">
<code>&lt;/&gt;</code>
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("```csharp\n", "\n```", "// Write code here")' title="Code Block">
<code>{ }</code>
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("```mermaid\ngraph TD;\n A[Start] --> B(Process);\n B --> C{Done?};\n C -->|Yes| D[End];\n", "\n```", "")' title="Mermaid Diagram">
📊 Diagram
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("[", "](https://example.com)", "link title")' title="Insert Link (Ctrl+K)">
🔗 Link
</button>
<span class="toolbar-divider"></span>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("- [ ] ", "", "Task item")' title="Task checkbox">
☑️ Task
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("- ", "", "List item")' title="Bullet List">
&bull; List
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("> ", "", "Quote text")' title="Blockquote">
❝ Quote
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("> [!NOTE]\n> ", "", "Helpful information here")' title="Note Callout">
💡 Note
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("| Column 1 | Column 2 |\n|---|---|\n| Cell 1 | Cell 2 |", "", "")' title="Table">
📋 Table
</button>
</div>
<!-- Editor Layout -->
<div class="editor-layout @(showPreview ? "split-view" : "full-view")" @ref="editorLayoutRef">
<!-- Editor Pane -->
<div class="editor-pane" style="@GetEditorPaneStyle()">
<textarea class="markdown-editor"
id="notemark-editor-textarea"
@ref="editorTextareaRef"
@bind="markdownContent"
@oninput="OnContentChanged"
@onkeydown="OnKeyDown"
placeholder="Start typing your markdown..."
placeholder="Start typing your markdown... Use # for headings, **bold**, *italic*, `code`, tables, task lists, and paste images (Ctrl+V)..."
spellcheck="true">
</textarea>
</div>
<!-- Simple Splitter -->
<!-- Splitter -->
@if (showPreview)
{
<div class="splitter @(isDragging ? "dragging" : "")" @onmousedown="OnSplitterMouseDown" @onmousedown:preventDefault="true">
<div class="splitter-handle"></div>
</div>
}
<!-- Preview Pane -->
@if (showPreview)
{
<div class="preview-pane" style="@GetPreviewPaneStyle()">
<div class="preview-content">
<div class="preview-header-bar">
<span class="preview-label">Live Preview</span>
<div class="preview-zoom-controls" title="Zoom: Scroll wheel over controls, or Ctrl + Scroll over preview">
<button type="button" class="btn-zoom" @onclick="ZoomOut" title="Zoom out (min 70%)" disabled="@(previewZoom <= 70)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<button type="button" class="btn-zoom-reset" @onclick="ResetZoom" title="Reset zoom to 100% (or Ctrl+Scroll to zoom)">
@(previewZoom)%
</button>
<button type="button" class="btn-zoom" @onclick="ZoomIn" title="Zoom in (max 200%)" disabled="@(previewZoom >= 200)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
</div>
<div class="preview-content" style="@GetPreviewContentStyle()">
@((MarkupString)renderedHtml)
</div>
</div>
}
</div>
<!-- Share Modal -->
@* Removed modal - now using dropdown *@
<!-- Notes Drawer Modal -->
<NotesDrawer IsOpen="@showNotesDrawer"
IsOpenChanged="@((val) => showNotesDrawer = val)"
CurrentDocumentId="@sessionId"
OnSelectDocument="@OnDocumentSelected" />
</div>
@code {
[Parameter] public string? DocumentId { get; set; }
private string sessionId = "";
private string documentTitle = "Untitled Note";
private bool isFlagged = false;
private int flaggedCount = 0;
private List<string> currentTags = new();
private string markdownContent = "";
private string renderedHtml = "";
private bool showPreview = false;
private bool showPreview = true;
private string saveStatus = "Saved";
private bool showNotesDrawer = false;
private bool showExportDropdown = false;
private ElementReference editorTextareaRef;
private ElementReference editorLayoutRef;
private DotNetObjectReference<Editor>? dotNetRef;
private double splitRatio = 0.5;
private bool isDragging = false;
private int previewZoom = 100;
// Undo / Redo History Stack
private readonly List<string> undoStack = new();
private readonly List<string> redoStack = new();
private bool canUndo => undoStack.Count > 0;
private bool canRedo => redoStack.Count > 0;
// Word stats
private int wordCount = 0;
private int charCount = 0;
private int readingTime = 1;
// Share dropdown state
private bool showShareDropdown = false;
private bool isGeneratingLinks = false;
private string readonlyLink = "";
private string editableLink = "";
private string copiedLink = "";
// Auto-save timer
private Timer? autoSaveTimer;
private bool hasUnsavedChanges = false;
protected override async Task OnInitializedAsync()
{
// Get or create session
var httpContext = HttpContextAccessor.HttpContext;
if (httpContext != null)
if (!string.IsNullOrEmpty(DocumentId))
{
sessionId = DocumentId;
}
else if (httpContext != null)
{
sessionId = SessionService.GetOrCreateSessionId(httpContext);
// Load existing content
markdownContent = await SessionService.LoadSessionContentAsync(sessionId);
// Render initial preview if needed
if (showPreview)
{
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
}
}
else
{
sessionId = Guid.NewGuid().ToString();
}
await LoadDocumentData();
}
private async Task OnContentChanged(ChangeEventArgs e)
private async Task LoadDocumentData()
{
markdownContent = e.Value?.ToString() ?? "";
hasUnsavedChanges = true;
// Update preview if visible
markdownContent = await SessionService.LoadSessionContentAsync(sessionId);
var meta = await DocumentService.GetDocumentAsync(sessionId);
if (meta != null)
{
documentTitle = meta.Title;
isFlagged = meta.IsFlagged;
currentTags = meta.Tags;
}
else
{
documentTitle = DocumentService.ExtractTitle(markdownContent);
isFlagged = false;
currentTags = DocumentService.ExtractTags(markdownContent);
}
undoStack.Clear();
redoStack.Clear();
UpdateStatistics();
if (showPreview)
{
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
}
// Debounced autosave - cancel previous timer and start new one
autoSaveTimer?.Dispose();
autoSaveTimer = new Timer(async _ =>
await UpdateFlaggedCount();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await InvokeAsync(async () => await AutoSave());
}, null, 2000, Timeout.Infinite); // Increased to 2 seconds for production
// Only update status if it was previously "Saved"
if (saveStatus == "Saved")
try
{
// Save current document ID to localStorage as the last worked on note on this device
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "notemark-last-doc-id", sessionId);
// Initialize Tab handling, image paste/drop, and scroll sync
await JSRuntime.InvokeVoidAsync("notemarkEditor.initTextarea", editorTextareaRef);
// Load saved split ratio
var savedRatio = await JSRuntime.InvokeAsync<string?>("localStorage.getItem", "notemark-split-ratio");
if (!string.IsNullOrEmpty(savedRatio) && double.TryParse(savedRatio, out var ratio))
{
splitRatio = Math.Max(0.2, Math.Min(0.8, ratio));
StateHasChanged();
}
// Load saved preview zoom
var savedZoom = await JSRuntime.InvokeAsync<string?>("localStorage.getItem", "notemark-preview-zoom");
if (!string.IsNullOrEmpty(savedZoom) && int.TryParse(savedZoom, out var z))
{
previewZoom = Math.Clamp(z, 70, 200);
StateHasChanged();
}
// Attach mouse wheel zoom listener (Ctrl + Wheel over preview or direct scroll over zoom controls)
dotNetRef ??= DotNetObjectReference.Create(this);
await JSRuntime.InvokeVoidAsync("notemarkEditor.attachPreviewZoomWheel", ".preview-pane", dotNetRef);
}
catch { }
}
// Highlight code and render Mermaid in preview
if (showPreview && !string.IsNullOrEmpty(renderedHtml))
{
saveStatus = "Modified";
try
{
await JSRuntime.InvokeVoidAsync("notemarkEditor.highlightPreview", ".preview-content");
}
catch { }
}
}
private void UpdateStatistics()
{
charCount = markdownContent.Length;
var words = markdownContent.Split(new[] { ' ', '\r', '\n', '\t' }, StringSplitOptions.RemoveEmptyEntries);
wordCount = words.Length;
readingTime = Math.Max(1, (int)Math.Ceiling(wordCount / 200.0));
currentTags = DocumentService.ExtractTags(markdownContent);
}
private async Task OnContentChanged(ChangeEventArgs e)
{
var newContent = e.Value?.ToString() ?? "";
if (newContent != markdownContent)
{
// Push to undo stack (max 50 states)
if (undoStack.Count > 50) undoStack.RemoveAt(0);
undoStack.Add(markdownContent);
redoStack.Clear();
markdownContent = newContent;
hasUnsavedChanges = true;
UpdateStatistics();
if (documentTitle == "Untitled Note" || string.IsNullOrWhiteSpace(documentTitle))
{
documentTitle = DocumentService.ExtractTitle(markdownContent);
}
if (showPreview)
{
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
}
// Debounced autosave
autoSaveTimer?.Dispose();
autoSaveTimer = new Timer(async _ =>
{
await InvokeAsync(async () => await AutoSave());
}, null, 1500, Timeout.Infinite);
if (saveStatus == "Saved")
{
saveStatus = "Modified";
StateHasChanged();
}
}
}
private void Undo()
{
if (canUndo)
{
var prev = undoStack[^1];
undoStack.RemoveAt(undoStack.Count - 1);
redoStack.Add(markdownContent);
markdownContent = prev;
hasUnsavedChanges = true;
UpdateStatistics();
if (showPreview) renderedHtml = MarkdownService.RenderToHtml(markdownContent);
StateHasChanged();
}
}
private void Redo()
{
if (canRedo)
{
var next = redoStack[^1];
redoStack.RemoveAt(redoStack.Count - 1);
undoStack.Add(markdownContent);
markdownContent = next;
hasUnsavedChanges = true;
UpdateStatistics();
if (showPreview) renderedHtml = MarkdownService.RenderToHtml(markdownContent);
StateHasChanged();
}
}
private async Task OnTitleChanged(ChangeEventArgs e)
{
documentTitle = e.Value?.ToString() ?? "Untitled Note";
hasUnsavedChanges = true;
await SaveContent();
}
private async Task AutoSave()
{
if (hasUnsavedChanges && !string.IsNullOrEmpty(sessionId))
@@ -174,61 +516,68 @@
private async Task SaveContent()
{
if (string.IsNullOrEmpty(sessionId) || !hasUnsavedChanges)
return;
if (string.IsNullOrEmpty(sessionId)) return;
var previousStatus = saveStatus;
// Set saving status only when actually starting to save
saveStatus = "Saving...";
StateHasChanged(); // Use StateHasChanged instead of InvokeAsync
StateHasChanged();
try
{
// Longer timeout for large content (30 seconds)
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await SessionService.SaveSessionContentAsync(sessionId, markdownContent);
await DocumentService.SaveDocumentAsync(sessionId, markdownContent, isFlagged, documentTitle, currentTags);
hasUnsavedChanges = false;
saveStatus = "Saved";
await UpdateFlaggedCount();
}
catch (OperationCanceledException)
{
saveStatus = "Timeout";
// Simplified retry - no immediate re-rendering
_ = Task.Delay(5000).ContinueWith(async _ =>
{
if (hasUnsavedChanges)
{
await InvokeAsync(async () => await SaveContent());
}
});
}
catch (Exception)
catch
{
saveStatus = "Error";
// Simplified retry - no immediate re-rendering
_ = Task.Delay(3000).ContinueWith(async _ =>
{
if (hasUnsavedChanges)
{
await InvokeAsync(async () => await SaveContent());
}
});
}
// Only trigger re-render if status actually changed
if (saveStatus != previousStatus)
StateHasChanged();
}
private async Task ToggleFlag()
{
isFlagged = await DocumentService.ToggleFlagAsync(sessionId);
await UpdateFlaggedCount();
}
private async Task UpdateFlaggedCount()
{
try
{
StateHasChanged();
var flagged = await DocumentService.GetFlaggedDocumentsAsync();
flaggedCount = flagged.Count;
}
catch { }
}
private void ToggleNotesDrawer()
{
showNotesDrawer = !showNotesDrawer;
}
private void ToggleExportDropdown()
{
showExportDropdown = !showExportDropdown;
}
private async Task OnDocumentSelected(string docId)
{
if (hasUnsavedChanges)
{
await SaveContent();
}
sessionId = docId;
await LoadDocumentData();
StateHasChanged();
}
private void TogglePreview()
{
showPreview = !showPreview;
if (showPreview)
{
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
@@ -237,16 +586,38 @@
private async Task OnKeyDown(KeyboardEventArgs e)
{
// Ctrl+S or Cmd+S for manual save
if ((e.CtrlKey || e.MetaKey) && e.Key == "s")
if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("s", StringComparison.OrdinalIgnoreCase))
{
await ManualSave();
}
// Ctrl+N or Cmd+N for new document
else if ((e.CtrlKey || e.MetaKey) && e.Key == "n")
else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("n", StringComparison.OrdinalIgnoreCase))
{
await NewDocument();
}
else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("z", StringComparison.OrdinalIgnoreCase) && !e.ShiftKey)
{
Undo();
}
else if ((e.CtrlKey || e.MetaKey) && (e.Key.Equals("y", StringComparison.OrdinalIgnoreCase) || (e.Key.Equals("z", StringComparison.OrdinalIgnoreCase) && e.ShiftKey)))
{
Redo();
}
else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("b", StringComparison.OrdinalIgnoreCase))
{
await InsertFormatting("**", "**", "bold text");
}
else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("i", StringComparison.OrdinalIgnoreCase))
{
await InsertFormatting("*", "*", "italic text");
}
else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("k", StringComparison.OrdinalIgnoreCase))
{
await InsertFormatting("[", "](https://example.com)", "link text");
}
else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("p", StringComparison.OrdinalIgnoreCase))
{
await PrintDocument();
}
}
private async Task ManualSave()
@@ -256,34 +627,59 @@
private async Task NewDocument()
{
// Ask for confirmation if there are unsaved changes
if (hasUnsavedChanges)
{
bool confirmed = await JSRuntime.InvokeAsync<bool>("confirm",
"You have unsaved changes. Are you sure you want to create a new document?");
if (!confirmed)
return;
await SaveContent();
}
// Simple approach: Clear current session content
markdownContent = "";
renderedHtml = "";
sessionId = Guid.NewGuid().ToString();
markdownContent = "# Untitled Note\n\nStart typing here... #ideas";
documentTitle = "Untitled Note";
isFlagged = false;
hasUnsavedChanges = false;
saveStatus = "New Document";
// Save empty content to current session
if (!string.IsNullOrEmpty(sessionId))
{
await SessionService.SaveSessionContentAsync(sessionId, "");
}
// Update UI
await InvokeAsync(StateHasChanged);
// Reset save status after a moment
await Task.Delay(1500);
saveStatus = "Saved";
await InvokeAsync(StateHasChanged);
undoStack.Clear();
redoStack.Clear();
UpdateStatistics();
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
await DocumentService.SaveDocumentAsync(sessionId, markdownContent, isFlagged, documentTitle, currentTags);
await SessionService.SaveSessionContentAsync(sessionId, markdownContent);
await UpdateFlaggedCount();
StateHasChanged();
}
private async Task InsertFormatting(string prefix, string suffix, string defaultText)
{
try
{
await JSRuntime.InvokeVoidAsync("notemarkEditor.wrapSelection", editorTextareaRef, prefix, suffix, defaultText);
}
catch { }
}
private async Task ExportMarkdown()
{
showExportDropdown = false;
var filename = $"{documentTitle.Replace(" ", "_").ToLowerInvariant()}.md";
await JSRuntime.InvokeVoidAsync("notemarkEditor.downloadMarkdown", filename, markdownContent);
}
private async Task ExportHtml()
{
showExportDropdown = false;
var filename = $"{documentTitle.Replace(" ", "_").ToLowerInvariant()}.html";
var currentTheme = await JSRuntime.InvokeAsync<string>("notemarkTheme.getCurrentTheme");
await JSRuntime.InvokeVoidAsync("notemarkEditor.downloadHtml", filename, documentTitle, renderedHtml, currentTheme);
}
private async Task PrintDocument()
{
showExportDropdown = false;
await JSRuntime.InvokeVoidAsync("notemarkEditor.printDocument");
}
private async Task ToggleShareDropdown()
@@ -294,23 +690,21 @@
isGeneratingLinks = true;
readonlyLink = "";
editableLink = "";
await InvokeAsync(StateHasChanged);
StateHasChanged();
try
{
// Generate share tokens
var readonlyToken = await ShareService.CreateShareTokenAsync(sessionId, "readonly");
var editableToken = await ShareService.CreateShareTokenAsync(sessionId, "editable");
// Build URLs
var httpContext = HttpContextAccessor.HttpContext;
if (httpContext != null)
{
var baseUrl = $"{httpContext.Request.Scheme}://{httpContext.Request.Host}";
readonlyLink = $"{baseUrl}/s/{readonlyToken}";
editableLink = $"{baseUrl}/e/{editableToken}";
}
var baseUrl = httpContext != null
? $"{httpContext.Request.Scheme}://{httpContext.Request.Host}"
: Navigation.BaseUri.TrimEnd('/');
readonlyLink = $"{baseUrl}/s/{readonlyToken}";
editableLink = $"{baseUrl}/e/{editableToken}";
}
catch
{
@@ -320,7 +714,7 @@
finally
{
isGeneratingLinks = false;
await InvokeAsync(StateHasChanged);
StateHasChanged();
}
}
else
@@ -332,6 +726,7 @@
private void CloseShareDropdown()
{
showShareDropdown = false;
copiedLink = "";
}
private async Task CopyToClipboard(string text)
@@ -339,46 +734,20 @@
try
{
await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", text);
// Could add a toast notification here in the future
copiedLink = text;
StateHasChanged();
_ = Task.Delay(2000).ContinueWith(_ =>
{
copiedLink = "";
InvokeAsync(StateHasChanged);
});
}
catch
{
// Fallback for browsers that don't support clipboard API
await JSRuntime.InvokeVoidAsync("prompt", "Copy this link:", text);
}
}
public void Dispose()
{
autoSaveTimer?.Dispose();
}
// Simple splitter functionality
private ElementReference editorLayoutRef;
private double splitRatio = 0.5; // 50/50 by default
private bool isDragging = false;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
// Load saved split ratio from localStorage
var savedRatio = await JSRuntime.InvokeAsync<string?>("localStorage.getItem", "notemark-split-ratio");
if (!string.IsNullOrEmpty(savedRatio) && double.TryParse(savedRatio, out var ratio))
{
splitRatio = Math.Max(0.2, Math.Min(0.8, ratio));
StateHasChanged();
}
}
catch
{
// Ignore localStorage errors
}
}
}
private string GetEditorPaneStyle()
{
if (!showPreview) return "";
@@ -394,20 +763,16 @@
private async Task OnSplitterMouseDown(MouseEventArgs e)
{
isDragging = true;
await JSRuntime.InvokeVoidAsync("startSplitterDrag", DotNetObjectReference.Create(this));
dotNetRef?.Dispose();
dotNetRef = DotNetObjectReference.Create(this);
await JSRuntime.InvokeVoidAsync("startSplitterDrag", dotNetRef);
}
[JSInvokable]
public async Task UpdateSplitRatio(double clientX, double containerWidth)
{
if (!isDragging || containerWidth <= 0) return;
var newRatio = Math.Max(0.2, Math.Min(0.8, clientX / containerWidth)); // Clamp between 20% and 80%
// Update immediately for smooth dragging
splitRatio = newRatio;
// Use StateHasChanged for immediate visual feedback
splitRatio = Math.Max(0.2, Math.Min(0.8, clientX / containerWidth));
StateHasChanged();
}
@@ -415,20 +780,71 @@
public async Task StopDragging()
{
if (!isDragging) return;
isDragging = false;
// Save split ratio to localStorage when dragging stops
try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "notemark-split-ratio", splitRatio.ToString("F2"));
}
catch
{
// Ignore localStorage errors
}
// Single StateHasChanged at the end
catch { }
StateHasChanged();
}
[JSInvokable]
public void AdjustZoom(int delta)
{
previewZoom = Math.Clamp(previewZoom + delta, 70, 200);
SavePreviewZoom();
StateHasChanged();
}
private void ZoomIn()
{
previewZoom = Math.Min(200, previewZoom + 10);
SavePreviewZoom();
StateHasChanged();
}
private void ZoomOut()
{
previewZoom = Math.Max(70, previewZoom - 10);
SavePreviewZoom();
StateHasChanged();
}
private void ResetZoom()
{
previewZoom = 100;
SavePreviewZoom();
StateHasChanged();
}
private async void SavePreviewZoom()
{
try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "notemark-preview-zoom", previewZoom.ToString());
}
catch { }
}
private string GetPreviewContentStyle()
{
var factor = (previewZoom / 100.0).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
return $"zoom: {factor}; font-size: calc(1rem * {factor}); --preview-zoom: {factor};";
}
private async Task GoToHome()
{
if (hasUnsavedChanges)
{
await SaveContent();
}
Navigation.NavigateTo("/");
}
public void Dispose()
{
autoSaveTimer?.Dispose();
dotNetRef?.Dispose();
}
}
File diff suppressed because it is too large Load Diff
+355
View File
@@ -0,0 +1,355 @@
@page "/"
@rendermode InteractiveServer
@using Notemark.Models
@using Notemark.Services
@inject IDocumentService DocumentService
@inject ISessionService SessionService
@inject IHttpContextAccessor HttpContextAccessor
@inject IJSRuntime JSRuntime
@inject NavigationManager Navigation
<PageTitle>Notemark - Home</PageTitle>
<div class="home-container">
<!-- Top Navigation Bar -->
<header class="home-topbar">
<div class="topbar-brand">
<div class="brand-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
</div>
<span class="brand-name">Notemark</span>
</div>
<div class="topbar-search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" placeholder="Search your flagged/tagged notes..." @bind="searchQuery" @bind:event="oninput" />
@if (!string.IsNullOrEmpty(searchQuery))
{
<button class="btn-clear-search" @onclick="() => searchQuery = string.Empty">✕</button>
}
</div>
<div class="topbar-actions">
<button class="btn-primary-action" @onclick="CreateNewNote" title="Create new document">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span>New Note</span>
</button>
<ThemeToggle />
</div>
</header>
<!-- Main Dashboard Content -->
<main class="home-main">
@if (isLoading)
{
<div class="home-loading">
<div class="spinner"></div>
<p>Loading...</p>
</div>
}
else
{
<!-- Quick Hero Section: New Note & Device's Last Note -->
<section class="quick-hero">
<div class="hero-card hero-new" @onclick="CreateNewNote">
<div class="hero-icon new-icon">📄</div>
<div class="hero-info">
<h3>New Blank Note</h3>
<p>Start writing markdown from scratch</p>
</div>
</div>
@if (localRecentDoc != null)
{
<div class="hero-card hero-recent" @onclick="() => OpenNote(localRecentDoc.Id)">
<div class="hero-icon recent-icon">⚡</div>
<div class="hero-info">
<div class="hero-tag">Your Last Note on this PC</div>
<h3>@localRecentDoc.Title</h3>
<p>@localRecentDoc.Snippet</p>
<span class="hero-time">Last edited @FormatDate(localRecentDoc.LastModifiedAt)</span>
</div>
</div>
}
</section>
<!-- Tag Filter Chips (for tagged/flagged notes) -->
@if (activeTags.Any())
{
<div class="home-tags-filter">
<button class="tag-chip @(string.IsNullOrEmpty(selectedTag) ? "active" : "")" @onclick="() => SelectTag(string.Empty)">
All Pinned & Tagged (@displayedNotes.Count)
</button>
@foreach (var tag in activeTags)
{
var count = displayedNotes.Count(d => d.Tags.Any(t => t.Equals(tag, StringComparison.OrdinalIgnoreCase)));
<button class="tag-chip @(selectedTag == tag ? "active" : "")" @onclick="() => SelectTag(tag)">
#@tag (@count)
</button>
}
</div>
}
<!-- Flagged / Pinned Notes Section -->
@if (flaggedDocs.Any() && string.IsNullOrEmpty(selectedTag) && string.IsNullOrEmpty(searchQuery))
{
<section class="notes-section">
<div class="section-header">
<h2>🚩 Flagged Notes <span class="section-count">@flaggedDocs.Count</span></h2>
</div>
<div class="notes-grid">
@foreach (var doc in flaggedDocs)
{
<div class="note-card flagged-card" @onclick="() => OpenNote(doc.Id)">
<div class="card-header">
<h3 class="card-title">@doc.Title</h3>
<button class="btn-card-flag" @onclick="() => ToggleFlag(doc.Id)" @onclick:stopPropagation="true" title="Unflag note">
🚩
</button>
</div>
<p class="card-snippet">@doc.Snippet</p>
@if (doc.Tags.Any())
{
<div class="card-tags">
@foreach (var tag in doc.Tags)
{
<span class="tag-pill" @onclick="() => SelectTag(tag)" @onclick:stopPropagation="true">#@tag</span>
}
</div>
}
<div class="card-footer">
<span class="card-date">@FormatDate(doc.LastModifiedAt)</span>
</div>
</div>
}
</div>
</section>
}
<!-- Tagged / Filtered Notes Section -->
<section class="notes-section">
<div class="section-header">
<h2>
@(string.IsNullOrEmpty(selectedTag)
? (string.IsNullOrEmpty(searchQuery) ? (flaggedDocs.Any() ? "🏷️ Tagged Notes" : "🚩 Flagged & 🏷️ Tagged Notes") : "🔍 Search Results")
: $"🏷️ Notes tagged #{selectedTag}")
<span class="section-count">@filteredNotes.Count</span>
</h2>
</div>
@if (!filteredNotes.Any())
{
<div class="empty-dashboard">
<div class="empty-icon">🏷️</div>
<h3>No flagged or tagged notes yet</h3>
<p>Flag notes with 🚩 in the editor or add <code>#tags</code> in your text to see them on your home screen!</p>
@if (localRecentDoc != null)
{
<button class="btn-primary-action" @onclick="() => OpenNote(localRecentDoc.Id)">
Continue in Last Note: @localRecentDoc.Title
</button>
}
else
{
<button class="btn-primary-action" @onclick="CreateNewNote">
Create Your First Note
</button>
}
</div>
}
else
{
<div class="notes-grid">
@foreach (var doc in filteredNotes)
{
<div class="note-card @(doc.IsFlagged ? "flagged-card" : "")" @onclick="() => OpenNote(doc.Id)">
<div class="card-header">
<h3 class="card-title">@doc.Title</h3>
<div class="card-actions" @onclick:stopPropagation="true">
<button class="btn-card-flag @(doc.IsFlagged ? "active" : "")"
@onclick="() => ToggleFlag(doc.Id)"
title="@(doc.IsFlagged ? "Unflag note" : "Flag note")">
@(doc.IsFlagged ? "🚩" : "🏳️")
</button>
<button class="btn-card-delete"
@onclick="() => DeleteNote(doc.Id)"
title="Delete note">
🗑️
</button>
</div>
</div>
<p class="card-snippet">@doc.Snippet</p>
@if (doc.Tags.Any())
{
<div class="card-tags">
@foreach (var tag in doc.Tags)
{
<span class="tag-pill" @onclick="() => SelectTag(tag)" @onclick:stopPropagation="true">#@tag</span>
}
</div>
}
<div class="card-footer">
<span class="card-date">@FormatDate(doc.LastModifiedAt)</span>
</div>
</div>
}
</div>
}
</section>
}
</main>
</div>
@code {
private bool isLoading = true;
private string searchQuery = "";
private string selectedTag = "";
private DocumentMetadata? localRecentDoc;
private List<DocumentMetadata> displayedNotes = new();
private List<string> activeTags = new();
private List<DocumentMetadata> flaggedDocs => displayedNotes.Where(d => d.IsFlagged).OrderByDescending(d => d.LastModifiedAt).ToList();
private List<DocumentMetadata> filteredNotes
{
get
{
// If flaggedDocs section is shown above, only show tagged (non-flagged) or all matching filter
var list = string.IsNullOrEmpty(selectedTag) && string.IsNullOrEmpty(searchQuery) && flaggedDocs.Any()
? displayedNotes.Where(d => !d.IsFlagged)
: displayedNotes.AsEnumerable();
if (!string.IsNullOrEmpty(selectedTag))
{
list = list.Where(d => d.Tags.Any(t => t.Equals(selectedTag, StringComparison.OrdinalIgnoreCase)));
}
if (!string.IsNullOrWhiteSpace(searchQuery))
{
var q = searchQuery.Trim().ToLowerInvariant();
list = list.Where(d => d.Title.ToLowerInvariant().Contains(q) ||
d.Snippet.ToLowerInvariant().Contains(q) ||
d.Tags.Any(t => t.ToLowerInvariant().Contains(q)));
}
return list.OrderByDescending(d => d.LastModifiedAt).ToList();
}
}
protected override async Task OnInitializedAsync()
{
await LoadDashboard();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
// Read last note ID worked on this specific browser/PC
var lastDocId = await JSRuntime.InvokeAsync<string?>("localStorage.getItem", "notemark-last-doc-id");
if (!string.IsNullOrEmpty(lastDocId))
{
var doc = await DocumentService.GetDocumentAsync(lastDocId);
if (doc != null && doc.Id != localRecentDoc?.Id)
{
localRecentDoc = doc;
StateHasChanged();
}
}
}
catch { }
}
}
private async Task LoadDashboard()
{
isLoading = true;
try
{
var allDocs = await DocumentService.GetAllDocumentsAsync();
// Only include notes that are Flagged OR Tagged (having at least 1 tag)
displayedNotes = allDocs.Where(d => d.IsFlagged || d.Tags.Any())
.OrderByDescending(d => d.LastModifiedAt)
.ToList();
activeTags = displayedNotes.SelectMany(d => d.Tags)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(t => t)
.ToList();
// Try to find device's current session document as fallback
var httpContext = HttpContextAccessor.HttpContext;
if (httpContext != null)
{
var currentSessionId = SessionService.GetOrCreateSessionId(httpContext);
localRecentDoc = await DocumentService.GetDocumentAsync(currentSessionId);
}
if (localRecentDoc == null)
{
localRecentDoc = await DocumentService.GetMostRecentDocumentAsync();
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error loading dashboard: {ex.Message}");
}
finally
{
isLoading = false;
}
}
private void SelectTag(string tag)
{
selectedTag = selectedTag == tag ? string.Empty : tag;
}
private void CreateNewNote()
{
var newId = Guid.NewGuid().ToString();
Navigation.NavigateTo($"/editor/{newId}?isNew=true");
}
private void OpenNote(string id)
{
Navigation.NavigateTo($"/editor/{id}");
}
private async Task ToggleFlag(string id)
{
await DocumentService.ToggleFlagAsync(id);
await LoadDashboard();
}
private async Task DeleteNote(string id)
{
await DocumentService.DeleteDocumentAsync(id);
await LoadDashboard();
}
private string FormatDate(DateTime date)
{
var diff = DateTime.UtcNow - date;
if (diff.TotalMinutes < 1) return "Just now";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago";
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago";
return date.ToLocalTime().ToString("MMM dd, yyyy");
}
}
+456
View File
@@ -0,0 +1,456 @@
/* Home Dashboard Styles */
.home-container {
min-height: 100vh;
background-color: var(--bg);
color: var(--primary-text);
display: flex;
flex-direction: column;
}
/* Topbar */
.home-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 2rem;
background-color: var(--surface);
border-bottom: 1px solid var(--border);
gap: 1.5rem;
position: sticky;
top: 0;
z-index: 50;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 0.6rem;
}
.brand-logo {
color: var(--accent);
display: flex;
align-items: center;
}
.brand-name {
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--primary-text);
}
.topbar-search {
flex: 1;
max-width: 520px;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--secondary-text);
transition: all 0.15s ease;
}
.topbar-search:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-light);
}
.topbar-search input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--primary-text);
font-size: 0.875rem;
}
.btn-clear-search {
background: none;
border: none;
color: var(--secondary-text);
cursor: pointer;
padding: 0;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.btn-primary-action {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.5rem 1rem;
background-color: var(--accent);
color: #ffffff;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-sm);
}
.btn-primary-action:hover {
background-color: var(--accent-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
/* Main Layout */
.home-main {
flex: 1;
max-width: 1100px;
width: 100%;
margin: 0 auto;
padding: 2.5rem 2rem;
display: flex;
flex-direction: column;
gap: 2.5rem;
box-sizing: border-box;
}
/* Loading */
.home-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 5rem 0;
gap: 1rem;
color: var(--secondary-text);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Hero Quick Actions */
.quick-hero {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1.25rem;
}
.hero-card {
padding: 1.5rem;
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
gap: 1.25rem;
align-items: center;
box-shadow: var(--shadow-sm);
}
.hero-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.hero-icon {
font-size: 2rem;
padding: 0.75rem;
background-color: var(--bg);
border-radius: 10px;
border: 1px solid var(--border);
}
.hero-info {
flex: 1;
min-width: 0;
}
.hero-tag {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent);
margin-bottom: 0.2rem;
}
.hero-info h3 {
margin: 0 0 0.35rem 0;
font-size: 1.125rem;
font-weight: 700;
color: var(--primary-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hero-info p {
margin: 0;
font-size: 0.84rem;
color: var(--secondary-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hero-time {
display: inline-block;
font-size: 0.75rem;
color: var(--secondary-text);
margin-top: 0.4rem;
}
/* Tag Filter Chips */
.home-tags-filter {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag-chip {
padding: 0.35rem 0.8rem;
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--secondary-text);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.tag-chip:hover {
border-color: var(--accent);
color: var(--accent);
}
.tag-chip.active {
background-color: var(--accent);
border-color: var(--accent);
color: #ffffff;
}
/* Sections */
.notes-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.section-header h2 {
font-size: 1.25rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.section-count {
font-size: 0.8125rem;
font-weight: 700;
padding: 0.15rem 0.55rem;
background-color: var(--surface-hover);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--secondary-text);
}
/* Notes Grid */
.notes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.25rem;
}
.note-card {
padding: 1.25rem;
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
gap: 0.6rem;
box-shadow: var(--shadow-sm);
}
.note-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.flagged-card {
border-color: rgba(239, 68, 68, 0.3);
background: linear-gradient(to bottom right, var(--surface), rgba(239, 68, 68, 0.03));
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.card-title {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
color: var(--primary-text);
line-height: 1.35;
word-break: break-word;
}
.card-actions {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.btn-card-flag,
.btn-card-delete {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
font-size: 0.95rem;
border-radius: 6px;
opacity: 0.7;
transition: all 0.15s ease;
}
.btn-card-flag:hover,
.btn-card-delete:hover {
opacity: 1;
background-color: var(--surface-hover);
transform: scale(1.1);
}
.btn-card-flag.active {
opacity: 1;
}
.card-snippet {
margin: 0;
font-size: 0.875rem;
color: var(--secondary-text);
line-height: 1.5;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.2rem;
}
.tag-pill {
font-size: 0.75rem;
font-weight: 600;
padding: 0.15rem 0.5rem;
background-color: var(--accent-light);
color: var(--accent);
border-radius: 999px;
transition: all 0.15s ease;
}
.tag-pill:hover {
background-color: var(--accent);
color: #ffffff;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.75rem;
color: var(--secondary-text);
margin-top: auto;
padding-top: 0.5rem;
border-top: 1px solid var(--border-subtle);
}
/* Empty Dashboard */
.empty-dashboard {
text-align: center;
padding: 4rem 2rem;
background-color: var(--surface);
border: 1px dashed var(--border);
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.empty-icon {
font-size: 3rem;
}
.empty-dashboard h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
}
.empty-dashboard p {
margin: 0;
font-size: 0.9rem;
color: var(--secondary-text);
max-width: 400px;
}
/* Responsive */
@media screen and (max-width: 768px) {
.home-topbar {
padding: 0.75rem 1rem;
flex-wrap: wrap;
}
.topbar-search {
order: 3;
width: 100%;
max-width: 100%;
}
.home-main {
padding: 1.5rem 1rem;
gap: 1.5rem;
}
.quick-hero {
grid-template-columns: 1fr;
}
.notes-grid {
grid-template-columns: 1fr;
}
}
@@ -1,5 +1,6 @@
@page "/e/{token}"
@rendermode InteractiveServer
@implements IDisposable
@using Notemark.Services
@inject IShareService ShareService
@inject ISessionService SessionService
@@ -227,6 +228,18 @@ else
Navigation.NavigateTo("/");
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (showPreview && !string.IsNullOrEmpty(renderedHtml))
{
try
{
await JSRuntime.InvokeVoidAsync("notemarkEditor.highlightPreview", ".preview-content");
}
catch { }
}
}
public void Dispose()
{
autoSaveTimer?.Dispose();
+75 -1
View File
@@ -1,10 +1,12 @@
@page "/s/{token}"
@rendermode InteractiveServer
@implements IDisposable
@using Notemark.Services
@inject IShareService ShareService
@inject ISessionService SessionService
@inject IMarkdownService MarkdownService
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
<PageTitle>Notemark - Shared Document</PageTitle>
@@ -37,6 +39,17 @@ else
</div>
<div class="header-right">
<div class="preview-zoom-controls">
<button type="button" class="btn-zoom" @onclick="ZoomOut" title="Zoom out" disabled="@(previewZoom <= 70)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</button>
<button type="button" class="btn-zoom-reset" @onclick="ResetZoom" title="Reset zoom">
@(previewZoom)%
</button>
<button type="button" class="btn-zoom" @onclick="ZoomIn" title="Zoom in" disabled="@(previewZoom >= 200)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</button>
</div>
@if (shareToken?.Mode == "editable")
{
<button class="btn-edit" @onclick="GoToEditor">✏️ Edit</button>
@@ -57,7 +70,7 @@ else
}
else
{
<div class="preview-content">
<div class="preview-content" style="@GetPreviewContentStyle()">
@((MarkupString)renderedHtml)
</div>
}
@@ -124,6 +137,62 @@ else
}
}
private DotNetObjectReference<SharedPreview>? dotNetRef;
private int previewZoom = 100;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
dotNetRef ??= DotNetObjectReference.Create(this);
await JSRuntime.InvokeVoidAsync("notemarkEditor.attachPreviewZoomWheel", ".shared-content", dotNetRef);
}
catch { }
}
if (!isLoading && !string.IsNullOrEmpty(renderedHtml))
{
try
{
await JSRuntime.InvokeVoidAsync("notemarkEditor.highlightPreview", ".preview-content");
}
catch { }
}
}
[JSInvokable]
public void AdjustZoom(int delta)
{
previewZoom = Math.Clamp(previewZoom + delta, 70, 200);
StateHasChanged();
}
private void ZoomIn()
{
previewZoom = Math.Min(200, previewZoom + 10);
StateHasChanged();
}
private void ZoomOut()
{
previewZoom = Math.Max(70, previewZoom - 10);
StateHasChanged();
}
private void ResetZoom()
{
previewZoom = 100;
StateHasChanged();
}
private string GetPreviewContentStyle()
{
var factor = (previewZoom / 100.0).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
return $"zoom: {factor}; font-size: calc(1rem * {factor}); --preview-zoom: {factor};";
}
private void GoToEditor()
{
if (shareToken?.Mode == "editable")
@@ -136,4 +205,9 @@ else
{
Navigation.NavigateTo("/");
}
public void Dispose()
{
dotNetRef?.Dispose();
}
}
+208 -95
View File
@@ -47,28 +47,28 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
padding: 0.75rem 1.5rem;
background-color: var(--surface);
border-bottom: 2px solid var(--border);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
gap: 0.75rem;
}
.header-left h1 {
margin: 0;
font-size: 1.5rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--accent);
}
.share-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
@@ -76,54 +76,103 @@
}
.share-badge.readonly {
background-color: var(--secondary-text);
color: white;
background-color: var(--border);
color: var(--secondary-text);
}
.share-badge.editable {
background-color: var(--success);
color: white;
background-color: rgba(16, 185, 129, 0.15);
color: var(--success);
}
.header-right {
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.5rem;
}
.preview-zoom-controls {
display: inline-flex;
align-items: center;
gap: 0.2rem;
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.15rem 0.25rem;
}
.btn-zoom {
background: none;
border: none;
color: var(--secondary-text);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.2rem 0.35rem;
border-radius: 4px;
transition: all 0.15s ease;
}
.btn-zoom:hover:not(:disabled) {
background-color: var(--surface-hover);
color: var(--accent);
}
.btn-zoom:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.btn-zoom-reset {
background: none;
border: none;
font-size: 0.72rem;
font-weight: 700;
color: var(--primary-text);
cursor: pointer;
padding: 0.15rem 0.35rem;
border-radius: 3px;
min-width: 38px;
text-align: center;
}
.btn-zoom-reset:hover {
background-color: var(--accent-light);
color: var(--accent);
}
/* Buttons */
.btn-edit,
.btn-home {
padding: 0.5rem 1rem;
padding: 0.45rem 0.85rem;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: 7px;
background-color: var(--surface);
color: var(--primary-text);
font-size: 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
gap: 0.4rem;
}
.btn-edit {
background-color: var(--accent);
color: white;
color: #ffffff;
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;
background-color: var(--surface-hover);
border-color: var(--accent);
color: var(--accent);
}
/* Content Area */
@@ -151,14 +200,15 @@
}
.preview-content {
max-width: 800px;
max-width: 850px;
margin: 0 auto;
padding: 3rem 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
line-height: 1.6;
padding: 2.5rem 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.7;
color: var(--primary-text);
}
/* Markdown Preview Styles (same as editor) */
/* Markdown Preview Typography */
.preview-content h1,
.preview-content h2,
.preview-content h3,
@@ -166,118 +216,181 @@
.preview-content h5,
.preview-content h6 {
color: var(--primary-text);
margin-top: 2em;
margin-bottom: 1em;
font-weight: 600;
font-weight: 700;
line-height: 1.3;
margin-top: 1.75em;
margin-bottom: 0.6em;
letter-spacing: -0.02em;
}
.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 h1:first-child,
.preview-content h2:first-child,
.preview-content h3:first-child {
margin-top: 0;
}
.preview-content h1 {
font-size: 2.15em;
border-bottom: 1px solid var(--border);
padding-bottom: 0.4em;
}
.preview-content h2 {
font-size: 1.6em;
border-bottom: 1px solid var(--border-subtle);
padding-bottom: 0.3em;
}
.preview-content h3 { font-size: 1.3em; }
.preview-content h4 { font-size: 1.1em; }
.preview-content h5 { font-size: 0.95em; text-transform: uppercase; letter-spacing: 0.05em; }
.preview-content h6 { font-size: 0.875em; color: var(--secondary-text); }
.preview-content p {
margin-bottom: 1.5em;
color: var(--primary-text);
font-size: 1.1em;
margin-top: 0;
margin-bottom: 1.1em;
font-size: 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;
.preview-content a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
font-weight: 500;
}
.preview-content pre {
background-color: var(--bg);
padding: 1.5em;
border-radius: 8px;
overflow-x: auto;
margin-bottom: 1.5em;
.preview-content a:hover {
color: var(--accent-hover);
}
.preview-content :not(pre) > code {
background-color: var(--code-bg);
color: var(--accent);
padding: 0.2em 0.45em;
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.875em;
border: 1px solid var(--border);
}
.preview-content pre code {
background: none;
padding: 0;
.preview-content pre {
position: relative;
background-color: var(--code-bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem 1.5rem;
margin: 1.25em 0;
overflow-x: auto;
}
.code-copy-btn {
position: absolute;
top: 0.65rem;
right: 0.65rem;
padding: 0.35rem 0.5rem;
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--secondary-text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.2s ease;
}
.preview-content pre:hover .code-copy-btn {
opacity: 1;
}
.code-copy-btn:hover {
color: var(--primary-text);
background-color: var(--surface-hover);
border-color: var(--accent);
}
.code-copy-btn.copied {
color: var(--success);
border-color: var(--success);
opacity: 1;
}
.preview-content blockquote {
margin: 1.25em 0;
padding: 0.75rem 1.25rem;
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;
background-color: var(--accent-light);
border-radius: 0 8px 8px 0;
color: var(--primary-text);
}
.preview-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5em;
border-collapse: separate;
border-spacing: 0;
margin: 1.5em 0;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.preview-content th,
.preview-content td {
border: 1px solid var(--border);
padding: 0.75em 1em;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
border-right: 1px solid var(--border);
text-align: left;
}
.preview-content th:last-child,
.preview-content td:last-child {
border-right: none;
}
.preview-content tr:last-child td {
border-bottom: none;
}
.preview-content th {
background-color: var(--bg);
background-color: var(--surface-hover);
font-weight: 600;
}
.preview-content img {
max-width: 100%;
height: auto;
border-radius: 6px;
.preview-content tr:hover td {
background-color: var(--surface-hover);
}
.preview-content ul,
.preview-content ol {
padding-left: 1.75em;
margin-bottom: 1.1em;
}
.preview-content input[type="checkbox"] {
margin-right: 0.5em;
accent-color: var(--accent);
}
.preview-content hr {
border: none;
border-top: 2px solid var(--border);
margin: 3em 0;
height: 1px;
background-color: var(--border);
margin: 2em 0;
}
.preview-content img {
max-width: 100%;
border-radius: 8px;
margin: 1em 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;
padding: 0.75rem 1rem;
}
.preview-content {
padding: 2rem 1rem;
}
.btn-edit,
.btn-home {
flex: 1;
justify-content: center;
padding: 1.5rem 1rem;
}
}
@@ -0,0 +1,263 @@
@using Notemark.Models
@using Notemark.Services
@inject IDocumentService DocumentService
@if (IsOpen)
{
<div class="drawer-backdrop" @onclick="CloseDrawer"></div>
<div class="notes-drawer @(IsOpen ? "open" : "")">
<div class="drawer-header">
<div class="drawer-header-top">
<div class="drawer-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
<span>My Notes</span>
</div>
<button class="btn-close-drawer" @onclick="CloseDrawer" title="Close notes list">✕</button>
</div>
<!-- Quick Action: Open Most Recent -->
<button class="btn-open-recent" @onclick="OpenMostRecent" title="Open the most recently modified note">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</svg>
<span>Open Most Recent Note</span>
</button>
<!-- Search box -->
<div class="search-box">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" placeholder="Search notes or #tags..." @bind="searchQuery" @bind:event="oninput" />
@if (!string.IsNullOrEmpty(searchQuery))
{
<button class="btn-clear-search" @onclick="() => searchQuery = string.Empty">✕</button>
}
</div>
<!-- Tag Filter Bar -->
@if (allTags.Any())
{
<div class="tag-filter-bar">
<button class="tag-chip @(string.IsNullOrEmpty(selectedTag) ? "active" : "")"
@onclick="() => SelectTag(string.Empty)">
All Tags
</button>
@foreach (var tag in allTags)
{
<button class="tag-chip @(selectedTag == tag ? "active" : "")"
@onclick="() => SelectTag(tag)">
#@tag
</button>
}
</div>
}
<!-- Tab Switcher -->
<div class="drawer-tabs">
<button class="tab-btn @(activeTab == "flagged" ? "active" : "")" @onclick="@(() => activeTab = "flagged")">
🚩 Flagged <span class="tab-badge">@flaggedCount</span>
</button>
<button class="tab-btn @(activeTab == "recent" ? "active" : "")" @onclick="@(() => activeTab = "recent")">
⏱️ Recent <span class="tab-badge">@allDocs.Count</span>
</button>
</div>
</div>
<div class="drawer-body">
@if (isLoading)
{
<div class="drawer-loading">
<div class="loading-spinner"></div>
<span>Loading notes...</span>
</div>
}
else
{
var displayList = GetFilteredList();
if (!displayList.Any())
{
<div class="empty-state">
@if (!string.IsNullOrEmpty(selectedTag))
{
<div class="empty-icon">🏷️</div>
<h4>No notes with #@selectedTag</h4>
<p>Try selecting another tag or clearing the filter.</p>
}
else if (activeTab == "flagged")
{
<div class="empty-icon">🚩</div>
<h4>No flagged notes</h4>
<p>Click the flag button on any note to keep it here for quick access.</p>
}
else
{
<div class="empty-icon">📝</div>
<h4>No notes found</h4>
<p>@(string.IsNullOrEmpty(searchQuery) ? "Start typing in the editor to create your first note." : "No notes match your search.")</p>
}
</div>
}
else
{
<div class="notes-list">
@foreach (var doc in displayList)
{
<div class="note-card @(doc.Id == CurrentDocumentId ? "active" : "")" @onclick="() => SelectDoc(doc.Id)">
<div class="note-card-header">
<h4 class="note-card-title">@doc.Title</h4>
<div class="note-card-actions" @onclick:stopPropagation="true">
<button class="btn-card-flag @(doc.IsFlagged ? "flagged" : "")"
@onclick="() => ToggleFlag(doc.Id)"
title="@(doc.IsFlagged ? "Unflag note" : "Flag note")">
@(doc.IsFlagged ? "🚩" : "🏳️")
</button>
<button class="btn-card-delete"
@onclick="() => DeleteDoc(doc.Id)"
title="Delete note">
🗑️
</button>
</div>
</div>
<p class="note-card-snippet">@doc.Snippet</p>
@if (doc.Tags.Any())
{
<div class="card-tags" @onclick:stopPropagation="true">
@foreach (var tag in doc.Tags)
{
<span class="card-tag-badge" @onclick="() => SelectTag(tag)">#@tag</span>
}
</div>
}
<div class="note-card-footer">
<span class="note-card-time">@FormatDate(doc.LastModifiedAt)</span>
</div>
</div>
}
</div>
}
}
</div>
</div>
}
@code {
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
[Parameter] public string CurrentDocumentId { get; set; } = "";
[Parameter] public EventCallback<string> OnSelectDocument { get; set; }
private bool isLoading = true;
private string activeTab = "flagged";
private string searchQuery = "";
private string selectedTag = "";
private List<DocumentMetadata> allDocs = new();
private List<string> allTags = new();
private int flaggedCount => allDocs.Count(d => d.IsFlagged);
protected override async Task OnParametersSetAsync()
{
if (IsOpen)
{
await RefreshNotesList();
}
}
public async Task RefreshNotesList()
{
isLoading = true;
try
{
allDocs = await DocumentService.GetAllDocumentsAsync();
allTags = await DocumentService.GetAllTagsAsync();
if (!allDocs.Any(d => d.IsFlagged) && activeTab == "flagged")
{
activeTab = "recent";
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error loading documents: {ex.Message}");
}
finally
{
isLoading = false;
}
}
private List<DocumentMetadata> GetFilteredList()
{
var list = activeTab == "flagged"
? allDocs.Where(d => d.IsFlagged)
: allDocs;
if (!string.IsNullOrEmpty(selectedTag))
{
list = list.Where(d => d.Tags.Any(t => t.Equals(selectedTag, StringComparison.OrdinalIgnoreCase)));
}
if (!string.IsNullOrWhiteSpace(searchQuery))
{
var q = searchQuery.Trim().ToLowerInvariant();
list = list.Where(d => d.Title.ToLowerInvariant().Contains(q) ||
d.Snippet.ToLowerInvariant().Contains(q) ||
d.Tags.Any(t => t.ToLowerInvariant().Contains(q)));
}
return list.OrderByDescending(d => d.LastModifiedAt).ToList();
}
private void SelectTag(string tag)
{
selectedTag = selectedTag == tag ? string.Empty : tag;
}
private async Task CloseDrawer()
{
IsOpen = false;
await IsOpenChanged.InvokeAsync(false);
}
private async Task SelectDoc(string docId)
{
await OnSelectDocument.InvokeAsync(docId);
await CloseDrawer();
}
private async Task OpenMostRecent()
{
var mostRecent = await DocumentService.GetMostRecentDocumentAsync();
if (mostRecent != null)
{
await SelectDoc(mostRecent.Id);
}
}
private async Task ToggleFlag(string docId)
{
await DocumentService.ToggleFlagAsync(docId);
await RefreshNotesList();
}
private async Task DeleteDoc(string docId)
{
await DocumentService.DeleteDocumentAsync(docId);
await RefreshNotesList();
}
private string FormatDate(DateTime date)
{
var diff = DateTime.UtcNow - date;
if (diff.TotalMinutes < 1) return "Just now";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago";
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago";
return date.ToLocalTime().ToString("MMM dd, yyyy");
}
}
@@ -0,0 +1,396 @@
/* Notes Drawer Styles */
.drawer-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
z-index: 1050;
animation: fadeIn 0.2s ease-out;
}
.notes-drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 390px;
max-width: 90vw;
background-color: var(--surface);
border-right: 1px solid var(--border);
box-shadow: var(--shadow-lg);
z-index: 1100;
display: flex;
flex-direction: column;
animation: slideIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideIn {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.drawer-header {
padding: 1.25rem 1rem 0.75rem 1rem;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 0.75rem;
background-color: var(--surface);
}
.drawer-header-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.drawer-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 700;
color: var(--primary-text);
}
.drawer-title svg {
color: var(--accent);
}
.btn-close-drawer {
background: none;
border: none;
font-size: 1.125rem;
color: var(--secondary-text);
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: all 0.15s ease;
}
.btn-close-drawer:hover {
background-color: var(--surface-hover);
color: var(--primary-text);
}
/* Quick Action Button: Open Most Recent */
.btn-open-recent {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.6rem 1rem;
background-color: var(--accent-light);
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-open-recent:hover {
background-color: var(--accent);
color: #ffffff;
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
/* Search Box */
.search-box {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--secondary-text);
}
.search-box input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--primary-text);
font-size: 0.875rem;
}
.search-box input::placeholder {
color: var(--secondary-text);
}
.btn-clear-search {
background: none;
border: none;
color: var(--secondary-text);
cursor: pointer;
padding: 0 0.25rem;
font-size: 0.75rem;
}
/* Tag Filter Bar */
.tag-filter-bar {
display: flex;
gap: 0.35rem;
overflow-x: auto;
padding-bottom: 0.25rem;
scrollbar-width: none;
}
.tag-filter-bar::-webkit-scrollbar {
display: none;
}
.tag-chip {
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
background-color: var(--bg);
border: 1px solid var(--border);
color: var(--secondary-text);
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.tag-chip:hover {
border-color: var(--accent);
color: var(--accent);
}
.tag-chip.active {
background-color: var(--accent);
border-color: var(--accent);
color: #ffffff;
}
/* Tabs */
.drawer-tabs {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
.tab-btn {
flex: 1;
padding: 0.4rem 0.5rem;
background: none;
border: 1px solid transparent;
border-radius: 6px;
color: var(--secondary-text);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
transition: all 0.15s ease;
}
.tab-btn.active {
background-color: var(--surface-hover);
color: var(--primary-text);
border-color: var(--border);
}
.tab-badge {
font-size: 0.7rem;
padding: 0.1rem 0.35rem;
background-color: var(--border);
border-radius: 999px;
font-weight: 700;
}
.tab-btn.active .tab-badge {
background-color: var(--accent);
color: #ffffff;
}
/* Drawer Body */
.drawer-body {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
}
.drawer-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
gap: 0.75rem;
color: var(--secondary-text);
font-size: 0.875rem;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1.5rem;
color: var(--secondary-text);
}
.empty-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
}
.empty-state h4 {
font-size: 1rem;
font-weight: 600;
color: var(--primary-text);
margin-bottom: 0.5rem;
}
.empty-state p {
font-size: 0.8125rem;
line-height: 1.5;
}
/* Notes List */
.notes-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.note-card {
padding: 0.875rem;
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.note-card:hover {
border-color: var(--accent);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
background-color: var(--surface-hover);
}
.note-card.active {
border-color: var(--accent);
background-color: var(--accent-light);
}
.note-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.note-card-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--primary-text);
line-height: 1.3;
word-break: break-word;
}
.note-card-actions {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.btn-card-flag,
.btn-card-delete {
background: none;
border: none;
cursor: pointer;
padding: 0.2rem;
font-size: 0.9rem;
border-radius: 4px;
opacity: 0.7;
transition: all 0.15s ease;
}
.btn-card-flag:hover,
.btn-card-delete:hover {
opacity: 1;
background-color: var(--border);
transform: scale(1.1);
}
.btn-card-flag.flagged {
opacity: 1;
}
.note-card-snippet {
margin: 0;
font-size: 0.8125rem;
color: var(--secondary-text);
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* Card Tags */
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.2rem;
}
.card-tag-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.4rem;
background-color: var(--accent-light);
color: var(--accent);
border-radius: 4px;
cursor: pointer;
}
.card-tag-badge:hover {
background-color: var(--accent);
color: #ffffff;
}
.note-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.75rem;
color: var(--secondary-text);
margin-top: 0.25rem;
}
+52 -18
View File
@@ -1,49 +1,83 @@
@inject IJSRuntime JSRuntime
@inject IHttpContextAccessor HttpContextAccessor
<button class="theme-toggle" @onclick="ToggleTheme" title="@GetThemeTooltip()">
@GetThemeIcon()
<button class="theme-toggle" @onclick="ToggleTheme" title="@GetThemeTooltip()" aria-label="@GetThemeTooltip()">
@if (currentTheme == "dark")
{
<!-- Sun icon for dark mode (click to switch to light) -->
<svg class="theme-icon sun-icon" viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
}
else
{
<!-- Moon icon for light mode (click to switch to dark) -->
<svg class="theme-icon moon-icon" viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
}
</button>
@code {
private string currentTheme = "light";
protected override async Task OnInitializedAsync()
protected override void OnInitialized()
{
try
{
await JSRuntime.InvokeVoidAsync("notemarkTheme.init");
currentTheme = await JSRuntime.InvokeAsync<string>("notemarkTheme.getCurrentTheme");
var cookie = HttpContextAccessor.HttpContext?.Request.Cookies["notemark_theme"];
if (!string.IsNullOrEmpty(cookie) && (cookie == "dark" || cookie == "light"))
{
currentTheme = cookie;
}
}
catch (Exception ex)
catch { }
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
System.Diagnostics.Debug.WriteLine($"Theme init error: {ex.Message}");
currentTheme = "light";
try
{
var theme = await JSRuntime.InvokeAsync<string>("notemarkTheme.getCurrentTheme");
if (!string.IsNullOrEmpty(theme) && theme != currentTheme)
{
currentTheme = theme;
StateHasChanged();
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Theme init error: {ex.Message}");
}
}
}
private async Task ToggleTheme()
{
// Simple toggle: light <-> dark
currentTheme = currentTheme == "light" ? "dark" : "light";
try
{
await JSRuntime.InvokeVoidAsync("notemarkTheme.setTheme", currentTheme);
System.Diagnostics.Debug.WriteLine($"Theme switched to: {currentTheme}");
var nextTheme = await JSRuntime.InvokeAsync<string>("notemarkTheme.toggleTheme");
currentTheme = nextTheme;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Theme toggle error: {ex.Message}");
currentTheme = currentTheme == "dark" ? "light" : "dark";
}
StateHasChanged();
}
private string GetThemeIcon()
{
return currentTheme == "dark" ? "🌙" : "☀️";
}
private string GetThemeTooltip()
{
return currentTheme == "dark" ? "Switch to light mode" : "Switch to dark mode";
@@ -1,35 +1,48 @@
.theme-toggle {
padding: 0.5rem;
background: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
color: var(--primary-text);
line-height: 1;
transition: all 0.2s ease;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
height: 2.5rem;
min-width: 2.25rem;
height: 2.25rem;
box-shadow: var(--shadow-sm);
}
.theme-toggle:hover {
background-color: var(--accent);
background-color: var(--surface-hover);
border-color: var(--accent);
transform: scale(1.05);
color: var(--accent);
transform: translateY(-1px);
}
.theme-toggle:active {
transform: scale(0.95);
transform: translateY(0) scale(0.96);
}
/* Mobile styles */
.theme-icon {
transition: transform 0.3s ease, stroke 0.2s ease;
}
.theme-toggle:hover .sun-icon {
transform: rotate(45deg);
}
.theme-toggle:hover .moon-icon {
transform: rotate(-15deg);
}
/* Mobile responsive */
@media (max-width: 768px) {
.theme-toggle {
min-width: 2rem;
height: 2rem;
padding: 0.375rem;
font-size: 0.875rem;
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace Notemark.Models;
public record DocumentMetadata
{
public string Id { get; init; } = "";
public string Title { get; init; } = "Untitled Document";
public bool IsFlagged { get; init; } = false;
public List<string> Tags { get; init; } = new();
public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
public DateTime LastModifiedAt { get; init; } = DateTime.UtcNow;
public string Snippet { get; init; } = "";
}
+2 -2
View File
@@ -12,6 +12,7 @@ builder.Services.AddHttpContextAccessor();
// Register Notemark services
builder.Services.AddSingleton<IMarkdownService, MarkdownService>();
builder.Services.AddSingleton<IDocumentService, DocumentService>();
builder.Services.AddScoped<ISessionService, SessionService>();
builder.Services.AddScoped<IShareService, ShareService>();
@@ -40,8 +41,7 @@ app.UseHttpsRedirection();
// Enable WebSockets
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromMinutes(2),
ReceiveBufferSize = 4 * 1024
KeepAliveInterval = TimeSpan.FromMinutes(2)
});
// Enable cookies for session management
+282
View File
@@ -0,0 +1,282 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Notemark.Models;
namespace Notemark.Services;
public partial class DocumentService : IDocumentService
{
private readonly string _metadataFilePath;
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly JsonSerializerOptions _jsonOptions;
// Matches hashtags like #work, #todo, #project_1 (excluding markdown headings which start at line begin followed by space)
private static readonly Regex HashtagRegex = new(@"(?<=^|\s)#([a-zA-Z0-9_\-]+)(?=\s|$)", RegexOptions.Compiled);
public DocumentService(IWebHostEnvironment environment)
{
var dataDir = Path.Combine(environment.ContentRootPath, "data");
Directory.CreateDirectory(dataDir);
_metadataFilePath = Path.Combine(dataDir, "documents.json");
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
}
public async Task<List<DocumentMetadata>> GetAllDocumentsAsync()
{
await _lock.WaitAsync();
try
{
return await ReadMetadataListInternalAsync();
}
finally
{
_lock.Release();
}
}
public async Task<List<DocumentMetadata>> GetFlaggedDocumentsAsync()
{
var docs = await GetAllDocumentsAsync();
return docs.Where(d => d.IsFlagged)
.OrderByDescending(d => d.LastModifiedAt)
.ToList();
}
public async Task<List<DocumentMetadata>> GetRecentDocumentsAsync(int limit = 20)
{
var docs = await GetAllDocumentsAsync();
return docs.OrderByDescending(d => d.LastModifiedAt)
.Take(limit)
.ToList();
}
public async Task<List<string>> GetAllTagsAsync()
{
var docs = await GetAllDocumentsAsync();
return docs.SelectMany(d => d.Tags)
.Where(t => !string.IsNullOrWhiteSpace(t))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(t => t)
.ToList();
}
public async Task<List<DocumentMetadata>> GetDocumentsByTagAsync(string tag)
{
if (string.IsNullOrWhiteSpace(tag)) return await GetAllDocumentsAsync();
var docs = await GetAllDocumentsAsync();
return docs.Where(d => d.Tags.Any(t => t.Equals(tag, StringComparison.OrdinalIgnoreCase)))
.OrderByDescending(d => d.LastModifiedAt)
.ToList();
}
public async Task<DocumentMetadata?> GetMostRecentDocumentAsync()
{
var docs = await GetAllDocumentsAsync();
return docs.OrderByDescending(d => d.LastModifiedAt).FirstOrDefault();
}
public async Task<DocumentMetadata?> GetDocumentAsync(string id)
{
if (string.IsNullOrEmpty(id)) return null;
var docs = await GetAllDocumentsAsync();
return docs.FirstOrDefault(d => d.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
}
public async Task<DocumentMetadata> SaveDocumentAsync(string id, string content, bool? isFlagged = null, string? customTitle = null, List<string>? tags = null)
{
if (string.IsNullOrEmpty(id))
throw new ArgumentException("Document ID cannot be empty", nameof(id));
var title = !string.IsNullOrWhiteSpace(customTitle) ? customTitle.Trim() : ExtractTitle(content);
var snippet = ExtractSnippet(content);
var extractedTags = tags ?? ExtractTags(content);
await _lock.WaitAsync();
try
{
var docs = await ReadMetadataListInternalAsync();
var existingIndex = docs.FindIndex(d => d.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
DocumentMetadata doc;
if (existingIndex >= 0)
{
var existing = docs[existingIndex];
doc = existing with
{
Title = title,
Snippet = snippet,
IsFlagged = isFlagged ?? existing.IsFlagged,
Tags = extractedTags,
LastModifiedAt = DateTime.UtcNow
};
docs[existingIndex] = doc;
}
else
{
doc = new DocumentMetadata
{
Id = id,
Title = title,
Snippet = snippet,
IsFlagged = isFlagged ?? false,
Tags = extractedTags,
CreatedAt = DateTime.UtcNow,
LastModifiedAt = DateTime.UtcNow
};
docs.Add(doc);
}
await WriteMetadataListInternalAsync(docs);
return doc;
}
finally
{
_lock.Release();
}
}
public async Task<bool> ToggleFlagAsync(string id)
{
if (string.IsNullOrEmpty(id)) return false;
await _lock.WaitAsync();
try
{
var docs = await ReadMetadataListInternalAsync();
var doc = docs.FirstOrDefault(d => d.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
if (doc == null)
{
var newDoc = new DocumentMetadata
{
Id = id,
IsFlagged = true,
CreatedAt = DateTime.UtcNow,
LastModifiedAt = DateTime.UtcNow
};
docs.Add(newDoc);
await WriteMetadataListInternalAsync(docs);
return true;
}
var nextState = !doc.IsFlagged;
var updated = doc with { IsFlagged = nextState, LastModifiedAt = DateTime.UtcNow };
var idx = docs.IndexOf(doc);
docs[idx] = updated;
await WriteMetadataListInternalAsync(docs);
return nextState;
}
finally
{
_lock.Release();
}
}
public async Task DeleteDocumentAsync(string id)
{
if (string.IsNullOrEmpty(id)) return;
await _lock.WaitAsync();
try
{
var docs = await ReadMetadataListInternalAsync();
docs.RemoveAll(d => d.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
await WriteMetadataListInternalAsync(docs);
}
finally
{
_lock.Release();
}
}
public string ExtractTitle(string content)
{
if (string.IsNullOrWhiteSpace(content))
return "Untitled Note";
var lines = content.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
var trimmed = line.Trim();
if (trimmed.StartsWith("#"))
{
var clean = trimmed.TrimStart('#').Trim();
if (!string.IsNullOrEmpty(clean))
return Truncate(clean, 40);
}
else if (!string.IsNullOrEmpty(trimmed))
{
return Truncate(trimmed, 40);
}
}
return "Untitled Note";
}
public List<string> ExtractTags(string content)
{
if (string.IsNullOrWhiteSpace(content))
return new List<string>();
var tags = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var matches = HashtagRegex.Matches(content);
foreach (Match match in matches)
{
if (match.Success && match.Groups.Count > 1)
{
var tag = match.Groups[1].Value.Trim().ToLowerInvariant();
// Avoid markdown heading hashes (e.g. ##) or pure numbers
if (!string.IsNullOrEmpty(tag) && !tag.All(char.IsDigit))
{
tags.Add(tag);
}
}
}
return tags.ToList();
}
private static string ExtractSnippet(string content)
{
if (string.IsNullOrWhiteSpace(content))
return "No content yet...";
var lines = content.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries);
var cleanLines = lines.Select(l => l.Trim().TrimStart('#', '-', '*', '>', ' ', '\t')).Where(l => !string.IsNullOrEmpty(l));
var text = string.Join(" ", cleanLines);
return Truncate(text, 120);
}
private static string Truncate(string value, int maxLen)
{
if (string.IsNullOrEmpty(value)) return value;
return value.Length <= maxLen ? value : value[..maxLen] + "...";
}
private async Task<List<DocumentMetadata>> ReadMetadataListInternalAsync()
{
if (!File.Exists(_metadataFilePath))
return new List<DocumentMetadata>();
try
{
var json = await File.ReadAllTextAsync(_metadataFilePath);
return JsonSerializer.Deserialize<List<DocumentMetadata>>(json, _jsonOptions) ?? new List<DocumentMetadata>();
}
catch
{
return new List<DocumentMetadata>();
}
}
private async Task WriteMetadataListInternalAsync(List<DocumentMetadata> list)
{
var json = JsonSerializer.Serialize(list, _jsonOptions);
await File.WriteAllTextAsync(_metadataFilePath, json);
}
}
+19
View File
@@ -0,0 +1,19 @@
using Notemark.Models;
namespace Notemark.Services;
public interface IDocumentService
{
Task<List<DocumentMetadata>> GetAllDocumentsAsync();
Task<List<DocumentMetadata>> GetFlaggedDocumentsAsync();
Task<List<DocumentMetadata>> GetRecentDocumentsAsync(int limit = 20);
Task<List<string>> GetAllTagsAsync();
Task<List<DocumentMetadata>> GetDocumentsByTagAsync(string tag);
Task<DocumentMetadata?> GetMostRecentDocumentAsync();
Task<DocumentMetadata?> GetDocumentAsync(string id);
Task<DocumentMetadata> SaveDocumentAsync(string id, string content, bool? isFlagged = null, string? customTitle = null, List<string>? tags = null);
Task<bool> ToggleFlagAsync(string id);
Task DeleteDocumentAsync(string id);
string ExtractTitle(string content);
List<string> ExtractTags(string content);
}
+2 -2
View File
@@ -69,8 +69,8 @@ public class SessionService : ISessionService
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);
// Use FileStream with FileShare.ReadWrite to avoid locking collisions
await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite, bufferSize: 8192, useAsync: true);
await using var writer = new StreamWriter(fileStream, Encoding.UTF8);
// Write in chunks for very large content
+11
View File
@@ -0,0 +1,11 @@
[
{
"id": "2ba5f046-c8d8-4e55-acf4-5e654dee5afc",
"title": "Kopanie",
"isFlagged": true,
"tags": [],
"createdAt": "2026-09-22T09:08:10.0528137Z",
"lastModifiedAt": "2026-09-22T09:45:38.5280677Z",
"snippet": "Kopanie Moj otec mi v\u017Edy hovoril, \u017Ee do pivnice sa nechod\u00ED. Nie preto, \u017Ee by tam bolo nebezpe\u010Dn\u00E9 \u2014 aspo\u0148 tak to tvrdil \u2014..."
}
]
@@ -0,0 +1,37 @@
# Kopanie
Moj otec mi vždy hovoril, že do pivnice sa nechodí. Nie preto, že by tam bolo nebezpečné — aspoň tak to tvrdil — ale preto, že „tam dole sa spí." Ja som mu veril. Otcovi som veril vo všetkom, čo hovoril, a on hovoril málo.
Do Hraboviec sme sa vrátili na pohreb. Dedina stála pod kopcom, ako ju pamätám z detstva: tie isté strechy, ten istý kostolík s krivým krížom, ten istý pach mokrej zeme a slamy. Vtedy tu bolo plno detí, ktoré behali po uliciach a kričali do okien. Teraz bolo ticho. Také ticho, aké poznáš len v dedinách, kde už nikto neostal.
Spali sme v starom dome na okraji, v izbe, kde som prespal ako chlapec. Postel bola tá istá — taštiak s prasknutým čalúnením, pod ktorým sa skrývali mince a kocky od kostiek. Na stene visela fotografia mojej matky, ktorá odišla pred dvadsiatimi rokmi a nikdy sa nevrátila. Otec ju nikdy nezobral dole.
Prvú noc som nič nepočul. Len vetra v komíne a vzdialený zvonec z kostola, ktorý niekto zapominal odraziť. Druhú noc, o tretej ráno, ma zobudilo kopanie.
Ticho. A potom: tik-tik-tik. Pomalé, pravidelné, ako keby niekto pod podlahou kreslil prstom po dreve. Zastavilo sa. Ja som ležal a počúval vlastné srdce. Potom opäť: tik-tik-tik.
Ráno som o tom povedal sestre Lenke. Ona sa len pousmiala. „Starý dom, Marek. Drevo pracuje."
Druhá noc to bolo hlasnejšie. Už nie tikanie, ale skôr ako keby niekto podlahou ťahol nechtami. A znievalo to bližšie — nie z pivnice, ale priamo pod mojou postelou.
Tretiu noc som sa zobudil a počúval. Kopanie bolo teraz tak jasné, že som počul aj dych. Nie môj. Podlahou sa niečo presunovalo, pomaly, od steny k stredu izby, a zastavilo sa priamo pod mnou.
„Otecko," povedal som do ticha. „Viem, že si tam."
Kopanie sa zastavilo. A potom, veľmi potichúčko, ako keby niekto odkašľol: odpovedalo mi jedno tik.
Ráno som zostúpil do pivnice. Dvere boli zamknuté zvonku — kľúčom, ktorý nikde nemal byť. Otvoril som ich a zapálil sviečku. Pivnica bola prázdna. Len staré vedrá, fľaše po slivovici a v rohu, pod vrstvou prachu, čosi, čo som nepoznal.
Nebol to nábytok. Nebol to žiadny predmet, ktorý by otec kedy vlastnil. Bola to diera. Malá, okrúhla, vykopaná do zeme, a na jej okraji stopy — stopy, ktoré niekto vykopal odspodu nahor.
Zapálil som druhú sviečku a pozeral do diery. V tme pod zemou sa niečo hýbalo. Pomaly. Ako keby sa niečo prebúdalo.
A potom som to pochopil. Otec mi neveril, keď mi hovoril, že tam dole sa spí. Hovoril pravdu. Lenže ja som myslel, že má na mysli niečo iné.
Zatvoril som dvere pivnice a zamkol ich zvonku. Kľúč som odniesol na kopiec a hodil ho do studne.
Dnes spím v izbe nad pivnicou. Kopanie sa vrátilo — o tretej, pravidelne, ako budík. Už neunikám. Počúvam. A každú noc je bližšie.
Zajtra bude pod mojou postelou.
A ja už viem, čo to chce. Chce, aby som otvoril dvere.
@@ -0,0 +1,3 @@
# Untitled Note
Start typing here... #ideas
+80 -76
View File
@@ -1,122 +1,126 @@
/* Notemark Theme Variables */
/* Notemark Global Theme System */
:root {
/* Light Mode (Default) */
/* Default Light Mode Variables */
--bg: #F9FAFB;
--surface: #FFFFFF;
--surface-hover: #F3F4F6;
--border: #E5E7EB;
--border-subtle: #F3F4F6;
--primary-text: #111827;
--secondary-text: #6B7280;
--accent: #4F46E5;
--accent-hover: #4338CA;
--accent-light: rgba(79, 70, 229, 0.1);
--success: #10B981;
--error: #F43F5E;
--error: #EF4444;
--code-bg: #F1F5F9;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
/* Auto-detect system preference for dark mode */
/* System preference fallback when no data-theme is set */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--bg: #0B0F19;
--surface: #111827;
--surface-hover: #1F2937;
--border: #1F2937;
--border-subtle: #182234;
--primary-text: #F9FAFB;
--secondary-text: #9CA3AF;
--accent: #22D3EE;
--accent-hover: #06B6D4;
--success: #4ADE80;
--error: #FB7185;
--accent: #6366F1;
--accent-hover: #818CF8;
--accent-light: rgba(99, 102, 241, 0.15);
--success: #34D399;
--error: #F87171;
--code-bg: #1E293B;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
}
}
/* Explicit light mode */
:root[data-theme="light"] {
--bg: #F9FAFB !important;
--surface: #FFFFFF !important;
--border: #E5E7EB !important;
--primary-text: #111827 !important;
--secondary-text: #6B7280 !important;
--accent: #4F46E5 !important;
--accent-hover: #4338CA !important;
--success: #10B981 !important;
--error: #F43F5E !important;
--bg: #F9FAFB;
--surface: #FFFFFF;
--surface-hover: #F3F4F6;
--border: #E5E7EB;
--border-subtle: #F3F4F6;
--primary-text: #111827;
--secondary-text: #6B7280;
--accent: #4F46E5;
--accent-hover: #4338CA;
--accent-light: rgba(79, 70, 229, 0.1);
--success: #10B981;
--error: #EF4444;
--code-bg: #F1F5F9;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
/* Explicit dark mode */
:root[data-theme="dark"] {
--bg: #0B0F19 !important;
--surface: #111827 !important;
--border: #1F2937 !important;
--primary-text: #F9FAFB !important;
--secondary-text: #9CA3AF !important;
--accent: #22D3EE !important;
--accent-hover: #06B6D4 !important;
--success: #4ADE80 !important;
--error: #FB7185 !important;
--bg: #0B0F19;
--surface: #111827;
--surface-hover: #1F2937;
--border: #1F2937;
--border-subtle: #182234;
--primary-text: #F9FAFB;
--secondary-text: #9CA3AF;
--accent: #6366F1;
--accent-hover: #818CF8;
--accent-light: rgba(99, 102, 241, 0.15);
--success: #34D399;
--error: #F87171;
--code-bg: #1E293B;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
}
/* Apply theme variables globally */
html, body {
background-color: var(--bg) !important;
color: var(--primary-text) !important;
transition: background-color 0.3s ease, color 0.3s ease;
}
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
background-color: var(--bg);
color: var(--primary-text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
margin: 0;
padding: 0;
height: 100%;
}
a, .btn-link {
color: #006bb7;
color: var(--accent);
}
a:hover {
color: var(--accent-hover);
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
color: #fff;
background-color: var(--accent);
border-color: var(--accent);
}
.btn-primary:hover {
background-color: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
box-shadow: 0 0 0 0.15rem var(--accent-light), 0 0 0 0.25rem var(--accent);
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA69.NzMwOSAyNjcuODc5IDY5LjU4NzcgMjY3Ljg3OSA2OS4zMTc5IDI2Ny44NzkgNjcuMTEyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
background: #b32121;
padding: 1rem 1rem 1rem 2rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
.blazor-error-boundary::after {
content: "An error has occurred.";
}
+301
View File
@@ -0,0 +1,301 @@
// Notemark Advanced Editor JavaScript Suite
window.notemarkEditor = {
isSyncingScroll: false,
mermaidLoaded: false,
// Initialize textarea with Tab handling, Paste/Drop Image support, and Scroll Sync
initTextarea: function(textarea) {
if (!textarea) return;
// 1. Tab Key Handling
textarea.addEventListener('keydown', function(e) {
if (e.key === 'Tab') {
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
if (e.shiftKey) {
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
if (value.substring(lineStart, lineStart + 2) === ' ') {
textarea.value = value.substring(0, lineStart) + value.substring(lineStart + 2);
textarea.selectionStart = Math.max(lineStart, start - 2);
textarea.selectionEnd = Math.max(lineStart, end - 2);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
} else {
textarea.value = value.substring(0, start) + ' ' + value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + 2;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
}
});
// 2. Paste Image Support (Base64)
textarea.addEventListener('paste', function(e) {
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile();
if (file) {
e.preventDefault();
window.notemarkEditor.insertImageAsBase64(textarea, file);
break;
}
}
}
});
// 3. Drag & Drop Image Support (Base64)
textarea.addEventListener('dragover', function(e) {
e.preventDefault();
e.stopPropagation();
});
textarea.addEventListener('drop', function(e) {
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0];
if (file.type.startsWith('image/')) {
e.preventDefault();
e.stopPropagation();
window.notemarkEditor.insertImageAsBase64(textarea, file);
}
}
});
// 4. Scroll Sync Listener on Editor
textarea.addEventListener('scroll', function() {
if (window.notemarkEditor.isSyncingScroll) return;
const preview = document.querySelector('.preview-content');
if (!preview) return;
window.notemarkEditor.isSyncingScroll = true;
const scrollRatio = textarea.scrollTop / (textarea.scrollHeight - textarea.clientHeight || 1);
preview.scrollTop = scrollRatio * (preview.scrollHeight - preview.clientHeight);
setTimeout(() => { window.notemarkEditor.isSyncingScroll = false; }, 25);
});
// Attach scroll sync on preview as well
const preview = document.querySelector('.preview-content');
if (preview) {
preview.addEventListener('scroll', function() {
if (window.notemarkEditor.isSyncingScroll) return;
window.notemarkEditor.isSyncingScroll = true;
const scrollRatio = preview.scrollTop / (preview.scrollHeight - preview.clientHeight || 1);
textarea.scrollTop = scrollRatio * (textarea.scrollHeight - textarea.clientHeight);
setTimeout(() => { window.notemarkEditor.isSyncingScroll = false; }, 25);
});
}
},
// Convert file to Base64 and insert at cursor
insertImageAsBase64: function(textarea, file) {
const reader = new FileReader();
reader.onload = function(event) {
const base64Data = event.target.result;
const markdownImage = `\n\n![${file.name || 'image'}](${base64Data})\n\n`;
textarea.focus();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const val = textarea.value;
textarea.value = val.substring(0, start) + markdownImage + val.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + markdownImage.length;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
};
reader.readAsDataURL(file);
},
// Wrap / insert text in selection
wrapSelection: function(textarea, prefix, suffix, defaultText) {
if (!textarea) return;
textarea.focus();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const val = textarea.value;
const selected = val.substring(start, end) || defaultText || '';
const replacement = prefix + selected + suffix;
textarea.value = val.substring(0, start) + replacement + val.substring(end);
textarea.selectionStart = start + prefix.length;
textarea.selectionEnd = start + prefix.length + selected.length;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
},
// Preview enhancement: Prism syntax highlighting + Mermaid diagrams + Copy code buttons
highlightPreview: function(containerSelector) {
const container = document.querySelector(containerSelector || '.preview-content');
if (!container) return;
// 1. Render Prism code blocks
if (window.Prism) {
window.Prism.highlightAllUnder(container);
}
// 2. Render Mermaid diagrams
window.notemarkEditor.renderMermaidDiagrams(container);
// 3. Attach code copy buttons
const preBlocks = container.querySelectorAll('pre');
preBlocks.forEach(function(pre) {
if (pre.querySelector('.code-copy-btn') || pre.classList.contains('mermaid-pre')) return;
const btn = document.createElement('button');
btn.className = 'code-copy-btn';
btn.setAttribute('type', 'button');
btn.setAttribute('title', 'Copy code');
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
btn.addEventListener('click', function(e) {
e.stopPropagation();
const code = pre.querySelector('code');
const text = code ? code.innerText : pre.innerText;
navigator.clipboard.writeText(text).then(function() {
btn.classList.add('copied');
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
setTimeout(function() {
btn.classList.remove('copied');
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
}, 2000);
});
});
pre.style.position = 'relative';
pre.appendChild(btn);
});
},
// Mermaid.js rendering engine with dynamic script loading
renderMermaidDiagrams: function(container) {
const mermaidCodeBlocks = container.querySelectorAll('code.language-mermaid');
if (!mermaidCodeBlocks.length) return;
function doRender() {
if (!window.mermaid) return;
const theme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'default';
window.mermaid.initialize({ startOnLoad: false, theme: theme, securityLevel: 'loose' });
mermaidCodeBlocks.forEach(function(codeElem, index) {
const parentPre = codeElem.closest('pre');
if (!parentPre || parentPre.dataset.processed === 'true') return;
parentPre.dataset.processed = 'true';
parentPre.classList.add('mermaid-pre');
const rawGraph = codeElem.innerText;
const containerId = 'mermaid-chart-' + Date.now() + '-' + index;
const chartDiv = document.createElement('div');
chartDiv.className = 'mermaid-rendered';
chartDiv.id = containerId;
try {
window.mermaid.render(containerId + '-svg', rawGraph).then(result => {
chartDiv.innerHTML = result.svg;
parentPre.parentNode.replaceChild(chartDiv, parentPre);
}).catch(err => {
console.warn('Mermaid rendering error:', err);
});
} catch (e) {
console.warn('Mermaid synchronous error:', e);
}
});
}
if (window.mermaid) {
doRender();
} else if (!window.notemarkEditor.mermaidLoading) {
window.notemarkEditor.mermaidLoading = true;
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js';
script.onload = function() {
window.notemarkEditor.mermaidLoading = false;
doRender();
};
document.head.appendChild(script);
}
},
// Print to PDF
printDocument: function() {
window.print();
},
// Download markdown file
downloadMarkdown: function(filename, content) {
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'document.md';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
// Attach mouse wheel zoom listener (Ctrl + Mouse Wheel over preview or direct Wheel over zoom controls)
attachPreviewZoomWheel: function(containerSelector, blazorInstance) {
const pane = document.querySelector(containerSelector || '.preview-pane');
if (!pane || pane._hasWheelZoom) return;
pane._hasWheelZoom = true;
pane.addEventListener('wheel', function(e) {
// Check if wheeling directly over zoom controls OR if holding Ctrl/Cmd over preview
const isOverControls = e.target.closest('.preview-zoom-controls');
if (e.ctrlKey || e.metaKey || isOverControls) {
e.preventDefault();
e.stopPropagation();
const delta = e.deltaY < 0 ? 10 : -10;
if (blazorInstance) {
blazorInstance.invokeMethodAsync('AdjustZoom', delta)
.catch(() => {});
}
}
}, { passive: false });
},
// Download standalone styled HTML file
downloadHtml: function(filename, title, htmlBody, theme) {
const fullHtml = `<!DOCTYPE html>
<html lang="en" data-theme="${theme || 'light'}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title || 'Notemark Document'}</title>
<style>
:root { --bg: #ffffff; --primary-text: #111827; --accent: #4f46e5; --border: #e5e7eb; --code-bg: #f1f5f9; --surface-hover: #f3f4f6; }
:root[data-theme="dark"] { --bg: #0b0f19; --primary-text: #f9fafb; --accent: #6366f1; --border: #1f2937; --code-bg: #1e293b; --surface-hover: #1f2937; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.7; background-color: var(--bg); color: var(--primary-text); max-width: 860px; margin: 2rem auto; padding: 0 1.5rem; }
h1, h2, h3, h4, h5, h6 { color: var(--primary-text); font-weight: 700; }
h1 { font-size: 2.2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
h2 { font-size: 1.6rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
code { background-color: var(--code-bg); color: var(--accent); padding: 0.2em 0.4em; border-radius: 4px; font-family: monospace; }
pre { background-color: var(--code-bg); padding: 1.25rem; border-radius: 8px; overflow-x: auto; border: 1px solid var(--border); }
pre code { background: none; color: inherit; padding: 0; }
blockquote { border-left: 4px solid var(--accent); padding: 0.5rem 1rem; margin: 1em 0; background: rgba(79, 70, 229, 0.08); }
table { width: 100%; border-collapse: collapse; margin: 1.5em 0; }
th, td { border: 1px solid var(--border); padding: 0.6rem 0.9rem; text-align: left; }
th { background-color: var(--surface-hover); }
img { max-width: 100%; border-radius: 8px; }
</style>
</head>
<body>
<article class="notemark-exported-document">
${htmlBody}
</article>
</body>
</html>`;
const blob = new Blob([fullHtml], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (filename || 'document').replace(/\.md$/, '') + '.html';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
};
+52 -50
View File
@@ -1,47 +1,58 @@
// Ultra-simple theme for iOS testing
// Notemark Theme Management
window.notemarkTheme = {
currentTheme: 'light',
init: function() {
console.log('Simple theme init');
this.applyTheme(this.currentTheme);
return this.currentTheme;
},
setTheme: function(theme) {
console.log('Setting theme:', theme);
this.currentTheme = theme;
this.applyTheme(theme);
},
applyTheme: function(theme) {
console.log('Applying theme:', theme);
const root = document.documentElement;
if (theme === 'dark') {
root.style.setProperty('--bg', '#1a1a1a');
root.style.setProperty('--surface', '#2d2d2d');
root.style.setProperty('--primary-text', '#ffffff');
root.style.setProperty('--secondary-text', '#cccccc');
root.style.setProperty('--border', '#404040');
root.style.setProperty('--accent', '#007acc');
} else {
root.style.setProperty('--bg', '#ffffff');
root.style.setProperty('--surface', '#f8f9fa');
root.style.setProperty('--primary-text', '#333333');
root.style.setProperty('--secondary-text', '#666666');
root.style.setProperty('--border', '#e9ecef');
root.style.setProperty('--accent', '#007acc');
getPreferredTheme: function() {
try {
const cookieMatch = document.cookie.match(/(?:^|; )notemark_theme=([^;]*)/);
const fromCookie = cookieMatch ? cookieMatch[1] : null;
const fromStorage = localStorage.getItem('notemark-theme');
const saved = fromStorage || fromCookie;
if (saved === 'dark' || saved === 'light') {
return saved;
}
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
} catch (e) {
console.warn('localStorage access failed:', e);
}
console.log('Theme applied successfully');
return 'light';
},
getCurrentTheme: function() {
return this.currentTheme;
const attr = document.documentElement.getAttribute('data-theme');
if (attr === 'dark' || attr === 'light') {
return attr;
}
return this.getPreferredTheme();
},
setTheme: function(theme) {
const targetTheme = (theme === 'dark') ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', targetTheme);
try {
localStorage.setItem('notemark-theme', targetTheme);
document.cookie = `notemark_theme=${targetTheme}; path=/; max-age=31536000; SameSite=Lax`;
window.dispatchEvent(new CustomEvent('notemark-theme-changed', { detail: targetTheme }));
} catch (e) {
console.warn('localStorage/cookie save failed:', e);
}
return targetTheme;
},
toggleTheme: function() {
const current = this.getCurrentTheme();
const next = (current === 'dark') ? 'light' : 'dark';
return this.setTheme(next);
},
init: function() {
const theme = this.getPreferredTheme();
document.documentElement.setAttribute('data-theme', theme);
return theme;
}
};
// Simple splitter drag functionality
// Splitter Drag Functionality
let splitterState = {
isDragging: false,
blazorInstance: null,
@@ -51,19 +62,14 @@ let splitterState = {
window.startSplitterDrag = function(blazorInstance) {
splitterState.blazorInstance = blazorInstance;
splitterState.isDragging = true;
// Find the editor layout container
splitterState.container = document.querySelector('.editor-layout');
// Add global mouse events
document.addEventListener('mousemove', handleSplitterMove);
document.addEventListener('mouseup', handleSplitterUp);
// Improve visual feedback during drag
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
// Add dragging class for visual feedback
const splitter = document.querySelector('.splitter');
if (splitter) {
splitter.classList.add('dragging');
@@ -77,8 +83,8 @@ function handleSplitterMove(e) {
const clientX = e.clientX - rect.left;
const containerWidth = rect.width;
// Call Blazor method to update split ratio
splitterState.blazorInstance.invokeMethodAsync('UpdateSplitRatio', clientX, containerWidth);
splitterState.blazorInstance.invokeMethodAsync('UpdateSplitRatio', clientX, containerWidth)
.catch(err => console.debug('Splitter invoke ignored', err));
}
function handleSplitterUp(e) {
@@ -86,26 +92,22 @@ function handleSplitterUp(e) {
splitterState.isDragging = false;
// Remove global mouse events
document.removeEventListener('mousemove', handleSplitterMove);
document.removeEventListener('mouseup', handleSplitterUp);
// Reset cursor and selection
document.body.style.cursor = '';
document.body.style.userSelect = '';
// Remove dragging visual state
const splitter = document.querySelector('.splitter');
if (splitter) {
splitter.classList.remove('dragging');
}
// Notify Blazor that dragging stopped
if (splitterState.blazorInstance) {
splitterState.blazorInstance.invokeMethodAsync('StopDragging');
splitterState.blazorInstance.invokeMethodAsync('StopDragging')
.catch(err => console.debug('StopDragging invoke ignored', err));
}
// Clean up
splitterState.blazorInstance = null;
splitterState.container = null;
}
+130
View File
@@ -0,0 +1,130 @@
/* Prism theme adapting to Notemark CSS variables */
code[class*="language-"],
pre[class*="language-"] {
color: var(--primary-text);
background: none;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', Consolas, Monaco, monospace;
font-size: 0.9em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.6;
tab-size: 2;
hyphens: none;
}
/* Light theme token colors */
:root[data-theme="light"] .token.comment,
:root[data-theme="light"] .token.prolog,
:root[data-theme="light"] .token.doctype,
:root[data-theme="light"] .token.cdata {
color: #94a3b8;
font-style: italic;
}
:root[data-theme="light"] .token.punctuation {
color: #64748b;
}
:root[data-theme="light"] .token.property,
:root[data-theme="light"] .token.tag,
:root[data-theme="light"] .token.boolean,
:root[data-theme="light"] .token.number,
:root[data-theme="light"] .token.constant,
:root[data-theme="light"] .token.symbol,
:root[data-theme="light"] .token.deleted {
color: #e11d48;
}
:root[data-theme="light"] .token.selector,
:root[data-theme="light"] .token.attr-name,
:root[data-theme="light"] .token.string,
:root[data-theme="light"] .token.char,
:root[data-theme="light"] .token.builtin,
:root[data-theme="light"] .token.inserted {
color: #059669;
}
:root[data-theme="light"] .token.operator,
:root[data-theme="light"] .token.entity,
:root[data-theme="light"] .token.url,
:root[data-theme="light"] .token.variable {
color: #d97706;
}
:root[data-theme="light"] .token.atrule,
:root[data-theme="light"] .token.attr-value,
:root[data-theme="light"] .token.keyword {
color: #4f46e5;
font-weight: 600;
}
:root[data-theme="light"] .token.function,
:root[data-theme="light"] .token.class-name {
color: #2563eb;
font-weight: 600;
}
:root[data-theme="light"] .token.regex,
:root[data-theme="light"] .token.important {
color: #ca8a04;
}
/* Dark theme token colors */
:root[data-theme="dark"] .token.comment,
:root[data-theme="dark"] .token.prolog,
:root[data-theme="dark"] .token.doctype,
:root[data-theme="dark"] .token.cdata {
color: #64748b;
font-style: italic;
}
:root[data-theme="dark"] .token.punctuation {
color: #94a3b8;
}
:root[data-theme="dark"] .token.property,
:root[data-theme="dark"] .token.tag,
:root[data-theme="dark"] .token.boolean,
:root[data-theme="dark"] .token.number,
:root[data-theme="dark"] .token.constant,
:root[data-theme="dark"] .token.symbol,
:root[data-theme="dark"] .token.deleted {
color: #fb7185;
}
:root[data-theme="dark"] .token.selector,
:root[data-theme="dark"] .token.attr-name,
:root[data-theme="dark"] .token.string,
:root[data-theme="dark"] .token.char,
:root[data-theme="dark"] .token.builtin,
:root[data-theme="dark"] .token.inserted {
color: #34d399;
}
:root[data-theme="dark"] .token.operator,
:root[data-theme="dark"] .token.entity,
:root[data-theme="dark"] .token.url,
:root[data-theme="dark"] .token.variable {
color: #fbbf24;
}
:root[data-theme="dark"] .token.atrule,
:root[data-theme="dark"] .token.attr-value,
:root[data-theme="dark"] .token.keyword {
color: #818cf8;
font-weight: 600;
}
:root[data-theme="dark"] .token.function,
:root[data-theme="dark"] .token.class-name {
color: #60a5fa;
font-weight: 600;
}
:root[data-theme="dark"] .token.regex,
:root[data-theme="dark"] .token.important {
color: #facc15;
}
File diff suppressed because one or more lines are too long