fixes and improvements
This commit is contained in:
@@ -1,5 +1,20 @@
|
|||||||
<!DOCTYPE html>
|
@inject IHttpContextAccessor HttpContextAccessor
|
||||||
<html lang="en">
|
|
||||||
|
@{
|
||||||
|
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>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
@@ -9,8 +24,24 @@
|
|||||||
<meta name="apple-mobile-web-app-title" content="Notemark" />
|
<meta name="apple-mobile-web-app-title" content="Notemark" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<base href="/" />
|
<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 />
|
<ResourcePreloader />
|
||||||
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
|
<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["app.css"]" />
|
||||||
<link rel="stylesheet" href="@Assets["Notemark.styles.css"]" />
|
<link rel="stylesheet" href="@Assets["Notemark.styles.css"]" />
|
||||||
<ImportMap />
|
<ImportMap />
|
||||||
@@ -32,22 +63,9 @@
|
|||||||
<Routes />
|
<Routes />
|
||||||
<ReconnectModal />
|
<ReconnectModal />
|
||||||
<script src="js/theme.js"></script>
|
<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 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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,38 +1,142 @@
|
|||||||
@page "/"
|
@page "/editor"
|
||||||
|
@page "/editor/{DocumentId}"
|
||||||
|
@page "/doc/{DocumentId}"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
@implements IDisposable
|
||||||
@using Notemark.Services
|
@using Notemark.Services
|
||||||
|
@using Notemark.Models
|
||||||
@inject ISessionService SessionService
|
@inject ISessionService SessionService
|
||||||
|
@inject IDocumentService DocumentService
|
||||||
@inject IMarkdownService MarkdownService
|
@inject IMarkdownService MarkdownService
|
||||||
@inject IShareService ShareService
|
@inject IShareService ShareService
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@inject IHttpContextAccessor HttpContextAccessor
|
@inject IHttpContextAccessor HttpContextAccessor
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
<PageTitle>Notemark</PageTitle>
|
<PageTitle>@(string.IsNullOrWhiteSpace(documentTitle) ? "Notemark" : $"{documentTitle} - Notemark")</PageTitle>
|
||||||
|
|
||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
<!-- Top Bar -->
|
<!-- Top Bar -->
|
||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
<div class="top-bar-left">
|
<div class="top-bar-left">
|
||||||
<button class="btn-new" @onclick="NewDocument" title="Create new document (Ctrl+N)">
|
<button class="btn-topbar btn-home-nav" @onclick="GoToHome" title="Back to Home Dashboard">
|
||||||
📄 New
|
<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>
|
||||||
<button class="btn-toggle @(showPreview ? "active" : "")"
|
|
||||||
@onclick="TogglePreview">
|
<button class="btn-topbar btn-notes" @onclick="ToggleNotesDrawer" title="View all & flagged notes">
|
||||||
👁️ @(showPreview ? "Hide Preview" : "Show 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="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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="top-bar-center">
|
<div class="top-bar-center">
|
||||||
<span class="save-status @saveStatus.ToLower()">@saveStatus</span>
|
<span class="save-status @saveStatus.ToLower()">@saveStatus</span>
|
||||||
|
<span class="doc-stats" title="Words • Characters • Reading Time">
|
||||||
|
@wordCount words • @charCount chars • @readingTime min read
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="top-bar-right">
|
<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>
|
||||||
|
|
||||||
|
<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">
|
<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)
|
@if (showShareDropdown)
|
||||||
{
|
{
|
||||||
<div class="dropdown-backdrop" @onclick="CloseShareDropdown"></div>
|
<div class="dropdown-backdrop" @onclick="CloseShareDropdown"></div>
|
||||||
@@ -47,123 +151,361 @@
|
|||||||
<label>View Only</label>
|
<label>View Only</label>
|
||||||
<div class="link-group">
|
<div class="link-group">
|
||||||
<input type="text" readonly value="@readonlyLink" class="link-field" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="share-item">
|
<div class="share-item">
|
||||||
<label>Can Edit</label>
|
<label>Can Edit</label>
|
||||||
<div class="link-group">
|
<div class="link-group">
|
||||||
<input type="text" readonly value="@editableLink" class="link-field" />
|
<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>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</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></></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">
|
||||||
|
• 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 -->
|
<!-- Editor Layout -->
|
||||||
<div class="editor-layout @(showPreview ? "split-view" : "full-view")" @ref="editorLayoutRef">
|
<div class="editor-layout @(showPreview ? "split-view" : "full-view")" @ref="editorLayoutRef">
|
||||||
<!-- Editor Pane -->
|
<!-- Editor Pane -->
|
||||||
<div class="editor-pane" style="@GetEditorPaneStyle()">
|
<div class="editor-pane" style="@GetEditorPaneStyle()">
|
||||||
<textarea class="markdown-editor"
|
<textarea class="markdown-editor"
|
||||||
|
id="notemark-editor-textarea"
|
||||||
|
@ref="editorTextareaRef"
|
||||||
@bind="markdownContent"
|
@bind="markdownContent"
|
||||||
@oninput="OnContentChanged"
|
@oninput="OnContentChanged"
|
||||||
@onkeydown="OnKeyDown"
|
@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">
|
spellcheck="true">
|
||||||
</textarea>
|
</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Simple Splitter -->
|
<!-- Splitter -->
|
||||||
@if (showPreview)
|
@if (showPreview)
|
||||||
{
|
{
|
||||||
<div class="splitter @(isDragging ? "dragging" : "")" @onmousedown="OnSplitterMouseDown" @onmousedown:preventDefault="true">
|
<div class="splitter @(isDragging ? "dragging" : "")" @onmousedown="OnSplitterMouseDown" @onmousedown:preventDefault="true">
|
||||||
<div class="splitter-handle"></div>
|
<div class="splitter-handle"></div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Preview Pane -->
|
<!-- Preview Pane -->
|
||||||
@if (showPreview)
|
@if (showPreview)
|
||||||
{
|
{
|
||||||
<div class="preview-pane" style="@GetPreviewPaneStyle()">
|
<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)
|
@((MarkupString)renderedHtml)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Share Modal -->
|
<!-- Notes Drawer Modal -->
|
||||||
@* Removed modal - now using dropdown *@
|
<NotesDrawer IsOpen="@showNotesDrawer"
|
||||||
|
IsOpenChanged="@((val) => showNotesDrawer = val)"
|
||||||
|
CurrentDocumentId="@sessionId"
|
||||||
|
OnSelectDocument="@OnDocumentSelected" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
[Parameter] public string? DocumentId { get; set; }
|
||||||
|
|
||||||
private string sessionId = "";
|
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 markdownContent = "";
|
||||||
private string renderedHtml = "";
|
private string renderedHtml = "";
|
||||||
private bool showPreview = false;
|
private bool showPreview = true;
|
||||||
private string saveStatus = "Saved";
|
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
|
// Share dropdown state
|
||||||
private bool showShareDropdown = false;
|
private bool showShareDropdown = false;
|
||||||
private bool isGeneratingLinks = false;
|
private bool isGeneratingLinks = false;
|
||||||
private string readonlyLink = "";
|
private string readonlyLink = "";
|
||||||
private string editableLink = "";
|
private string editableLink = "";
|
||||||
|
private string copiedLink = "";
|
||||||
|
|
||||||
// Auto-save timer
|
// Auto-save timer
|
||||||
private Timer? autoSaveTimer;
|
private Timer? autoSaveTimer;
|
||||||
private bool hasUnsavedChanges = false;
|
private bool hasUnsavedChanges = false;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
// Get or create session
|
|
||||||
var httpContext = HttpContextAccessor.HttpContext;
|
var httpContext = HttpContextAccessor.HttpContext;
|
||||||
if (httpContext != null)
|
if (!string.IsNullOrEmpty(DocumentId))
|
||||||
|
{
|
||||||
|
sessionId = DocumentId;
|
||||||
|
}
|
||||||
|
else if (httpContext != null)
|
||||||
{
|
{
|
||||||
sessionId = SessionService.GetOrCreateSessionId(httpContext);
|
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() ?? "";
|
markdownContent = await SessionService.LoadSessionContentAsync(sessionId);
|
||||||
hasUnsavedChanges = true;
|
var meta = await DocumentService.GetDocumentAsync(sessionId);
|
||||||
|
if (meta != null)
|
||||||
// Update preview if visible
|
{
|
||||||
|
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)
|
if (showPreview)
|
||||||
{
|
{
|
||||||
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
|
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounced autosave - cancel previous timer and start new one
|
await UpdateFlaggedCount();
|
||||||
autoSaveTimer?.Dispose();
|
}
|
||||||
autoSaveTimer = new Timer(async _ =>
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
{
|
{
|
||||||
await InvokeAsync(async () => await AutoSave());
|
try
|
||||||
}, null, 2000, Timeout.Infinite); // Increased to 2 seconds for production
|
{
|
||||||
|
// Save current document ID to localStorage as the last worked on note on this device
|
||||||
// Only update status if it was previously "Saved"
|
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "notemark-last-doc-id", sessionId);
|
||||||
if (saveStatus == "Saved")
|
|
||||||
|
// 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();
|
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()
|
private async Task AutoSave()
|
||||||
{
|
{
|
||||||
if (hasUnsavedChanges && !string.IsNullOrEmpty(sessionId))
|
if (hasUnsavedChanges && !string.IsNullOrEmpty(sessionId))
|
||||||
@@ -174,61 +516,68 @@
|
|||||||
|
|
||||||
private async Task SaveContent()
|
private async Task SaveContent()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(sessionId) || !hasUnsavedChanges)
|
if (string.IsNullOrEmpty(sessionId)) return;
|
||||||
return;
|
|
||||||
|
|
||||||
var previousStatus = saveStatus;
|
|
||||||
|
|
||||||
// Set saving status only when actually starting to save
|
|
||||||
saveStatus = "Saving...";
|
saveStatus = "Saving...";
|
||||||
StateHasChanged(); // Use StateHasChanged instead of InvokeAsync
|
StateHasChanged();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Longer timeout for large content (30 seconds)
|
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
|
||||||
|
|
||||||
await SessionService.SaveSessionContentAsync(sessionId, markdownContent);
|
await SessionService.SaveSessionContentAsync(sessionId, markdownContent);
|
||||||
|
await DocumentService.SaveDocumentAsync(sessionId, markdownContent, isFlagged, documentTitle, currentTags);
|
||||||
hasUnsavedChanges = false;
|
hasUnsavedChanges = false;
|
||||||
saveStatus = "Saved";
|
saveStatus = "Saved";
|
||||||
|
await UpdateFlaggedCount();
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch
|
||||||
{
|
|
||||||
saveStatus = "Timeout";
|
|
||||||
// Simplified retry - no immediate re-rendering
|
|
||||||
_ = Task.Delay(5000).ContinueWith(async _ =>
|
|
||||||
{
|
|
||||||
if (hasUnsavedChanges)
|
|
||||||
{
|
|
||||||
await InvokeAsync(async () => await SaveContent());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
{
|
||||||
saveStatus = "Error";
|
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
|
StateHasChanged();
|
||||||
if (saveStatus != previousStatus)
|
}
|
||||||
|
|
||||||
|
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()
|
private void TogglePreview()
|
||||||
{
|
{
|
||||||
showPreview = !showPreview;
|
showPreview = !showPreview;
|
||||||
|
|
||||||
if (showPreview)
|
if (showPreview)
|
||||||
{
|
{
|
||||||
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
|
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
|
||||||
@@ -237,16 +586,38 @@
|
|||||||
|
|
||||||
private async Task OnKeyDown(KeyboardEventArgs e)
|
private async Task OnKeyDown(KeyboardEventArgs e)
|
||||||
{
|
{
|
||||||
// Ctrl+S or Cmd+S for manual save
|
if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("s", StringComparison.OrdinalIgnoreCase))
|
||||||
if ((e.CtrlKey || e.MetaKey) && e.Key == "s")
|
|
||||||
{
|
{
|
||||||
await ManualSave();
|
await ManualSave();
|
||||||
}
|
}
|
||||||
// Ctrl+N or Cmd+N for new document
|
else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("n", StringComparison.OrdinalIgnoreCase))
|
||||||
else if ((e.CtrlKey || e.MetaKey) && e.Key == "n")
|
|
||||||
{
|
{
|
||||||
await NewDocument();
|
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()
|
private async Task ManualSave()
|
||||||
@@ -256,34 +627,59 @@
|
|||||||
|
|
||||||
private async Task NewDocument()
|
private async Task NewDocument()
|
||||||
{
|
{
|
||||||
// Ask for confirmation if there are unsaved changes
|
|
||||||
if (hasUnsavedChanges)
|
if (hasUnsavedChanges)
|
||||||
{
|
{
|
||||||
bool confirmed = await JSRuntime.InvokeAsync<bool>("confirm",
|
await SaveContent();
|
||||||
"You have unsaved changes. Are you sure you want to create a new document?");
|
|
||||||
if (!confirmed)
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple approach: Clear current session content
|
sessionId = Guid.NewGuid().ToString();
|
||||||
markdownContent = "";
|
markdownContent = "# Untitled Note\n\nStart typing here... #ideas";
|
||||||
renderedHtml = "";
|
documentTitle = "Untitled Note";
|
||||||
|
isFlagged = false;
|
||||||
hasUnsavedChanges = 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";
|
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()
|
private async Task ToggleShareDropdown()
|
||||||
@@ -294,23 +690,21 @@
|
|||||||
isGeneratingLinks = true;
|
isGeneratingLinks = true;
|
||||||
readonlyLink = "";
|
readonlyLink = "";
|
||||||
editableLink = "";
|
editableLink = "";
|
||||||
|
|
||||||
await InvokeAsync(StateHasChanged);
|
StateHasChanged();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Generate share tokens
|
|
||||||
var readonlyToken = await ShareService.CreateShareTokenAsync(sessionId, "readonly");
|
var readonlyToken = await ShareService.CreateShareTokenAsync(sessionId, "readonly");
|
||||||
var editableToken = await ShareService.CreateShareTokenAsync(sessionId, "editable");
|
var editableToken = await ShareService.CreateShareTokenAsync(sessionId, "editable");
|
||||||
|
|
||||||
// Build URLs
|
|
||||||
var httpContext = HttpContextAccessor.HttpContext;
|
var httpContext = HttpContextAccessor.HttpContext;
|
||||||
if (httpContext != null)
|
var baseUrl = httpContext != null
|
||||||
{
|
? $"{httpContext.Request.Scheme}://{httpContext.Request.Host}"
|
||||||
var baseUrl = $"{httpContext.Request.Scheme}://{httpContext.Request.Host}";
|
: Navigation.BaseUri.TrimEnd('/');
|
||||||
readonlyLink = $"{baseUrl}/s/{readonlyToken}";
|
|
||||||
editableLink = $"{baseUrl}/e/{editableToken}";
|
readonlyLink = $"{baseUrl}/s/{readonlyToken}";
|
||||||
}
|
editableLink = $"{baseUrl}/e/{editableToken}";
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -320,7 +714,7 @@
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
isGeneratingLinks = false;
|
isGeneratingLinks = false;
|
||||||
await InvokeAsync(StateHasChanged);
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -332,6 +726,7 @@
|
|||||||
private void CloseShareDropdown()
|
private void CloseShareDropdown()
|
||||||
{
|
{
|
||||||
showShareDropdown = false;
|
showShareDropdown = false;
|
||||||
|
copiedLink = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CopyToClipboard(string text)
|
private async Task CopyToClipboard(string text)
|
||||||
@@ -339,46 +734,20 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", text);
|
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
|
catch
|
||||||
{
|
{
|
||||||
// Fallback for browsers that don't support clipboard API
|
|
||||||
await JSRuntime.InvokeVoidAsync("prompt", "Copy this link:", text);
|
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()
|
private string GetEditorPaneStyle()
|
||||||
{
|
{
|
||||||
if (!showPreview) return "";
|
if (!showPreview) return "";
|
||||||
@@ -394,20 +763,16 @@
|
|||||||
private async Task OnSplitterMouseDown(MouseEventArgs e)
|
private async Task OnSplitterMouseDown(MouseEventArgs e)
|
||||||
{
|
{
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
await JSRuntime.InvokeVoidAsync("startSplitterDrag", DotNetObjectReference.Create(this));
|
dotNetRef?.Dispose();
|
||||||
|
dotNetRef = DotNetObjectReference.Create(this);
|
||||||
|
await JSRuntime.InvokeVoidAsync("startSplitterDrag", dotNetRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
[JSInvokable]
|
[JSInvokable]
|
||||||
public async Task UpdateSplitRatio(double clientX, double containerWidth)
|
public async Task UpdateSplitRatio(double clientX, double containerWidth)
|
||||||
{
|
{
|
||||||
if (!isDragging || containerWidth <= 0) return;
|
if (!isDragging || containerWidth <= 0) return;
|
||||||
|
splitRatio = Math.Max(0.2, Math.Min(0.8, clientX / containerWidth));
|
||||||
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
|
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,20 +780,71 @@
|
|||||||
public async Task StopDragging()
|
public async Task StopDragging()
|
||||||
{
|
{
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
|
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
|
|
||||||
// Save split ratio to localStorage when dragging stops
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "notemark-split-ratio", splitRatio.ToString("F2"));
|
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "notemark-split-ratio", splitRatio.ToString("F2"));
|
||||||
}
|
}
|
||||||
catch
|
catch { }
|
||||||
{
|
|
||||||
// Ignore localStorage errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single StateHasChanged at the end
|
|
||||||
StateHasChanged();
|
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
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"
|
@page "/e/{token}"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
@implements IDisposable
|
||||||
@using Notemark.Services
|
@using Notemark.Services
|
||||||
@inject IShareService ShareService
|
@inject IShareService ShareService
|
||||||
@inject ISessionService SessionService
|
@inject ISessionService SessionService
|
||||||
@@ -227,6 +228,18 @@ else
|
|||||||
Navigation.NavigateTo("/");
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
autoSaveTimer?.Dispose();
|
autoSaveTimer?.Dispose();
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
@page "/s/{token}"
|
@page "/s/{token}"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
@implements IDisposable
|
||||||
@using Notemark.Services
|
@using Notemark.Services
|
||||||
@inject IShareService ShareService
|
@inject IShareService ShareService
|
||||||
@inject ISessionService SessionService
|
@inject ISessionService SessionService
|
||||||
@inject IMarkdownService MarkdownService
|
@inject IMarkdownService MarkdownService
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
|
||||||
<PageTitle>Notemark - Shared Document</PageTitle>
|
<PageTitle>Notemark - Shared Document</PageTitle>
|
||||||
|
|
||||||
@@ -37,6 +39,17 @@ else
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<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")
|
@if (shareToken?.Mode == "editable")
|
||||||
{
|
{
|
||||||
<button class="btn-edit" @onclick="GoToEditor">✏️ Edit</button>
|
<button class="btn-edit" @onclick="GoToEditor">✏️ Edit</button>
|
||||||
@@ -57,7 +70,7 @@ else
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="preview-content">
|
<div class="preview-content" style="@GetPreviewContentStyle()">
|
||||||
@((MarkupString)renderedHtml)
|
@((MarkupString)renderedHtml)
|
||||||
</div>
|
</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()
|
private void GoToEditor()
|
||||||
{
|
{
|
||||||
if (shareToken?.Mode == "editable")
|
if (shareToken?.Mode == "editable")
|
||||||
@@ -136,4 +205,9 @@ else
|
|||||||
{
|
{
|
||||||
Navigation.NavigateTo("/");
|
Navigation.NavigateTo("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
dotNetRef?.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -47,28 +47,28 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1rem 2rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background-color: var(--surface);
|
background-color: var(--surface);
|
||||||
border-bottom: 2px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left h1 {
|
.header-left h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-badge {
|
.share-badge {
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.2rem 0.6rem;
|
||||||
border-radius: 20px;
|
border-radius: 999px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -76,54 +76,103 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.share-badge.readonly {
|
.share-badge.readonly {
|
||||||
background-color: var(--secondary-text);
|
background-color: var(--border);
|
||||||
color: white;
|
color: var(--secondary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-badge.editable {
|
.share-badge.editable {
|
||||||
background-color: var(--success);
|
background-color: rgba(16, 185, 129, 0.15);
|
||||||
color: white;
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 */
|
/* Buttons */
|
||||||
.btn-edit,
|
.btn-edit,
|
||||||
.btn-home {
|
.btn-home {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.45rem 0.85rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 7px;
|
||||||
background-color: var(--surface);
|
background-color: var(--surface);
|
||||||
color: var(--primary-text);
|
color: var(--primary-text);
|
||||||
font-size: 0.875rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
text-decoration: none;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-edit {
|
.btn-edit {
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
color: white;
|
color: #ffffff;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-edit:hover {
|
.btn-edit:hover {
|
||||||
background-color: var(--accent-hover);
|
background-color: var(--accent-hover);
|
||||||
border-color: var(--accent-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-home:hover {
|
.btn-home:hover {
|
||||||
background-color: var(--accent);
|
background-color: var(--surface-hover);
|
||||||
color: white;
|
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content Area */
|
/* Content Area */
|
||||||
@@ -151,14 +200,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-content {
|
.preview-content {
|
||||||
max-width: 800px;
|
max-width: 850px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 3rem 2rem;
|
padding: 2.5rem 2rem;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.7;
|
||||||
|
color: var(--primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Markdown Preview Styles (same as editor) */
|
/* Markdown Preview Typography */
|
||||||
.preview-content h1,
|
.preview-content h1,
|
||||||
.preview-content h2,
|
.preview-content h2,
|
||||||
.preview-content h3,
|
.preview-content h3,
|
||||||
@@ -166,118 +216,181 @@
|
|||||||
.preview-content h5,
|
.preview-content h5,
|
||||||
.preview-content h6 {
|
.preview-content h6 {
|
||||||
color: var(--primary-text);
|
color: var(--primary-text);
|
||||||
margin-top: 2em;
|
font-weight: 700;
|
||||||
margin-bottom: 1em;
|
line-height: 1.3;
|
||||||
font-weight: 600;
|
margin-top: 1.75em;
|
||||||
|
margin-bottom: 0.6em;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-content h1 { font-size: 2.5em; margin-top: 0; }
|
.preview-content h1:first-child,
|
||||||
.preview-content h2 { font-size: 2em; }
|
.preview-content h2:first-child,
|
||||||
.preview-content h3 { font-size: 1.5em; }
|
.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 {
|
.preview-content p {
|
||||||
margin-bottom: 1.5em;
|
margin-top: 0;
|
||||||
color: var(--primary-text);
|
margin-bottom: 1.1em;
|
||||||
font-size: 1.1em;
|
font-size: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-content code {
|
.preview-content a {
|
||||||
background-color: var(--bg);
|
|
||||||
padding: 0.2em 0.4em;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-family: 'JetBrains Mono', 'SF Mono', 'Monaco', monospace;
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-content pre {
|
.preview-content a:hover {
|
||||||
background-color: var(--bg);
|
color: var(--accent-hover);
|
||||||
padding: 1.5em;
|
}
|
||||||
border-radius: 8px;
|
|
||||||
overflow-x: auto;
|
.preview-content :not(pre) > code {
|
||||||
margin-bottom: 1.5em;
|
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);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-content pre code {
|
.preview-content pre {
|
||||||
background: none;
|
position: relative;
|
||||||
padding: 0;
|
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);
|
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 {
|
.preview-content blockquote {
|
||||||
|
margin: 1.25em 0;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
border-left: 4px solid var(--accent);
|
border-left: 4px solid var(--accent);
|
||||||
padding-left: 1.5em;
|
background-color: var(--accent-light);
|
||||||
margin-left: 0;
|
border-radius: 0 8px 8px 0;
|
||||||
margin-bottom: 1.5em;
|
color: var(--primary-text);
|
||||||
color: var(--secondary-text);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-content ul,
|
|
||||||
.preview-content ol {
|
|
||||||
margin-bottom: 1.5em;
|
|
||||||
padding-left: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-content li {
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-content table {
|
.preview-content table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: separate;
|
||||||
margin-bottom: 1.5em;
|
border-spacing: 0;
|
||||||
|
margin: 1.5em 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-content th,
|
.preview-content th,
|
||||||
.preview-content td {
|
.preview-content td {
|
||||||
border: 1px solid var(--border);
|
padding: 0.75rem 1rem;
|
||||||
padding: 0.75em 1em;
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
text-align: left;
|
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 {
|
.preview-content th {
|
||||||
background-color: var(--bg);
|
background-color: var(--surface-hover);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-content img {
|
.preview-content tr:hover td {
|
||||||
max-width: 100%;
|
background-color: var(--surface-hover);
|
||||||
height: auto;
|
}
|
||||||
border-radius: 6px;
|
|
||||||
|
.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 {
|
.preview-content hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 2px solid var(--border);
|
height: 1px;
|
||||||
margin: 3em 0;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.shared-header {
|
.shared-header {
|
||||||
padding: 1rem;
|
padding: 0.75rem 1rem;
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-content {
|
.preview-content {
|
||||||
padding: 2rem 1rem;
|
padding: 1.5rem 1rem;
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit,
|
|
||||||
.btn-home {
|
|
||||||
flex: 1;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,49 +1,83 @@
|
|||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
|
@inject IHttpContextAccessor HttpContextAccessor
|
||||||
|
|
||||||
<button class="theme-toggle" @onclick="ToggleTheme" title="@GetThemeTooltip()">
|
<button class="theme-toggle" @onclick="ToggleTheme" title="@GetThemeTooltip()" aria-label="@GetThemeTooltip()">
|
||||||
@GetThemeIcon()
|
@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>
|
</button>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private string currentTheme = "light";
|
private string currentTheme = "light";
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await JSRuntime.InvokeVoidAsync("notemarkTheme.init");
|
var cookie = HttpContextAccessor.HttpContext?.Request.Cookies["notemark_theme"];
|
||||||
currentTheme = await JSRuntime.InvokeAsync<string>("notemarkTheme.getCurrentTheme");
|
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}");
|
try
|
||||||
currentTheme = "light";
|
{
|
||||||
|
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()
|
private async Task ToggleTheme()
|
||||||
{
|
{
|
||||||
// Simple toggle: light <-> dark
|
|
||||||
currentTheme = currentTheme == "light" ? "dark" : "light";
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await JSRuntime.InvokeVoidAsync("notemarkTheme.setTheme", currentTheme);
|
var nextTheme = await JSRuntime.InvokeAsync<string>("notemarkTheme.toggleTheme");
|
||||||
System.Diagnostics.Debug.WriteLine($"Theme switched to: {currentTheme}");
|
currentTheme = nextTheme;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
System.Diagnostics.Debug.WriteLine($"Theme toggle error: {ex.Message}");
|
System.Diagnostics.Debug.WriteLine($"Theme toggle error: {ex.Message}");
|
||||||
|
currentTheme = currentTheme == "dark" ? "light" : "dark";
|
||||||
}
|
}
|
||||||
|
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetThemeIcon()
|
|
||||||
{
|
|
||||||
return currentTheme == "dark" ? "🌙" : "☀️";
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetThemeTooltip()
|
private string GetThemeTooltip()
|
||||||
{
|
{
|
||||||
return currentTheme == "dark" ? "Switch to light mode" : "Switch to dark mode";
|
return currentTheme == "dark" ? "Switch to light mode" : "Switch to dark mode";
|
||||||
|
|||||||
@@ -1,35 +1,48 @@
|
|||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: none;
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
color: var(--primary-text);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 2.5rem;
|
min-width: 2.25rem;
|
||||||
height: 2.5rem;
|
height: 2.25rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle:hover {
|
.theme-toggle:hover {
|
||||||
background-color: var(--accent);
|
background-color: var(--surface-hover);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
transform: scale(1.05);
|
color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle:active {
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
min-width: 2rem;
|
min-width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
padding: 0.375rem;
|
padding: 0.375rem;
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -12,6 +12,7 @@ builder.Services.AddHttpContextAccessor();
|
|||||||
|
|
||||||
// Register Notemark services
|
// Register Notemark services
|
||||||
builder.Services.AddSingleton<IMarkdownService, MarkdownService>();
|
builder.Services.AddSingleton<IMarkdownService, MarkdownService>();
|
||||||
|
builder.Services.AddSingleton<IDocumentService, DocumentService>();
|
||||||
builder.Services.AddScoped<ISessionService, SessionService>();
|
builder.Services.AddScoped<ISessionService, SessionService>();
|
||||||
builder.Services.AddScoped<IShareService, ShareService>();
|
builder.Services.AddScoped<IShareService, ShareService>();
|
||||||
|
|
||||||
@@ -40,8 +41,7 @@ app.UseHttpsRedirection();
|
|||||||
// Enable WebSockets
|
// Enable WebSockets
|
||||||
app.UseWebSockets(new WebSocketOptions
|
app.UseWebSockets(new WebSocketOptions
|
||||||
{
|
{
|
||||||
KeepAliveInterval = TimeSpan.FromMinutes(2),
|
KeepAliveInterval = TimeSpan.FromMinutes(2)
|
||||||
ReceiveBufferSize = 4 * 1024
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enable cookies for session management
|
// Enable cookies for session management
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -69,8 +69,8 @@ public class SessionService : ISessionService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use FileStream for large content to avoid memory pressure
|
// Use FileStream with FileShare.ReadWrite to avoid locking collisions
|
||||||
await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 8192, useAsync: true);
|
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);
|
await using var writer = new StreamWriter(fileStream, Encoding.UTF8);
|
||||||
|
|
||||||
// Write in chunks for very large content
|
// Write in chunks for very large content
|
||||||
|
|||||||
@@ -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
@@ -1,122 +1,126 @@
|
|||||||
/* Notemark Theme Variables */
|
/* Notemark Global Theme System */
|
||||||
:root {
|
:root {
|
||||||
/* Light Mode (Default) */
|
/* Default Light Mode Variables */
|
||||||
--bg: #F9FAFB;
|
--bg: #F9FAFB;
|
||||||
--surface: #FFFFFF;
|
--surface: #FFFFFF;
|
||||||
|
--surface-hover: #F3F4F6;
|
||||||
--border: #E5E7EB;
|
--border: #E5E7EB;
|
||||||
|
--border-subtle: #F3F4F6;
|
||||||
--primary-text: #111827;
|
--primary-text: #111827;
|
||||||
--secondary-text: #6B7280;
|
--secondary-text: #6B7280;
|
||||||
--accent: #4F46E5;
|
--accent: #4F46E5;
|
||||||
--accent-hover: #4338CA;
|
--accent-hover: #4338CA;
|
||||||
|
--accent-light: rgba(79, 70, 229, 0.1);
|
||||||
--success: #10B981;
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme]) {
|
:root:not([data-theme]) {
|
||||||
--bg: #0B0F19;
|
--bg: #0B0F19;
|
||||||
--surface: #111827;
|
--surface: #111827;
|
||||||
|
--surface-hover: #1F2937;
|
||||||
--border: #1F2937;
|
--border: #1F2937;
|
||||||
|
--border-subtle: #182234;
|
||||||
--primary-text: #F9FAFB;
|
--primary-text: #F9FAFB;
|
||||||
--secondary-text: #9CA3AF;
|
--secondary-text: #9CA3AF;
|
||||||
--accent: #22D3EE;
|
--accent: #6366F1;
|
||||||
--accent-hover: #06B6D4;
|
--accent-hover: #818CF8;
|
||||||
--success: #4ADE80;
|
--accent-light: rgba(99, 102, 241, 0.15);
|
||||||
--error: #FB7185;
|
--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 */
|
/* Explicit light mode */
|
||||||
:root[data-theme="light"] {
|
:root[data-theme="light"] {
|
||||||
--bg: #F9FAFB !important;
|
--bg: #F9FAFB;
|
||||||
--surface: #FFFFFF !important;
|
--surface: #FFFFFF;
|
||||||
--border: #E5E7EB !important;
|
--surface-hover: #F3F4F6;
|
||||||
--primary-text: #111827 !important;
|
--border: #E5E7EB;
|
||||||
--secondary-text: #6B7280 !important;
|
--border-subtle: #F3F4F6;
|
||||||
--accent: #4F46E5 !important;
|
--primary-text: #111827;
|
||||||
--accent-hover: #4338CA !important;
|
--secondary-text: #6B7280;
|
||||||
--success: #10B981 !important;
|
--accent: #4F46E5;
|
||||||
--error: #F43F5E !important;
|
--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 */
|
/* Explicit dark mode */
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
--bg: #0B0F19 !important;
|
--bg: #0B0F19;
|
||||||
--surface: #111827 !important;
|
--surface: #111827;
|
||||||
--border: #1F2937 !important;
|
--surface-hover: #1F2937;
|
||||||
--primary-text: #F9FAFB !important;
|
--border: #1F2937;
|
||||||
--secondary-text: #9CA3AF !important;
|
--border-subtle: #182234;
|
||||||
--accent: #22D3EE !important;
|
--primary-text: #F9FAFB;
|
||||||
--accent-hover: #06B6D4 !important;
|
--secondary-text: #9CA3AF;
|
||||||
--success: #4ADE80 !important;
|
--accent: #6366F1;
|
||||||
--error: #FB7185 !important;
|
--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 */
|
/* Apply theme variables globally */
|
||||||
html, body {
|
html, body {
|
||||||
background-color: var(--bg) !important;
|
background-color: var(--bg);
|
||||||
color: var(--primary-text) !important;
|
color: var(--primary-text);
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
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;
|
||||||
html, body {
|
padding: 0;
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
a, .btn-link {
|
a, .btn-link {
|
||||||
color: #006bb7;
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: #1b6ec2;
|
background-color: var(--accent);
|
||||||
border-color: #1861ac;
|
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 {
|
.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;
|
box-shadow: 0 0 0 0.15rem var(--accent-light), 0 0 0 0.25rem var(--accent);
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.blazor-error-boundary {
|
.blazor-error-boundary {
|
||||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA69.NzMwOSAyNjcuODc5IDY5LjU4NzcgMjY3Ljg3OSA2OS4zMTc5IDI2Ny44NzkgNjcuMTEyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
background: #b32121;
|
||||||
padding: 1rem 1rem 1rem 3.7rem;
|
padding: 1rem 1rem 1rem 2rem;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blazor-error-boundary::after {
|
.blazor-error-boundary::after {
|
||||||
content: "An error has occurred."
|
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;
|
|
||||||
}
|
}
|
||||||
@@ -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\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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,47 +1,58 @@
|
|||||||
// Ultra-simple theme for iOS testing
|
// Notemark Theme Management
|
||||||
window.notemarkTheme = {
|
window.notemarkTheme = {
|
||||||
currentTheme: 'light',
|
getPreferredTheme: function() {
|
||||||
|
try {
|
||||||
init: function() {
|
const cookieMatch = document.cookie.match(/(?:^|; )notemark_theme=([^;]*)/);
|
||||||
console.log('Simple theme init');
|
const fromCookie = cookieMatch ? cookieMatch[1] : null;
|
||||||
this.applyTheme(this.currentTheme);
|
const fromStorage = localStorage.getItem('notemark-theme');
|
||||||
return this.currentTheme;
|
const saved = fromStorage || fromCookie;
|
||||||
},
|
if (saved === 'dark' || saved === 'light') {
|
||||||
|
return saved;
|
||||||
setTheme: function(theme) {
|
}
|
||||||
console.log('Setting theme:', theme);
|
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
this.currentTheme = theme;
|
return 'dark';
|
||||||
this.applyTheme(theme);
|
}
|
||||||
},
|
} catch (e) {
|
||||||
|
console.warn('localStorage access failed:', e);
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
console.log('Theme applied successfully');
|
return 'light';
|
||||||
},
|
},
|
||||||
|
|
||||||
getCurrentTheme: function() {
|
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 = {
|
let splitterState = {
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
blazorInstance: null,
|
blazorInstance: null,
|
||||||
@@ -51,19 +62,14 @@ let splitterState = {
|
|||||||
window.startSplitterDrag = function(blazorInstance) {
|
window.startSplitterDrag = function(blazorInstance) {
|
||||||
splitterState.blazorInstance = blazorInstance;
|
splitterState.blazorInstance = blazorInstance;
|
||||||
splitterState.isDragging = true;
|
splitterState.isDragging = true;
|
||||||
|
|
||||||
// Find the editor layout container
|
|
||||||
splitterState.container = document.querySelector('.editor-layout');
|
splitterState.container = document.querySelector('.editor-layout');
|
||||||
|
|
||||||
// Add global mouse events
|
|
||||||
document.addEventListener('mousemove', handleSplitterMove);
|
document.addEventListener('mousemove', handleSplitterMove);
|
||||||
document.addEventListener('mouseup', handleSplitterUp);
|
document.addEventListener('mouseup', handleSplitterUp);
|
||||||
|
|
||||||
// Improve visual feedback during drag
|
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
document.body.style.cursor = 'col-resize';
|
document.body.style.cursor = 'col-resize';
|
||||||
|
|
||||||
// Add dragging class for visual feedback
|
|
||||||
const splitter = document.querySelector('.splitter');
|
const splitter = document.querySelector('.splitter');
|
||||||
if (splitter) {
|
if (splitter) {
|
||||||
splitter.classList.add('dragging');
|
splitter.classList.add('dragging');
|
||||||
@@ -77,8 +83,8 @@ function handleSplitterMove(e) {
|
|||||||
const clientX = e.clientX - rect.left;
|
const clientX = e.clientX - rect.left;
|
||||||
const containerWidth = rect.width;
|
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) {
|
function handleSplitterUp(e) {
|
||||||
@@ -86,26 +92,22 @@ function handleSplitterUp(e) {
|
|||||||
|
|
||||||
splitterState.isDragging = false;
|
splitterState.isDragging = false;
|
||||||
|
|
||||||
// Remove global mouse events
|
|
||||||
document.removeEventListener('mousemove', handleSplitterMove);
|
document.removeEventListener('mousemove', handleSplitterMove);
|
||||||
document.removeEventListener('mouseup', handleSplitterUp);
|
document.removeEventListener('mouseup', handleSplitterUp);
|
||||||
|
|
||||||
// Reset cursor and selection
|
|
||||||
document.body.style.cursor = '';
|
document.body.style.cursor = '';
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
|
|
||||||
// Remove dragging visual state
|
|
||||||
const splitter = document.querySelector('.splitter');
|
const splitter = document.querySelector('.splitter');
|
||||||
if (splitter) {
|
if (splitter) {
|
||||||
splitter.classList.remove('dragging');
|
splitter.classList.remove('dragging');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify Blazor that dragging stopped
|
|
||||||
if (splitterState.blazorInstance) {
|
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.blazorInstance = null;
|
||||||
splitterState.container = null;
|
splitterState.container = null;
|
||||||
}
|
}
|
||||||
|
|||||||
+130
@@ -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;
|
||||||
|
}
|
||||||
+30
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user