diff --git a/Notemark/Components/App.razor b/Notemark/Components/App.razor index 8003bb5..6299e97 100644 --- a/Notemark/Components/App.razor +++ b/Notemark/Components/App.razor @@ -1,5 +1,20 @@ - - +@inject IHttpContextAccessor HttpContextAccessor + +@{ + var initialTheme = "light"; + try + { + var cookie = HttpContextAccessor.HttpContext?.Request.Cookies["notemark_theme"]; + if (!string.IsNullOrEmpty(cookie) && (cookie == "dark" || cookie == "light")) + { + initialTheme = cookie; + } + } + catch { } +} + + + @@ -9,8 +24,24 @@ + + + + + @@ -32,22 +63,9 @@ + + - diff --git a/Notemark/Components/Pages/Editor.razor b/Notemark/Components/Pages/Editor.razor index db1a694..09952d6 100644 --- a/Notemark/Components/Pages/Editor.razor +++ b/Notemark/Components/Pages/Editor.razor @@ -1,38 +1,142 @@ -@page "/" +@page "/editor" +@page "/editor/{DocumentId}" +@page "/doc/{DocumentId}" @rendermode InteractiveServer +@implements IDisposable @using Notemark.Services +@using Notemark.Models @inject ISessionService SessionService +@inject IDocumentService DocumentService @inject IMarkdownService MarkdownService @inject IShareService ShareService @inject IJSRuntime JSRuntime @inject IHttpContextAccessor HttpContextAccessor +@inject NavigationManager Navigation -Notemark +@(string.IsNullOrWhiteSpace(documentTitle) ? "Notemark" : $"{documentTitle} - Notemark")
- - + + + + + +
+ +
+ + + @if (currentTags.Any()) + { +
+ @foreach (var tag in currentTags.Take(3)) + { + #@tag + } +
+ }
- +
@saveStatus + + @wordCount words • @charCount chars • @readingTime min read +
- +
- + + + + +
+ + + @if (showExportDropdown) + { + +
+ + + +
+ } +
+ + - + }
}
+
+ +
+ + + + + + + + + + + + + + + + + +
+
- - + + @if (showPreview) {
} - + @if (showPreview) {
-
+
+ Live Preview +
+ + + +
+
+
@((MarkupString)renderedHtml)
}
- - @* Removed modal - now using dropdown *@ + +
@code { + [Parameter] public string? DocumentId { get; set; } + private string sessionId = ""; + private string documentTitle = "Untitled Note"; + private bool isFlagged = false; + private int flaggedCount = 0; + private List currentTags = new(); private string markdownContent = ""; private string renderedHtml = ""; - private bool showPreview = false; + private bool showPreview = true; private string saveStatus = "Saved"; - + private bool showNotesDrawer = false; + private bool showExportDropdown = false; + private ElementReference editorTextareaRef; + private ElementReference editorLayoutRef; + private DotNetObjectReference? dotNetRef; + private double splitRatio = 0.5; + private bool isDragging = false; + private int previewZoom = 100; + + // Undo / Redo History Stack + private readonly List undoStack = new(); + private readonly List redoStack = new(); + private bool canUndo => undoStack.Count > 0; + private bool canRedo => redoStack.Count > 0; + + // Word stats + private int wordCount = 0; + private int charCount = 0; + private int readingTime = 1; + // Share dropdown state private bool showShareDropdown = false; private bool isGeneratingLinks = false; private string readonlyLink = ""; private string editableLink = ""; - + private string copiedLink = ""; + // Auto-save timer private Timer? autoSaveTimer; private bool hasUnsavedChanges = false; protected override async Task OnInitializedAsync() { - // Get or create session var httpContext = HttpContextAccessor.HttpContext; - if (httpContext != null) + if (!string.IsNullOrEmpty(DocumentId)) + { + sessionId = DocumentId; + } + else if (httpContext != null) { sessionId = SessionService.GetOrCreateSessionId(httpContext); - - // Load existing content - markdownContent = await SessionService.LoadSessionContentAsync(sessionId); - - // Render initial preview if needed - if (showPreview) - { - renderedHtml = MarkdownService.RenderToHtml(markdownContent); - } } + else + { + sessionId = Guid.NewGuid().ToString(); + } + + await LoadDocumentData(); } - private async Task OnContentChanged(ChangeEventArgs e) + private async Task LoadDocumentData() { - markdownContent = e.Value?.ToString() ?? ""; - hasUnsavedChanges = true; - - // Update preview if visible + markdownContent = await SessionService.LoadSessionContentAsync(sessionId); + var meta = await DocumentService.GetDocumentAsync(sessionId); + if (meta != null) + { + documentTitle = meta.Title; + isFlagged = meta.IsFlagged; + currentTags = meta.Tags; + } + else + { + documentTitle = DocumentService.ExtractTitle(markdownContent); + isFlagged = false; + currentTags = DocumentService.ExtractTags(markdownContent); + } + + undoStack.Clear(); + redoStack.Clear(); + + UpdateStatistics(); if (showPreview) { renderedHtml = MarkdownService.RenderToHtml(markdownContent); } - - // Debounced autosave - cancel previous timer and start new one - autoSaveTimer?.Dispose(); - autoSaveTimer = new Timer(async _ => + + await UpdateFlaggedCount(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) { - await InvokeAsync(async () => await AutoSave()); - }, null, 2000, Timeout.Infinite); // Increased to 2 seconds for production - - // Only update status if it was previously "Saved" - if (saveStatus == "Saved") + try + { + // Save current document ID to localStorage as the last worked on note on this device + await JSRuntime.InvokeVoidAsync("localStorage.setItem", "notemark-last-doc-id", sessionId); + + // Initialize Tab handling, image paste/drop, and scroll sync + await JSRuntime.InvokeVoidAsync("notemarkEditor.initTextarea", editorTextareaRef); + + // Load saved split ratio + var savedRatio = await JSRuntime.InvokeAsync("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("localStorage.getItem", "notemark-preview-zoom"); + if (!string.IsNullOrEmpty(savedZoom) && int.TryParse(savedZoom, out var z)) + { + previewZoom = Math.Clamp(z, 70, 200); + StateHasChanged(); + } + + // Attach mouse wheel zoom listener (Ctrl + Wheel over preview or direct scroll over zoom controls) + dotNetRef ??= DotNetObjectReference.Create(this); + await JSRuntime.InvokeVoidAsync("notemarkEditor.attachPreviewZoomWheel", ".preview-pane", dotNetRef); + } + catch { } + } + + // Highlight code and render Mermaid in preview + if (showPreview && !string.IsNullOrEmpty(renderedHtml)) { - saveStatus = "Modified"; + try + { + await JSRuntime.InvokeVoidAsync("notemarkEditor.highlightPreview", ".preview-content"); + } + catch { } + } + } + + private void UpdateStatistics() + { + charCount = markdownContent.Length; + var words = markdownContent.Split(new[] { ' ', '\r', '\n', '\t' }, StringSplitOptions.RemoveEmptyEntries); + wordCount = words.Length; + readingTime = Math.Max(1, (int)Math.Ceiling(wordCount / 200.0)); + currentTags = DocumentService.ExtractTags(markdownContent); + } + + private async Task OnContentChanged(ChangeEventArgs e) + { + var newContent = e.Value?.ToString() ?? ""; + if (newContent != markdownContent) + { + // Push to undo stack (max 50 states) + if (undoStack.Count > 50) undoStack.RemoveAt(0); + undoStack.Add(markdownContent); + redoStack.Clear(); + + markdownContent = newContent; + hasUnsavedChanges = true; + UpdateStatistics(); + + if (documentTitle == "Untitled Note" || string.IsNullOrWhiteSpace(documentTitle)) + { + documentTitle = DocumentService.ExtractTitle(markdownContent); + } + + if (showPreview) + { + renderedHtml = MarkdownService.RenderToHtml(markdownContent); + } + + // Debounced autosave + autoSaveTimer?.Dispose(); + autoSaveTimer = new Timer(async _ => + { + await InvokeAsync(async () => await AutoSave()); + }, null, 1500, Timeout.Infinite); + + if (saveStatus == "Saved") + { + saveStatus = "Modified"; + StateHasChanged(); + } + } + } + + private void Undo() + { + if (canUndo) + { + var prev = undoStack[^1]; + undoStack.RemoveAt(undoStack.Count - 1); + redoStack.Add(markdownContent); + markdownContent = prev; + hasUnsavedChanges = true; + UpdateStatistics(); + if (showPreview) renderedHtml = MarkdownService.RenderToHtml(markdownContent); StateHasChanged(); } } + private void Redo() + { + if (canRedo) + { + var next = redoStack[^1]; + redoStack.RemoveAt(redoStack.Count - 1); + undoStack.Add(markdownContent); + markdownContent = next; + hasUnsavedChanges = true; + UpdateStatistics(); + if (showPreview) renderedHtml = MarkdownService.RenderToHtml(markdownContent); + StateHasChanged(); + } + } + + private async Task OnTitleChanged(ChangeEventArgs e) + { + documentTitle = e.Value?.ToString() ?? "Untitled Note"; + hasUnsavedChanges = true; + await SaveContent(); + } + private async Task AutoSave() { if (hasUnsavedChanges && !string.IsNullOrEmpty(sessionId)) @@ -174,61 +516,68 @@ private async Task SaveContent() { - if (string.IsNullOrEmpty(sessionId) || !hasUnsavedChanges) - return; + if (string.IsNullOrEmpty(sessionId)) return; - var previousStatus = saveStatus; - - // Set saving status only when actually starting to save saveStatus = "Saving..."; - StateHasChanged(); // Use StateHasChanged instead of InvokeAsync - + StateHasChanged(); + try { - // Longer timeout for large content (30 seconds) - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await SessionService.SaveSessionContentAsync(sessionId, markdownContent); - + await DocumentService.SaveDocumentAsync(sessionId, markdownContent, isFlagged, documentTitle, currentTags); hasUnsavedChanges = false; saveStatus = "Saved"; + await UpdateFlaggedCount(); } - catch (OperationCanceledException) - { - saveStatus = "Timeout"; - // Simplified retry - no immediate re-rendering - _ = Task.Delay(5000).ContinueWith(async _ => - { - if (hasUnsavedChanges) - { - await InvokeAsync(async () => await SaveContent()); - } - }); - } - catch (Exception) + catch { saveStatus = "Error"; - // Simplified retry - no immediate re-rendering - _ = Task.Delay(3000).ContinueWith(async _ => - { - if (hasUnsavedChanges) - { - await InvokeAsync(async () => await SaveContent()); - } - }); } - - // Only trigger re-render if status actually changed - if (saveStatus != previousStatus) + + StateHasChanged(); + } + + private async Task ToggleFlag() + { + isFlagged = await DocumentService.ToggleFlagAsync(sessionId); + await UpdateFlaggedCount(); + } + + private async Task UpdateFlaggedCount() + { + try { - StateHasChanged(); + var flagged = await DocumentService.GetFlaggedDocumentsAsync(); + flaggedCount = flagged.Count; } + catch { } + } + + private void ToggleNotesDrawer() + { + showNotesDrawer = !showNotesDrawer; + } + + private void ToggleExportDropdown() + { + showExportDropdown = !showExportDropdown; + } + + private async Task OnDocumentSelected(string docId) + { + if (hasUnsavedChanges) + { + await SaveContent(); + } + + sessionId = docId; + await LoadDocumentData(); + StateHasChanged(); } private void TogglePreview() { showPreview = !showPreview; - if (showPreview) { renderedHtml = MarkdownService.RenderToHtml(markdownContent); @@ -237,16 +586,38 @@ private async Task OnKeyDown(KeyboardEventArgs e) { - // Ctrl+S or Cmd+S for manual save - if ((e.CtrlKey || e.MetaKey) && e.Key == "s") + if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("s", StringComparison.OrdinalIgnoreCase)) { await ManualSave(); } - // Ctrl+N or Cmd+N for new document - else if ((e.CtrlKey || e.MetaKey) && e.Key == "n") + else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("n", StringComparison.OrdinalIgnoreCase)) { await NewDocument(); } + else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("z", StringComparison.OrdinalIgnoreCase) && !e.ShiftKey) + { + Undo(); + } + else if ((e.CtrlKey || e.MetaKey) && (e.Key.Equals("y", StringComparison.OrdinalIgnoreCase) || (e.Key.Equals("z", StringComparison.OrdinalIgnoreCase) && e.ShiftKey))) + { + Redo(); + } + else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("b", StringComparison.OrdinalIgnoreCase)) + { + await InsertFormatting("**", "**", "bold text"); + } + else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("i", StringComparison.OrdinalIgnoreCase)) + { + await InsertFormatting("*", "*", "italic text"); + } + else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("k", StringComparison.OrdinalIgnoreCase)) + { + await InsertFormatting("[", "](https://example.com)", "link text"); + } + else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("p", StringComparison.OrdinalIgnoreCase)) + { + await PrintDocument(); + } } private async Task ManualSave() @@ -256,34 +627,59 @@ private async Task NewDocument() { - // Ask for confirmation if there are unsaved changes if (hasUnsavedChanges) { - bool confirmed = await JSRuntime.InvokeAsync("confirm", - "You have unsaved changes. Are you sure you want to create a new document?"); - if (!confirmed) - return; + await SaveContent(); } - // Simple approach: Clear current session content - markdownContent = ""; - renderedHtml = ""; + sessionId = Guid.NewGuid().ToString(); + markdownContent = "# Untitled Note\n\nStart typing here... #ideas"; + documentTitle = "Untitled Note"; + isFlagged = false; hasUnsavedChanges = false; - saveStatus = "New Document"; - - // Save empty content to current session - if (!string.IsNullOrEmpty(sessionId)) - { - await SessionService.SaveSessionContentAsync(sessionId, ""); - } - - // Update UI - await InvokeAsync(StateHasChanged); - - // Reset save status after a moment - await Task.Delay(1500); saveStatus = "Saved"; - await InvokeAsync(StateHasChanged); + + undoStack.Clear(); + redoStack.Clear(); + + UpdateStatistics(); + renderedHtml = MarkdownService.RenderToHtml(markdownContent); + + await DocumentService.SaveDocumentAsync(sessionId, markdownContent, isFlagged, documentTitle, currentTags); + await SessionService.SaveSessionContentAsync(sessionId, markdownContent); + await UpdateFlaggedCount(); + + StateHasChanged(); + } + + private async Task InsertFormatting(string prefix, string suffix, string defaultText) + { + try + { + await JSRuntime.InvokeVoidAsync("notemarkEditor.wrapSelection", editorTextareaRef, prefix, suffix, defaultText); + } + catch { } + } + + private async Task ExportMarkdown() + { + showExportDropdown = false; + var filename = $"{documentTitle.Replace(" ", "_").ToLowerInvariant()}.md"; + await JSRuntime.InvokeVoidAsync("notemarkEditor.downloadMarkdown", filename, markdownContent); + } + + private async Task ExportHtml() + { + showExportDropdown = false; + var filename = $"{documentTitle.Replace(" ", "_").ToLowerInvariant()}.html"; + var currentTheme = await JSRuntime.InvokeAsync("notemarkTheme.getCurrentTheme"); + await JSRuntime.InvokeVoidAsync("notemarkEditor.downloadHtml", filename, documentTitle, renderedHtml, currentTheme); + } + + private async Task PrintDocument() + { + showExportDropdown = false; + await JSRuntime.InvokeVoidAsync("notemarkEditor.printDocument"); } private async Task ToggleShareDropdown() @@ -294,23 +690,21 @@ isGeneratingLinks = true; readonlyLink = ""; editableLink = ""; - - await InvokeAsync(StateHasChanged); - + + StateHasChanged(); + try { - // Generate share tokens var readonlyToken = await ShareService.CreateShareTokenAsync(sessionId, "readonly"); var editableToken = await ShareService.CreateShareTokenAsync(sessionId, "editable"); - - // Build URLs + var httpContext = HttpContextAccessor.HttpContext; - if (httpContext != null) - { - var baseUrl = $"{httpContext.Request.Scheme}://{httpContext.Request.Host}"; - readonlyLink = $"{baseUrl}/s/{readonlyToken}"; - editableLink = $"{baseUrl}/e/{editableToken}"; - } + var baseUrl = httpContext != null + ? $"{httpContext.Request.Scheme}://{httpContext.Request.Host}" + : Navigation.BaseUri.TrimEnd('/'); + + readonlyLink = $"{baseUrl}/s/{readonlyToken}"; + editableLink = $"{baseUrl}/e/{editableToken}"; } catch { @@ -320,7 +714,7 @@ finally { isGeneratingLinks = false; - await InvokeAsync(StateHasChanged); + StateHasChanged(); } } else @@ -332,6 +726,7 @@ private void CloseShareDropdown() { showShareDropdown = false; + copiedLink = ""; } private async Task CopyToClipboard(string text) @@ -339,46 +734,20 @@ try { await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", text); - // Could add a toast notification here in the future + copiedLink = text; + StateHasChanged(); + _ = Task.Delay(2000).ContinueWith(_ => + { + copiedLink = ""; + InvokeAsync(StateHasChanged); + }); } catch { - // Fallback for browsers that don't support clipboard API await JSRuntime.InvokeVoidAsync("prompt", "Copy this link:", text); } } - public void Dispose() - { - autoSaveTimer?.Dispose(); - } - - // Simple splitter functionality - private ElementReference editorLayoutRef; - private double splitRatio = 0.5; // 50/50 by default - private bool isDragging = false; - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - try - { - // Load saved split ratio from localStorage - var savedRatio = await JSRuntime.InvokeAsync("localStorage.getItem", "notemark-split-ratio"); - if (!string.IsNullOrEmpty(savedRatio) && double.TryParse(savedRatio, out var ratio)) - { - splitRatio = Math.Max(0.2, Math.Min(0.8, ratio)); - StateHasChanged(); - } - } - catch - { - // Ignore localStorage errors - } - } - } - private string GetEditorPaneStyle() { if (!showPreview) return ""; @@ -394,20 +763,16 @@ private async Task OnSplitterMouseDown(MouseEventArgs e) { isDragging = true; - await JSRuntime.InvokeVoidAsync("startSplitterDrag", DotNetObjectReference.Create(this)); + dotNetRef?.Dispose(); + dotNetRef = DotNetObjectReference.Create(this); + await JSRuntime.InvokeVoidAsync("startSplitterDrag", dotNetRef); } [JSInvokable] public async Task UpdateSplitRatio(double clientX, double containerWidth) { if (!isDragging || containerWidth <= 0) return; - - var newRatio = Math.Max(0.2, Math.Min(0.8, clientX / containerWidth)); // Clamp between 20% and 80% - - // Update immediately for smooth dragging - splitRatio = newRatio; - - // Use StateHasChanged for immediate visual feedback + splitRatio = Math.Max(0.2, Math.Min(0.8, clientX / containerWidth)); StateHasChanged(); } @@ -415,20 +780,71 @@ public async Task StopDragging() { if (!isDragging) return; - isDragging = false; - - // Save split ratio to localStorage when dragging stops try { await JSRuntime.InvokeVoidAsync("localStorage.setItem", "notemark-split-ratio", splitRatio.ToString("F2")); } - catch - { - // Ignore localStorage errors - } - - // Single StateHasChanged at the end + catch { } StateHasChanged(); } + + [JSInvokable] + public void AdjustZoom(int delta) + { + previewZoom = Math.Clamp(previewZoom + delta, 70, 200); + SavePreviewZoom(); + StateHasChanged(); + } + + private void ZoomIn() + { + previewZoom = Math.Min(200, previewZoom + 10); + SavePreviewZoom(); + StateHasChanged(); + } + + private void ZoomOut() + { + previewZoom = Math.Max(70, previewZoom - 10); + SavePreviewZoom(); + StateHasChanged(); + } + + private void ResetZoom() + { + previewZoom = 100; + SavePreviewZoom(); + StateHasChanged(); + } + + private async void SavePreviewZoom() + { + try + { + await JSRuntime.InvokeVoidAsync("localStorage.setItem", "notemark-preview-zoom", previewZoom.ToString()); + } + catch { } + } + + private string GetPreviewContentStyle() + { + var factor = (previewZoom / 100.0).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture); + return $"zoom: {factor}; font-size: calc(1rem * {factor}); --preview-zoom: {factor};"; + } + + private async Task GoToHome() + { + if (hasUnsavedChanges) + { + await SaveContent(); + } + Navigation.NavigateTo("/"); + } + + public void Dispose() + { + autoSaveTimer?.Dispose(); + dotNetRef?.Dispose(); + } } \ No newline at end of file diff --git a/Notemark/Components/Pages/Editor.razor.css b/Notemark/Components/Pages/Editor.razor.css index 3d95185..0f738a8 100644 --- a/Notemark/Components/Pages/Editor.razor.css +++ b/Notemark/Components/Pages/Editor.razor.css @@ -1,9 +1,10 @@ -/* Notemark Editor Styles */ +/* Notemark Main Editor Layout */ .editor-container { height: 100vh; display: flex; flex-direction: column; background-color: var(--bg); + overflow: hidden; } /* Top Bar */ @@ -11,11 +12,13 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.75rem 1rem; + padding: 0.5rem 1rem; background-color: var(--surface); border-bottom: 1px solid var(--border); - height: 60px; + min-height: 54px; flex-shrink: 0; + gap: 0.75rem; + z-index: 100; } .top-bar-left, @@ -23,124 +26,249 @@ .top-bar-right { display: flex; align-items: center; - gap: 0.75rem; + gap: 0.5rem; } .top-bar-center { flex: 1; justify-content: center; + gap: 1rem; } -/* Buttons */ -.btn-toggle, -.btn-share, -.btn-save, -.btn-new, -.btn-copy, -.btn-close { - background: none; - border: 1px solid var(--border); - color: var(--primary-text); - padding: 0.5rem 1rem; - border-radius: 6px; - cursor: pointer; - font-size: 0.875rem; - font-family: inherit; - transition: all 0.2s ease; -} - -.btn-save { - padding: 0.5rem; - min-width: 2.5rem; +/* Document Title Input */ +.doc-title-wrapper { display: flex; align-items: center; - justify-content: center; - font-size: 1rem; } -.btn-save:enabled:hover { - background-color: var(--success); - color: white; - border-color: var(--success); +.doc-title-input { + background: none; + border: 1px solid transparent; + border-radius: 6px; + padding: 0.35rem 0.6rem; + font-size: 0.9375rem; + font-weight: 600; + color: var(--primary-text); + outline: none; + transition: all 0.15s ease; + max-width: 200px; +} + +.doc-title-input:hover { + border-color: var(--border); + background-color: var(--surface-hover); +} + +.doc-title-input:focus { + border-color: var(--accent); + background-color: var(--bg); + box-shadow: 0 0 0 2px var(--accent-light); +} + +/* Topbar Tag Pills */ +.topbar-tags { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.topbar-tag-pill { + font-size: 0.7rem; + font-weight: 600; + padding: 0.15rem 0.45rem; + background-color: var(--accent-light); + color: var(--accent); + border-radius: 999px; + border: 1px solid var(--border); +} + +/* Stats Pill */ +.doc-stats { + font-size: 0.75rem; + color: var(--secondary-text); + background-color: var(--bg); + padding: 0.25rem 0.6rem; + border-radius: 999px; + border: 1px solid var(--border); + white-space: nowrap; +} + +/* Topbar Buttons */ +.btn-topbar { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.45rem 0.75rem; + background-color: var(--surface); + border: 1px solid var(--border); + color: var(--primary-text); + border-radius: 7px; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: var(--shadow-sm); + white-space: nowrap; + height: 34px; +} + +.btn-topbar:hover:not(:disabled) { + background-color: var(--surface-hover); + border-color: var(--accent); + color: var(--accent); transform: translateY(-1px); } -.btn-save:disabled { - opacity: 0.5; - cursor: not-allowed; - background-color: transparent; +.btn-topbar:active:not(:disabled) { + transform: translateY(0); } -.btn-save:disabled:hover { - background-color: transparent; - transform: none; +.btn-topbar:disabled { + opacity: 0.45; + cursor: not-allowed; + box-shadow: none; +} + +.btn-notes { + position: relative; + font-weight: 600; +} + +.notes-badge { + background-color: #ef4444; + color: #ffffff; + font-size: 0.6875rem; + font-weight: 700; + padding: 0.05rem 0.35rem; + border-radius: 999px; + line-height: 1.2; } .btn-new { - background-color: var(--success); - color: white; - border-color: var(--success); + color: var(--success); + border-color: var(--border); } .btn-new:hover { - background-color: var(--accent); - border-color: var(--accent); - transform: translateY(-1px); + background-color: rgba(16, 185, 129, 0.1); + border-color: var(--success); + color: var(--success); } -.btn-toggle:hover, -.btn-share:hover, -.btn-copy:hover { - background-color: var(--accent); - color: white; - border-color: var(--accent); +.btn-flag { + padding: 0.45rem 0.55rem; + font-size: 0.95rem; +} + +.btn-flag.flagged { + border-color: #ef4444; + background-color: rgba(239, 68, 68, 0.08); } .btn-toggle.active { - background-color: var(--accent); - color: white; + background-color: var(--accent-light); border-color: var(--accent); + color: var(--accent); } -.btn-close { - padding: 0.25rem 0.5rem; - font-size: 1.25rem; - line-height: 1; - background: none; - border: none; +.btn-save { + padding: 0.45rem 0.65rem; } -.btn-close:hover { - background-color: var(--error); - color: white; +.btn-save:enabled:hover { + background-color: rgba(16, 185, 129, 0.12); + border-color: var(--success); + color: var(--success); } -/* Save Status */ +.btn-export { + padding: 0.45rem 0.65rem; +} + +/* Save Status Indicator */ .save-status { - font-size: 0.875rem; - font-weight: 500; - padding: 0.25rem 0.75rem; - border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + padding: 0.2rem 0.5rem; + border-radius: 999px; + text-transform: capitalize; } .save-status.saved { color: var(--success); + background-color: rgba(16, 185, 129, 0.1); } .save-status.modified { color: var(--secondary-text); + background-color: var(--bg); + border: 1px solid var(--border); } .save-status.saving { color: var(--accent); + background-color: var(--accent-light); } .save-status.error { color: var(--error); + background-color: rgba(239, 68, 68, 0.1); } -.save-status.timeout { - color: var(--error); +/* Formatting Toolbar */ +.format-toolbar { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 1rem; + background-color: var(--surface); + border-bottom: 1px solid var(--border); + overflow-x: auto; + flex-shrink: 0; + scrollbar-width: none; +} + +.format-toolbar::-webkit-scrollbar { + display: none; +} + +.toolbar-btn { + background: none; + border: 1px solid transparent; + color: var(--secondary-text); + padding: 0.3rem 0.55rem; + border-radius: 5px; + font-size: 0.8125rem; + cursor: pointer; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + min-width: 28px; +} + +.toolbar-btn:hover:not(:disabled) { + background-color: var(--surface-hover); + border-color: var(--border); + color: var(--primary-text); +} + +.toolbar-btn:active:not(:disabled) { + background-color: var(--border); +} + +.toolbar-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.toolbar-divider { + width: 1px; + height: 18px; + background-color: var(--border); + margin: 0 0.25rem; } /* Editor Layout */ @@ -148,6 +276,7 @@ flex: 1; display: flex; min-height: 0; + overflow: hidden; } .editor-layout.full-view { @@ -164,10 +293,7 @@ display: flex; flex-direction: column; min-width: 0; -} - -.split-view .editor-pane { - border-right: 1px solid var(--border); + background-color: var(--bg); } .markdown-editor { @@ -176,20 +302,58 @@ border: none; outline: none; resize: none; - font-family: 'JetBrains Mono', 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; - font-size: 14px; - line-height: 1.6; - padding: 1.5rem; + font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', Consolas, Monaco, monospace; + font-size: 14.5px; + line-height: 1.7; + padding: 1.5rem 2rem; background-color: var(--bg); color: var(--primary-text); + tab-size: 2; + box-sizing: border-box; } .markdown-editor::placeholder { color: var(--secondary-text); + opacity: 0.6; } -.markdown-editor:focus { - outline: none; +/* Splitter */ +.splitter { + width: 8px; + background-color: var(--border); + cursor: col-resize; + display: flex; + align-items: center; + justify-content: center; + position: relative; + flex-shrink: 0; + transition: background-color 0.15s ease, width 0.15s ease; + z-index: 10; +} + +.splitter:hover { + background-color: var(--accent); + width: 9px; +} + +.splitter.dragging { + background-color: var(--accent); + width: 10px; +} + +.splitter-handle { + width: 2px; + height: 36px; + background-color: var(--secondary-text); + border-radius: 1px; + transition: all 0.15s ease; +} + +.splitter:hover .splitter-handle, +.splitter.dragging .splitter-handle { + background-color: #ffffff; + height: 50px; + width: 3px; } /* Preview Pane */ @@ -199,17 +363,91 @@ flex-direction: column; background-color: var(--surface); min-width: 0; + border-left: 1px solid var(--border); + position: relative; +} + +.preview-header-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.35rem 1rem; + background-color: var(--surface); + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; + font-size: 0.75rem; + color: var(--secondary-text); +} + +.preview-label { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.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); } .preview-content { flex: 1; - padding: 1.5rem; + padding: 2rem 2.5rem; overflow-y: auto; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; - line-height: 1.6; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + line-height: 1.7; + color: var(--primary-text); + max-width: 900px; + box-sizing: border-box; } -/* Markdown Preview Styles */ +/* Markdown Preview Typography */ .preview-content h1, .preview-content h2, .preview-content h3, @@ -217,77 +455,272 @@ .preview-content h5, .preview-content h6 { color: var(--primary-text); - margin-top: 2em; - margin-bottom: 1em; - font-weight: 600; + font-weight: 700; + line-height: 1.3; + margin-top: 1.75em; + margin-bottom: 0.6em; + letter-spacing: -0.02em; } -.preview-content h1 { font-size: 2em; } -.preview-content h2 { font-size: 1.5em; } -.preview-content h3 { font-size: 1.25em; } +.preview-content h1:first-child, +.preview-content h2:first-child, +.preview-content h3:first-child { + margin-top: 0; +} + +.preview-content h1 { + font-size: 2.15em; + border-bottom: 1px solid var(--border); + padding-bottom: 0.4em; +} + +.preview-content h2 { + font-size: 1.6em; + border-bottom: 1px solid var(--border-subtle); + padding-bottom: 0.3em; +} + +.preview-content h3 { font-size: 1.3em; } +.preview-content h4 { font-size: 1.1em; } +.preview-content h5 { font-size: 0.95em; text-transform: uppercase; letter-spacing: 0.05em; } +.preview-content h6 { font-size: 0.875em; color: var(--secondary-text); } .preview-content p { - margin-bottom: 1em; - color: var(--primary-text); + margin-top: 0; + margin-bottom: 1.1em; + font-size: 1em; } -.preview-content code { - background-color: var(--bg); - padding: 0.2em 0.4em; - border-radius: 3px; - font-family: 'JetBrains Mono', 'SF Mono', 'Monaco', monospace; - font-size: 0.875em; +.preview-content a { color: var(--accent); + text-decoration: underline; + text-underline-offset: 3px; + font-weight: 500; + transition: color 0.15s ease; } -.preview-content pre { - background-color: var(--bg); - padding: 1em; +.preview-content a:hover { + color: var(--accent-hover); +} + +/* Inline Code */ +.preview-content :not(pre) > code { + background-color: var(--code-bg); + color: var(--accent); + padding: 0.2em 0.45em; border-radius: 6px; - overflow-x: auto; - margin-bottom: 1em; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.875em; border: 1px solid var(--border); + font-weight: 500; +} + +/* Pre / Code Blocks */ +.preview-content pre { + position: relative; + background-color: var(--code-bg); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.25rem 1.5rem; + margin: 1.25em 0; + overflow-x: auto; + box-shadow: var(--shadow-sm); } .preview-content pre code { - background: none; - padding: 0; + font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; + font-size: 0.9em; + line-height: 1.6; +} + +/* Mermaid Rendered Charts */ +.mermaid-rendered { + display: flex; + justify-content: center; + margin: 1.5em 0; + padding: 1rem; + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + overflow-x: auto; +} + +/* Code Copy Button */ +.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; + box-shadow: var(--shadow-sm); + z-index: 5; +} + +.preview-content pre:hover .code-copy-btn { + opacity: 1; +} + +.code-copy-btn:hover { + color: var(--primary-text); + background-color: var(--surface-hover); + border-color: var(--accent); +} + +.code-copy-btn.copied { + color: var(--success); + border-color: var(--success); + opacity: 1; +} + +/* Blockquotes & Callouts / Alerts */ +.preview-content blockquote { + margin: 1.25em 0; + padding: 0.75rem 1.25rem; + border-left: 4px solid var(--accent); + background-color: var(--accent-light); + border-radius: 0 8px 8px 0; color: var(--primary-text); } -.preview-content blockquote { - border-left: 4px solid var(--accent); - padding-left: 1em; - margin-left: 0; - margin-bottom: 1em; - color: var(--secondary-text); -} - -.preview-content ul, -.preview-content ol { - margin-bottom: 1em; - padding-left: 2em; +.preview-content blockquote p:last-child { + margin-bottom: 0; } +/* Tables */ .preview-content table { width: 100%; - border-collapse: collapse; - margin-bottom: 1em; + border-collapse: separate; + border-spacing: 0; + margin: 1.5em 0; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + font-size: 0.9375rem; } .preview-content th, .preview-content td { - border: 1px solid var(--border); - padding: 0.5em 1em; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + border-right: 1px solid var(--border); text-align: left; } -.preview-content th { - background-color: var(--bg); - font-weight: 600; +.preview-content th:last-child, +.preview-content td:last-child { + border-right: none; } -/* Share Dropdown Styles */ +.preview-content tr:last-child td { + border-bottom: none; +} + +.preview-content th { + background-color: var(--surface-hover); + font-weight: 600; + color: var(--primary-text); +} + +.preview-content tr:hover td { + background-color: var(--surface-hover); +} + +/* Lists */ +.preview-content ul, +.preview-content ol { + padding-left: 1.75em; + margin-bottom: 1.1em; +} + +.preview-content li { + margin-bottom: 0.35em; +} + +/* Task Lists */ +.preview-content ul.task-list, +.preview-content li.task-list-item { + list-style: none; + padding-left: 0; +} + +.preview-content input[type="checkbox"] { + margin-right: 0.5em; + accent-color: var(--accent); + vertical-align: middle; + cursor: default; +} + +/* Horizontal Rule */ +.preview-content hr { + border: none; + height: 1px; + background-color: var(--border); + margin: 2em 0; +} + +/* Images */ +.preview-content img { + max-width: 100%; + border-radius: 8px; + box-shadow: var(--shadow-md); + margin: 1em 0; +} + +/* Export Container & Dropdown */ +.export-container { + position: relative; + display: inline-block; +} + +.export-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 0.5rem; + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: var(--shadow-lg); + padding: 0.5rem; + min-width: 230px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 0.25rem; + animation: dropdownFadeIn 0.15s cubic-bezier(0.16, 1, 0.3, 1); +} + +.export-menu-item { + width: 100%; + text-align: left; + background: none; + border: none; + padding: 0.6rem 0.8rem; + border-radius: 6px; + color: var(--primary-text); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; +} + +.export-menu-item:hover { + background-color: var(--surface-hover); + color: var(--accent); +} + +/* Share Dropdown */ .share-container { position: relative; display: inline-block; @@ -309,36 +742,18 @@ right: 0; margin-top: 0.5rem; background-color: var(--surface); - border: 2px solid var(--accent); - border-radius: 8px; - box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3); - padding: 1rem; + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: var(--shadow-lg); + padding: 1.25rem; width: 320px; z-index: 1000; - animation: dropdownFadeIn 0.15s ease-out; + animation: dropdownFadeIn 0.15s cubic-bezier(0.16, 1, 0.3, 1); } @keyframes dropdownFadeIn { - from { - opacity: 0; - transform: translateY(-8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.share-dropdown::before { - content: ''; - position: absolute; - top: -8px; - right: 1rem; - width: 0; - height: 0; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-bottom: 8px solid var(--accent); + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } } .share-item { @@ -351,9 +766,9 @@ .share-item label { display: block; - margin-bottom: 0.5rem; + margin-bottom: 0.4rem; font-weight: 600; - font-size: 0.875rem; + font-size: 0.75rem; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; @@ -361,187 +776,142 @@ .link-group { display: flex; - gap: 0.5rem; + gap: 0.4rem; align-items: center; } .link-field { flex: 1; - padding: 0.5rem 0.75rem; + padding: 0.45rem 0.65rem; border: 1px solid var(--border); - border-radius: 4px; + border-radius: 6px; background-color: var(--bg); color: var(--primary-text); - font-family: 'JetBrains Mono', 'SF Mono', 'Monaco', monospace; + font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; - font-weight: 500; } .link-field:focus { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2); } .btn-copy-small { - padding: 0.5rem; + padding: 0.45rem 0.65rem; background-color: var(--accent); - color: white; + color: #ffffff; border: none; - border-radius: 4px; + border-radius: 6px; cursor: pointer; - transition: all 0.2s ease; - font-size: 0.875rem; - display: flex; - align-items: center; - justify-content: center; - min-width: 2rem; + transition: all 0.15s ease; + font-size: 0.8125rem; } .btn-copy-small:hover { background-color: var(--accent-hover); - transform: scale(1.05); } -.loading-small { - text-align: center; - color: var(--accent); - padding: 1rem; - font-size: 0.875rem; - font-weight: 600; -} - -.loading-small::after { - content: ''; - display: inline-block; - width: 16px; - height: 16px; - border: 2px solid var(--border); - border-radius: 50%; - border-top-color: var(--accent); - animation: spin 1s ease-in-out infinite; - margin-left: 0.5rem; - vertical-align: middle; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -/* Splitter Styles */ -.splitter { - width: 8px; - background-color: var(--border); - cursor: col-resize; - display: flex; - align-items: center; - justify-content: center; - position: relative; - flex-shrink: 0; - transition: all 0.15s ease; -} - -.splitter:hover { - background-color: var(--accent); - width: 10px; -} - -.splitter.dragging { - background-color: var(--accent); - width: 12px; - box-shadow: 0 0 8px rgba(0, 122, 204, 0.3); -} - -.splitter-handle { - width: 2px; - height: 40px; - background-color: var(--secondary-text); - border-radius: 1px; - transition: all 0.15s ease; -} - -.splitter:hover .splitter-handle { - background-color: white; - width: 3px; - height: 50px; -} - -.splitter.dragging .splitter-handle { - background-color: white; - width: 4px; - height: 60px; -} - -/* Mobile Responsive - Place at end to override desktop styles */ +/* Mobile Responsive */ @media screen and (max-width: 768px) { + .top-bar { + padding: 0.4rem 0.6rem; + flex-wrap: wrap; + } + + .top-bar-center { + display: none; + } + + .topbar-tags { + display: none; + } + + .doc-title-input { + max-width: 140px; + font-size: 0.875rem; + } + + .btn-text { + display: none; + } + + .format-toolbar { + padding: 0.25rem 0.5rem; + } + + .preview-content { + padding: 1.25rem; + } + + .markdown-editor { + padding: 1.25rem; + font-size: 16px; + } + .editor-layout.split-view { flex-direction: column !important; } - - .split-view .editor-pane { - border-right: none; - border-bottom: 1px solid var(--border); - flex: 1; - } - - .preview-pane { - flex: 1; - } - + .splitter { display: none; } - - .top-bar { - padding: 0.5rem; - height: auto; - min-height: 50px; - flex-wrap: wrap; - gap: 0.5rem; - } - - .top-bar-left, - .top-bar-center, - .top-bar-right { - gap: 0.5rem; - } - - .btn-toggle, - .btn-share, - .btn-save, - .btn-new { - font-size: 14px; - padding: 8px 12px; - min-height: 44px; - white-space: nowrap; - } - - .btn-save { - min-width: 44px; - padding: 8px; - } - - .markdown-editor { - font-size: 16px; /* Prevent iOS zoom */ - padding: 1rem; - -webkit-overflow-scrolling: touch; - } - - .preview-content { - padding: 1rem; - -webkit-overflow-scrolling: touch; - } - - /* Share dropdown mobile adjustments */ - .share-dropdown { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 90vw; - max-width: 320px; - z-index: 1000; + + .preview-pane { + border-left: none; + border-top: 1px solid var(--border); } } +/* ========================================================= */ +/* PRINT / PDF EXPORT MEDIA QUERY STYLES */ +/* ========================================================= */ +@media print { + body, html { + background-color: #ffffff !important; + color: #000000 !important; + height: auto !important; + overflow: visible !important; + } + .top-bar, + .format-toolbar, + .splitter, + .editor-pane, + .notes-drawer, + .drawer-backdrop, + .code-copy-btn { + display: none !important; + } + + .editor-container { + height: auto !important; + display: block !important; + background: none !important; + } + + .editor-layout { + display: block !important; + } + + .preview-pane { + border: none !important; + background: none !important; + display: block !important; + } + + .preview-content { + max-width: 100% !important; + padding: 0 !important; + overflow: visible !important; + } + + .preview-content pre { + border: 1px solid #ddd !important; + background-color: #f8f9fa !important; + page-break-inside: avoid; + } + + .preview-content table { + page-break-inside: avoid; + } +} diff --git a/Notemark/Components/Pages/Home.razor b/Notemark/Components/Pages/Home.razor new file mode 100644 index 0000000..5ec2266 --- /dev/null +++ b/Notemark/Components/Pages/Home.razor @@ -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 + +Notemark - Home + +
+ +
+
+ + Notemark +
+ + + +
+ + +
+
+ + +
+ @if (isLoading) + { +
+
+

Loading...

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

New Blank Note

+

Start writing markdown from scratch

+
+
+ + @if (localRecentDoc != null) + { +
+
⚡
+
+
Your Last Note on this PC
+

@localRecentDoc.Title

+

@localRecentDoc.Snippet

+ Last edited @FormatDate(localRecentDoc.LastModifiedAt) +
+
+ } +
+ + + @if (activeTags.Any()) + { +
+ + @foreach (var tag in activeTags) + { + var count = displayedNotes.Count(d => d.Tags.Any(t => t.Equals(tag, StringComparison.OrdinalIgnoreCase))); + + } +
+ } + + + @if (flaggedDocs.Any() && string.IsNullOrEmpty(selectedTag) && string.IsNullOrEmpty(searchQuery)) + { +
+
+

🚩 Flagged Notes @flaggedDocs.Count

+
+
+ @foreach (var doc in flaggedDocs) + { +
+
+

@doc.Title

+ +
+

@doc.Snippet

+ @if (doc.Tags.Any()) + { +
+ @foreach (var tag in doc.Tags) + { + #@tag + } +
+ } + +
+ } +
+
+ } + + +
+
+

+ @(string.IsNullOrEmpty(selectedTag) + ? (string.IsNullOrEmpty(searchQuery) ? (flaggedDocs.Any() ? "🏷️ Tagged Notes" : "🚩 Flagged & 🏷️ Tagged Notes") : "🔍 Search Results") + : $"🏷️ Notes tagged #{selectedTag}") + @filteredNotes.Count +

+
+ + @if (!filteredNotes.Any()) + { +
+
🏷️
+

No flagged or tagged notes yet

+

Flag notes with 🚩 in the editor or add #tags in your text to see them on your home screen!

+ @if (localRecentDoc != null) + { + + } + else + { + + } +
+ } + else + { +
+ @foreach (var doc in filteredNotes) + { +
+
+

@doc.Title

+
+ + +
+
+

@doc.Snippet

+ @if (doc.Tags.Any()) + { +
+ @foreach (var tag in doc.Tags) + { + #@tag + } +
+ } + +
+ } +
+ } +
+ } +
+
+ +@code { + private bool isLoading = true; + private string searchQuery = ""; + private string selectedTag = ""; + private DocumentMetadata? localRecentDoc; + private List displayedNotes = new(); + private List activeTags = new(); + + private List flaggedDocs => displayedNotes.Where(d => d.IsFlagged).OrderByDescending(d => d.LastModifiedAt).ToList(); + + private List 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("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"); + } +} diff --git a/Notemark/Components/Pages/Home.razor.css b/Notemark/Components/Pages/Home.razor.css new file mode 100644 index 0000000..5b3dd21 --- /dev/null +++ b/Notemark/Components/Pages/Home.razor.css @@ -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; + } +} diff --git a/Notemark/Components/Pages/SharedEditor.razor b/Notemark/Components/Pages/SharedEditor.razor index 8d0cecf..e67808c 100644 --- a/Notemark/Components/Pages/SharedEditor.razor +++ b/Notemark/Components/Pages/SharedEditor.razor @@ -1,5 +1,6 @@ @page "/e/{token}" @rendermode InteractiveServer +@implements IDisposable @using Notemark.Services @inject IShareService ShareService @inject ISessionService SessionService @@ -227,6 +228,18 @@ else Navigation.NavigateTo("/"); } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (showPreview && !string.IsNullOrEmpty(renderedHtml)) + { + try + { + await JSRuntime.InvokeVoidAsync("notemarkEditor.highlightPreview", ".preview-content"); + } + catch { } + } + } + public void Dispose() { autoSaveTimer?.Dispose(); diff --git a/Notemark/Components/Pages/SharedPreview.razor b/Notemark/Components/Pages/SharedPreview.razor index 7ab8b05..311b9aa 100644 --- a/Notemark/Components/Pages/SharedPreview.razor +++ b/Notemark/Components/Pages/SharedPreview.razor @@ -1,10 +1,12 @@ @page "/s/{token}" @rendermode InteractiveServer +@implements IDisposable @using Notemark.Services @inject IShareService ShareService @inject ISessionService SessionService @inject IMarkdownService MarkdownService @inject NavigationManager Navigation +@inject IJSRuntime JSRuntime Notemark - Shared Document @@ -37,6 +39,17 @@ else
+
+ + + +
@if (shareToken?.Mode == "editable") { @@ -57,7 +70,7 @@ else } else { -
+
@((MarkupString)renderedHtml)
} @@ -124,6 +137,62 @@ else } } + private DotNetObjectReference? dotNetRef; + private int previewZoom = 100; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + dotNetRef ??= DotNetObjectReference.Create(this); + await JSRuntime.InvokeVoidAsync("notemarkEditor.attachPreviewZoomWheel", ".shared-content", dotNetRef); + } + catch { } + } + + if (!isLoading && !string.IsNullOrEmpty(renderedHtml)) + { + try + { + await JSRuntime.InvokeVoidAsync("notemarkEditor.highlightPreview", ".preview-content"); + } + catch { } + } + } + + [JSInvokable] + public void AdjustZoom(int delta) + { + previewZoom = Math.Clamp(previewZoom + delta, 70, 200); + StateHasChanged(); + } + + private void ZoomIn() + { + previewZoom = Math.Min(200, previewZoom + 10); + StateHasChanged(); + } + + private void ZoomOut() + { + previewZoom = Math.Max(70, previewZoom - 10); + StateHasChanged(); + } + + private void ResetZoom() + { + previewZoom = 100; + StateHasChanged(); + } + + private string GetPreviewContentStyle() + { + var factor = (previewZoom / 100.0).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture); + return $"zoom: {factor}; font-size: calc(1rem * {factor}); --preview-zoom: {factor};"; + } + private void GoToEditor() { if (shareToken?.Mode == "editable") @@ -136,4 +205,9 @@ else { Navigation.NavigateTo("/"); } + + public void Dispose() + { + dotNetRef?.Dispose(); + } } \ No newline at end of file diff --git a/Notemark/Components/Pages/SharedPreview.razor.css b/Notemark/Components/Pages/SharedPreview.razor.css index d05cc5f..b5af032 100644 --- a/Notemark/Components/Pages/SharedPreview.razor.css +++ b/Notemark/Components/Pages/SharedPreview.razor.css @@ -47,28 +47,28 @@ display: flex; align-items: center; justify-content: space-between; - padding: 1rem 2rem; + padding: 0.75rem 1.5rem; background-color: var(--surface); - border-bottom: 2px solid var(--border); + border-bottom: 1px solid var(--border); flex-shrink: 0; } .header-left { display: flex; align-items: center; - gap: 1rem; + gap: 0.75rem; } .header-left h1 { margin: 0; - font-size: 1.5rem; + font-size: 1.25rem; font-weight: 700; color: var(--accent); } .share-badge { - padding: 0.25rem 0.75rem; - border-radius: 20px; + padding: 0.2rem 0.6rem; + border-radius: 999px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; @@ -76,54 +76,103 @@ } .share-badge.readonly { - background-color: var(--secondary-text); - color: white; + background-color: var(--border); + color: var(--secondary-text); } .share-badge.editable { - background-color: var(--success); - color: white; + background-color: rgba(16, 185, 129, 0.15); + color: var(--success); } .header-right { display: flex; align-items: center; - gap: 0.75rem; + gap: 0.5rem; +} + +.preview-zoom-controls { + display: inline-flex; + align-items: center; + gap: 0.2rem; + background-color: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.15rem 0.25rem; +} + +.btn-zoom { + background: none; + border: none; + color: var(--secondary-text); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.2rem 0.35rem; + border-radius: 4px; + transition: all 0.15s ease; +} + +.btn-zoom:hover:not(:disabled) { + background-color: var(--surface-hover); + color: var(--accent); +} + +.btn-zoom:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.btn-zoom-reset { + background: none; + border: none; + font-size: 0.72rem; + font-weight: 700; + color: var(--primary-text); + cursor: pointer; + padding: 0.15rem 0.35rem; + border-radius: 3px; + min-width: 38px; + text-align: center; +} + +.btn-zoom-reset:hover { + background-color: var(--accent-light); + color: var(--accent); } /* Buttons */ .btn-edit, .btn-home { - padding: 0.5rem 1rem; + padding: 0.45rem 0.85rem; border: 1px solid var(--border); - border-radius: 6px; + border-radius: 7px; background-color: var(--surface); color: var(--primary-text); - font-size: 0.875rem; + font-size: 0.8125rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; - text-decoration: none; display: inline-flex; align-items: center; - gap: 0.5rem; + gap: 0.4rem; } .btn-edit { background-color: var(--accent); - color: white; + color: #ffffff; border-color: var(--accent); } .btn-edit:hover { background-color: var(--accent-hover); - border-color: var(--accent-hover); } .btn-home:hover { - background-color: var(--accent); - color: white; + background-color: var(--surface-hover); border-color: var(--accent); + color: var(--accent); } /* Content Area */ @@ -151,14 +200,15 @@ } .preview-content { - max-width: 800px; + max-width: 850px; margin: 0 auto; - padding: 3rem 2rem; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; - line-height: 1.6; + padding: 2.5rem 2rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + line-height: 1.7; + color: var(--primary-text); } -/* Markdown Preview Styles (same as editor) */ +/* Markdown Preview Typography */ .preview-content h1, .preview-content h2, .preview-content h3, @@ -166,118 +216,181 @@ .preview-content h5, .preview-content h6 { color: var(--primary-text); - margin-top: 2em; - margin-bottom: 1em; - font-weight: 600; + font-weight: 700; + line-height: 1.3; + margin-top: 1.75em; + margin-bottom: 0.6em; + letter-spacing: -0.02em; } -.preview-content h1 { font-size: 2.5em; margin-top: 0; } -.preview-content h2 { font-size: 2em; } -.preview-content h3 { font-size: 1.5em; } +.preview-content h1:first-child, +.preview-content h2:first-child, +.preview-content h3:first-child { + margin-top: 0; +} + +.preview-content h1 { + font-size: 2.15em; + border-bottom: 1px solid var(--border); + padding-bottom: 0.4em; +} + +.preview-content h2 { + font-size: 1.6em; + border-bottom: 1px solid var(--border-subtle); + padding-bottom: 0.3em; +} + +.preview-content h3 { font-size: 1.3em; } +.preview-content h4 { font-size: 1.1em; } +.preview-content h5 { font-size: 0.95em; text-transform: uppercase; letter-spacing: 0.05em; } +.preview-content h6 { font-size: 0.875em; color: var(--secondary-text); } .preview-content p { - margin-bottom: 1.5em; - color: var(--primary-text); - font-size: 1.1em; + margin-top: 0; + margin-bottom: 1.1em; + font-size: 1em; } -.preview-content code { - background-color: var(--bg); - padding: 0.2em 0.4em; - border-radius: 3px; - font-family: 'JetBrains Mono', 'SF Mono', 'Monaco', monospace; - font-size: 0.9em; +.preview-content a { color: var(--accent); + text-decoration: underline; + text-underline-offset: 3px; + font-weight: 500; } -.preview-content pre { - background-color: var(--bg); - padding: 1.5em; - border-radius: 8px; - overflow-x: auto; - margin-bottom: 1.5em; +.preview-content a:hover { + color: var(--accent-hover); +} + +.preview-content :not(pre) > code { + background-color: var(--code-bg); + color: var(--accent); + padding: 0.2em 0.45em; + border-radius: 6px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.875em; border: 1px solid var(--border); } -.preview-content pre code { - background: none; - padding: 0; +.preview-content pre { + position: relative; + background-color: var(--code-bg); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.25rem 1.5rem; + margin: 1.25em 0; + overflow-x: auto; +} + +.code-copy-btn { + position: absolute; + top: 0.65rem; + right: 0.65rem; + padding: 0.35rem 0.5rem; + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--secondary-text); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all 0.2s ease; +} + +.preview-content pre:hover .code-copy-btn { + opacity: 1; +} + +.code-copy-btn:hover { color: var(--primary-text); + background-color: var(--surface-hover); + border-color: var(--accent); +} + +.code-copy-btn.copied { + color: var(--success); + border-color: var(--success); + opacity: 1; } .preview-content blockquote { + margin: 1.25em 0; + padding: 0.75rem 1.25rem; border-left: 4px solid var(--accent); - padding-left: 1.5em; - margin-left: 0; - margin-bottom: 1.5em; - color: var(--secondary-text); - font-style: italic; -} - -.preview-content ul, -.preview-content ol { - margin-bottom: 1.5em; - padding-left: 2em; -} - -.preview-content li { - margin-bottom: 0.5em; + background-color: var(--accent-light); + border-radius: 0 8px 8px 0; + color: var(--primary-text); } .preview-content table { width: 100%; - border-collapse: collapse; - margin-bottom: 1.5em; + border-collapse: separate; + border-spacing: 0; + margin: 1.5em 0; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; } .preview-content th, .preview-content td { - border: 1px solid var(--border); - padding: 0.75em 1em; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + border-right: 1px solid var(--border); text-align: left; } +.preview-content th:last-child, +.preview-content td:last-child { + border-right: none; +} + +.preview-content tr:last-child td { + border-bottom: none; +} + .preview-content th { - background-color: var(--bg); + background-color: var(--surface-hover); font-weight: 600; } -.preview-content img { - max-width: 100%; - height: auto; - border-radius: 6px; +.preview-content tr:hover td { + background-color: var(--surface-hover); +} + +.preview-content ul, +.preview-content ol { + padding-left: 1.75em; + margin-bottom: 1.1em; +} + +.preview-content input[type="checkbox"] { + margin-right: 0.5em; + accent-color: var(--accent); } .preview-content hr { border: none; - border-top: 2px solid var(--border); - margin: 3em 0; + height: 1px; + background-color: var(--border); + margin: 2em 0; +} + +.preview-content img { + max-width: 100%; + border-radius: 8px; + margin: 1em 0; } -/* Mobile Responsive */ @media (max-width: 768px) { .shared-header { - padding: 1rem; - flex-direction: column; - gap: 1rem; - align-items: stretch; - } - - .header-left { - justify-content: center; - } - - .header-right { - justify-content: center; + padding: 0.75rem 1rem; } .preview-content { - padding: 2rem 1rem; - } - - .btn-edit, - .btn-home { - flex: 1; - justify-content: center; + padding: 1.5rem 1rem; } } \ No newline at end of file diff --git a/Notemark/Components/Shared/NotesDrawer.razor b/Notemark/Components/Shared/NotesDrawer.razor new file mode 100644 index 0000000..2789d88 --- /dev/null +++ b/Notemark/Components/Shared/NotesDrawer.razor @@ -0,0 +1,263 @@ +@using Notemark.Models +@using Notemark.Services +@inject IDocumentService DocumentService + +@if (IsOpen) +{ +
+
+
+
+
+ + + + + My Notes +
+ +
+ + + + + + + + + @if (allTags.Any()) + { +
+ + @foreach (var tag in allTags) + { + + } +
+ } + + +
+ + +
+
+ +
+ @if (isLoading) + { +
+
+ Loading notes... +
+ } + else + { + var displayList = GetFilteredList(); + if (!displayList.Any()) + { +
+ @if (!string.IsNullOrEmpty(selectedTag)) + { +
🏷️
+

No notes with #@selectedTag

+

Try selecting another tag or clearing the filter.

+ } + else if (activeTab == "flagged") + { +
🚩
+

No flagged notes

+

Click the flag button on any note to keep it here for quick access.

+ } + else + { +
📝
+

No notes found

+

@(string.IsNullOrEmpty(searchQuery) ? "Start typing in the editor to create your first note." : "No notes match your search.")

+ } +
+ } + else + { +
+ @foreach (var doc in displayList) + { +
+
+

@doc.Title

+
+ + +
+
+

@doc.Snippet

+ + @if (doc.Tags.Any()) + { +
+ @foreach (var tag in doc.Tags) + { + #@tag + } +
+ } + + +
+ } +
+ } + } +
+
+} + +@code { + [Parameter] public bool IsOpen { get; set; } + [Parameter] public EventCallback IsOpenChanged { get; set; } + [Parameter] public string CurrentDocumentId { get; set; } = ""; + [Parameter] public EventCallback OnSelectDocument { get; set; } + + private bool isLoading = true; + private string activeTab = "flagged"; + private string searchQuery = ""; + private string selectedTag = ""; + private List allDocs = new(); + private List 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 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"); + } +} diff --git a/Notemark/Components/Shared/NotesDrawer.razor.css b/Notemark/Components/Shared/NotesDrawer.razor.css new file mode 100644 index 0000000..c75b2cb --- /dev/null +++ b/Notemark/Components/Shared/NotesDrawer.razor.css @@ -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; +} diff --git a/Notemark/Components/Shared/ThemeToggle.razor b/Notemark/Components/Shared/ThemeToggle.razor index 7b107bb..567777a 100644 --- a/Notemark/Components/Shared/ThemeToggle.razor +++ b/Notemark/Components/Shared/ThemeToggle.razor @@ -1,49 +1,83 @@ @inject IJSRuntime JSRuntime +@inject IHttpContextAccessor HttpContextAccessor - @code { private string currentTheme = "light"; - protected override async Task OnInitializedAsync() + protected override void OnInitialized() { try { - await JSRuntime.InvokeVoidAsync("notemarkTheme.init"); - currentTheme = await JSRuntime.InvokeAsync("notemarkTheme.getCurrentTheme"); + var cookie = HttpContextAccessor.HttpContext?.Request.Cookies["notemark_theme"]; + if (!string.IsNullOrEmpty(cookie) && (cookie == "dark" || cookie == "light")) + { + currentTheme = cookie; + } } - catch (Exception ex) + catch { } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) { - System.Diagnostics.Debug.WriteLine($"Theme init error: {ex.Message}"); - currentTheme = "light"; + try + { + var theme = await JSRuntime.InvokeAsync("notemarkTheme.getCurrentTheme"); + if (!string.IsNullOrEmpty(theme) && theme != currentTheme) + { + currentTheme = theme; + StateHasChanged(); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Theme init error: {ex.Message}"); + } } } private async Task ToggleTheme() { - // Simple toggle: light <-> dark - currentTheme = currentTheme == "light" ? "dark" : "light"; - try { - await JSRuntime.InvokeVoidAsync("notemarkTheme.setTheme", currentTheme); - System.Diagnostics.Debug.WriteLine($"Theme switched to: {currentTheme}"); + var nextTheme = await JSRuntime.InvokeAsync("notemarkTheme.toggleTheme"); + currentTheme = nextTheme; } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Theme toggle error: {ex.Message}"); + currentTheme = currentTheme == "dark" ? "light" : "dark"; } StateHasChanged(); } - private string GetThemeIcon() - { - return currentTheme == "dark" ? "🌙" : "☀️"; - } - private string GetThemeTooltip() { return currentTheme == "dark" ? "Switch to light mode" : "Switch to dark mode"; diff --git a/Notemark/Components/Shared/ThemeToggle.razor.css b/Notemark/Components/Shared/ThemeToggle.razor.css index eaacd10..0755399 100644 --- a/Notemark/Components/Shared/ThemeToggle.razor.css +++ b/Notemark/Components/Shared/ThemeToggle.razor.css @@ -1,35 +1,48 @@ .theme-toggle { padding: 0.5rem; - background: none; + background: var(--surface); border: 1px solid var(--border); - border-radius: 6px; + border-radius: 8px; cursor: pointer; - font-size: 1rem; + color: var(--primary-text); line-height: 1; - transition: all 0.2s ease; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); display: flex; align-items: center; justify-content: center; - min-width: 2.5rem; - height: 2.5rem; + min-width: 2.25rem; + height: 2.25rem; + box-shadow: var(--shadow-sm); } .theme-toggle:hover { - background-color: var(--accent); + background-color: var(--surface-hover); border-color: var(--accent); - transform: scale(1.05); + color: var(--accent); + transform: translateY(-1px); } .theme-toggle:active { - transform: scale(0.95); + transform: translateY(0) scale(0.96); } -/* Mobile styles */ +.theme-icon { + transition: transform 0.3s ease, stroke 0.2s ease; +} + +.theme-toggle:hover .sun-icon { + transform: rotate(45deg); +} + +.theme-toggle:hover .moon-icon { + transform: rotate(-15deg); +} + +/* Mobile responsive */ @media (max-width: 768px) { .theme-toggle { min-width: 2rem; height: 2rem; padding: 0.375rem; - font-size: 0.875rem; } } diff --git a/Notemark/Models/DocumentMetadata.cs b/Notemark/Models/DocumentMetadata.cs new file mode 100644 index 0000000..b948e4f --- /dev/null +++ b/Notemark/Models/DocumentMetadata.cs @@ -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 Tags { get; init; } = new(); + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; + public DateTime LastModifiedAt { get; init; } = DateTime.UtcNow; + public string Snippet { get; init; } = ""; +} diff --git a/Notemark/Program.cs b/Notemark/Program.cs index feab57c..7d80436 100644 --- a/Notemark/Program.cs +++ b/Notemark/Program.cs @@ -12,6 +12,7 @@ builder.Services.AddHttpContextAccessor(); // Register Notemark services builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -40,8 +41,7 @@ app.UseHttpsRedirection(); // Enable WebSockets app.UseWebSockets(new WebSocketOptions { - KeepAliveInterval = TimeSpan.FromMinutes(2), - ReceiveBufferSize = 4 * 1024 + KeepAliveInterval = TimeSpan.FromMinutes(2) }); // Enable cookies for session management diff --git a/Notemark/Services/DocumentService.cs b/Notemark/Services/DocumentService.cs new file mode 100644 index 0000000..993e5fb --- /dev/null +++ b/Notemark/Services/DocumentService.cs @@ -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> GetAllDocumentsAsync() + { + await _lock.WaitAsync(); + try + { + return await ReadMetadataListInternalAsync(); + } + finally + { + _lock.Release(); + } + } + + public async Task> GetFlaggedDocumentsAsync() + { + var docs = await GetAllDocumentsAsync(); + return docs.Where(d => d.IsFlagged) + .OrderByDescending(d => d.LastModifiedAt) + .ToList(); + } + + public async Task> GetRecentDocumentsAsync(int limit = 20) + { + var docs = await GetAllDocumentsAsync(); + return docs.OrderByDescending(d => d.LastModifiedAt) + .Take(limit) + .ToList(); + } + + public async Task> 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> 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 GetMostRecentDocumentAsync() + { + var docs = await GetAllDocumentsAsync(); + return docs.OrderByDescending(d => d.LastModifiedAt).FirstOrDefault(); + } + + public async Task 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 SaveDocumentAsync(string id, string content, bool? isFlagged = null, string? customTitle = null, List? 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 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 ExtractTags(string content) + { + if (string.IsNullOrWhiteSpace(content)) + return new List(); + + var tags = new HashSet(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> ReadMetadataListInternalAsync() + { + if (!File.Exists(_metadataFilePath)) + return new List(); + + try + { + var json = await File.ReadAllTextAsync(_metadataFilePath); + return JsonSerializer.Deserialize>(json, _jsonOptions) ?? new List(); + } + catch + { + return new List(); + } + } + + private async Task WriteMetadataListInternalAsync(List list) + { + var json = JsonSerializer.Serialize(list, _jsonOptions); + await File.WriteAllTextAsync(_metadataFilePath, json); + } +} diff --git a/Notemark/Services/IDocumentService.cs b/Notemark/Services/IDocumentService.cs new file mode 100644 index 0000000..c30d31d --- /dev/null +++ b/Notemark/Services/IDocumentService.cs @@ -0,0 +1,19 @@ +using Notemark.Models; + +namespace Notemark.Services; + +public interface IDocumentService +{ + Task> GetAllDocumentsAsync(); + Task> GetFlaggedDocumentsAsync(); + Task> GetRecentDocumentsAsync(int limit = 20); + Task> GetAllTagsAsync(); + Task> GetDocumentsByTagAsync(string tag); + Task GetMostRecentDocumentAsync(); + Task GetDocumentAsync(string id); + Task SaveDocumentAsync(string id, string content, bool? isFlagged = null, string? customTitle = null, List? tags = null); + Task ToggleFlagAsync(string id); + Task DeleteDocumentAsync(string id); + string ExtractTitle(string content); + List ExtractTags(string content); +} diff --git a/Notemark/Services/SessionService.cs b/Notemark/Services/SessionService.cs index 5fdecd7..d5e6b56 100644 --- a/Notemark/Services/SessionService.cs +++ b/Notemark/Services/SessionService.cs @@ -69,8 +69,8 @@ public class SessionService : ISessionService return; } - // Use FileStream for large content to avoid memory pressure - await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 8192, useAsync: true); + // Use FileStream with FileShare.ReadWrite to avoid locking collisions + await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite, bufferSize: 8192, useAsync: true); await using var writer = new StreamWriter(fileStream, Encoding.UTF8); // Write in chunks for very large content diff --git a/Notemark/data/documents.json b/Notemark/data/documents.json new file mode 100644 index 0000000..dabf435 --- /dev/null +++ b/Notemark/data/documents.json @@ -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..." + } +] \ No newline at end of file diff --git a/Notemark/data/sessions/2ba5f046-c8d8-4e55-acf4-5e654dee5afc.md b/Notemark/data/sessions/2ba5f046-c8d8-4e55-acf4-5e654dee5afc.md new file mode 100644 index 0000000..582b3cc --- /dev/null +++ b/Notemark/data/sessions/2ba5f046-c8d8-4e55-acf4-5e654dee5afc.md @@ -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. diff --git a/Notemark/data/sessions/5c758081-4210-4c9d-a08c-3d90eb63d281.md b/Notemark/data/sessions/5c758081-4210-4c9d-a08c-3d90eb63d281.md new file mode 100644 index 0000000..0d4651e --- /dev/null +++ b/Notemark/data/sessions/5c758081-4210-4c9d-a08c-3d90eb63d281.md @@ -0,0 +1,3 @@ +# Untitled Note + +Start typing here... #ideas \ No newline at end of file diff --git a/Notemark/wwwroot/app.css b/Notemark/wwwroot/app.css index 4aee6c3..43772d0 100644 --- a/Notemark/wwwroot/app.css +++ b/Notemark/wwwroot/app.css @@ -1,122 +1,126 @@ -/* Notemark Theme Variables */ +/* Notemark Global Theme System */ :root { - /* Light Mode (Default) */ + /* Default Light Mode Variables */ --bg: #F9FAFB; --surface: #FFFFFF; + --surface-hover: #F3F4F6; --border: #E5E7EB; + --border-subtle: #F3F4F6; --primary-text: #111827; --secondary-text: #6B7280; --accent: #4F46E5; --accent-hover: #4338CA; + --accent-light: rgba(79, 70, 229, 0.1); --success: #10B981; - --error: #F43F5E; + --error: #EF4444; + --code-bg: #F1F5F9; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); } -/* Auto-detect system preference for dark mode */ +/* System preference fallback when no data-theme is set */ @media (prefers-color-scheme: dark) { :root:not([data-theme]) { --bg: #0B0F19; --surface: #111827; + --surface-hover: #1F2937; --border: #1F2937; + --border-subtle: #182234; --primary-text: #F9FAFB; --secondary-text: #9CA3AF; - --accent: #22D3EE; - --accent-hover: #06B6D4; - --success: #4ADE80; - --error: #FB7185; + --accent: #6366F1; + --accent-hover: #818CF8; + --accent-light: rgba(99, 102, 241, 0.15); + --success: #34D399; + --error: #F87171; + --code-bg: #1E293B; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.2); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3); } } /* Explicit light mode */ :root[data-theme="light"] { - --bg: #F9FAFB !important; - --surface: #FFFFFF !important; - --border: #E5E7EB !important; - --primary-text: #111827 !important; - --secondary-text: #6B7280 !important; - --accent: #4F46E5 !important; - --accent-hover: #4338CA !important; - --success: #10B981 !important; - --error: #F43F5E !important; + --bg: #F9FAFB; + --surface: #FFFFFF; + --surface-hover: #F3F4F6; + --border: #E5E7EB; + --border-subtle: #F3F4F6; + --primary-text: #111827; + --secondary-text: #6B7280; + --accent: #4F46E5; + --accent-hover: #4338CA; + --accent-light: rgba(79, 70, 229, 0.1); + --success: #10B981; + --error: #EF4444; + --code-bg: #F1F5F9; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); } /* Explicit dark mode */ :root[data-theme="dark"] { - --bg: #0B0F19 !important; - --surface: #111827 !important; - --border: #1F2937 !important; - --primary-text: #F9FAFB !important; - --secondary-text: #9CA3AF !important; - --accent: #22D3EE !important; - --accent-hover: #06B6D4 !important; - --success: #4ADE80 !important; - --error: #FB7185 !important; + --bg: #0B0F19; + --surface: #111827; + --surface-hover: #1F2937; + --border: #1F2937; + --border-subtle: #182234; + --primary-text: #F9FAFB; + --secondary-text: #9CA3AF; + --accent: #6366F1; + --accent-hover: #818CF8; + --accent-light: rgba(99, 102, 241, 0.15); + --success: #34D399; + --error: #F87171; + --code-bg: #1E293B; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.2); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3); } /* Apply theme variables globally */ html, body { - background-color: var(--bg) !important; - color: var(--primary-text) !important; - transition: background-color 0.3s ease, color 0.3s ease; -} - -html, body { - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + background-color: var(--bg); + color: var(--primary-text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; + margin: 0; + padding: 0; + height: 100%; } a, .btn-link { - color: #006bb7; + color: var(--accent); +} + +a:hover { + color: var(--accent-hover); } .btn-primary { - color: #fff; - background-color: #1b6ec2; - border-color: #1861ac; + color: #fff; + background-color: var(--accent); + border-color: var(--accent); +} + +.btn-primary:hover { + background-color: var(--accent-hover); + border-color: var(--accent-hover); } .btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { - box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; -} - -.content { - padding-top: 1.1rem; -} - -h1:focus { - outline: none; -} - -.valid.modified:not([type=checkbox]) { - outline: 1px solid #26b050; -} - -.invalid { - outline: 1px solid #e50000; -} - -.validation-message { - color: #e50000; + box-shadow: 0 0 0 0.15rem var(--accent-light), 0 0 0 0.25rem var(--accent); } .blazor-error-boundary { - background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA69.NzMwOSAyNjcuODc5IDY5LjU4NzcgMjY3Ljg3OSA2OS4zMTc5IDI2Ny44NzkgNjcuMTEyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; - padding: 1rem 1rem 1rem 3.7rem; - color: white; + background: #b32121; + padding: 1rem 1rem 1rem 2rem; + color: white; } - .blazor-error-boundary::after { - content: "An error has occurred." - } - -.darker-border-checkbox.form-check-input { - border-color: #929292; -} - -.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { - color: var(--bs-secondary-color); - text-align: end; -} - -.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { - text-align: start; +.blazor-error-boundary::after { + content: "An error has occurred."; } \ No newline at end of file diff --git a/Notemark/wwwroot/js/editor.js b/Notemark/wwwroot/js/editor.js new file mode 100644 index 0000000..48500e6 --- /dev/null +++ b/Notemark/wwwroot/js/editor.js @@ -0,0 +1,301 @@ +// Notemark Advanced Editor JavaScript Suite +window.notemarkEditor = { + isSyncingScroll: false, + mermaidLoaded: false, + + // Initialize textarea with Tab handling, Paste/Drop Image support, and Scroll Sync + initTextarea: function(textarea) { + if (!textarea) return; + + // 1. Tab Key Handling + textarea.addEventListener('keydown', function(e) { + if (e.key === 'Tab') { + e.preventDefault(); + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const value = textarea.value; + + if (e.shiftKey) { + const lineStart = value.lastIndexOf('\n', start - 1) + 1; + if (value.substring(lineStart, lineStart + 2) === ' ') { + textarea.value = value.substring(0, lineStart) + value.substring(lineStart + 2); + textarea.selectionStart = Math.max(lineStart, start - 2); + textarea.selectionEnd = Math.max(lineStart, end - 2); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + } + } else { + textarea.value = value.substring(0, start) + ' ' + value.substring(end); + textarea.selectionStart = textarea.selectionEnd = start + 2; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + } + } + }); + + // 2. Paste Image Support (Base64) + textarea.addEventListener('paste', function(e) { + const items = (e.clipboardData || e.originalEvent.clipboardData).items; + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + const file = items[i].getAsFile(); + if (file) { + e.preventDefault(); + window.notemarkEditor.insertImageAsBase64(textarea, file); + break; + } + } + } + }); + + // 3. Drag & Drop Image Support (Base64) + textarea.addEventListener('dragover', function(e) { + e.preventDefault(); + e.stopPropagation(); + }); + + textarea.addEventListener('drop', function(e) { + if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const file = e.dataTransfer.files[0]; + if (file.type.startsWith('image/')) { + e.preventDefault(); + e.stopPropagation(); + window.notemarkEditor.insertImageAsBase64(textarea, file); + } + } + }); + + // 4. Scroll Sync Listener on Editor + textarea.addEventListener('scroll', function() { + if (window.notemarkEditor.isSyncingScroll) return; + const preview = document.querySelector('.preview-content'); + if (!preview) return; + + window.notemarkEditor.isSyncingScroll = true; + const scrollRatio = textarea.scrollTop / (textarea.scrollHeight - textarea.clientHeight || 1); + preview.scrollTop = scrollRatio * (preview.scrollHeight - preview.clientHeight); + setTimeout(() => { window.notemarkEditor.isSyncingScroll = false; }, 25); + }); + + // Attach scroll sync on preview as well + const preview = document.querySelector('.preview-content'); + if (preview) { + preview.addEventListener('scroll', function() { + if (window.notemarkEditor.isSyncingScroll) return; + window.notemarkEditor.isSyncingScroll = true; + const scrollRatio = preview.scrollTop / (preview.scrollHeight - preview.clientHeight || 1); + textarea.scrollTop = scrollRatio * (textarea.scrollHeight - textarea.clientHeight); + setTimeout(() => { window.notemarkEditor.isSyncingScroll = false; }, 25); + }); + } + }, + + // Convert file to Base64 and insert at cursor + insertImageAsBase64: function(textarea, file) { + const reader = new FileReader(); + reader.onload = function(event) { + const base64Data = event.target.result; + const markdownImage = `\n\n![${file.name || 'image'}](${base64Data})\n\n`; + + textarea.focus(); + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const val = textarea.value; + + textarea.value = val.substring(0, start) + markdownImage + val.substring(end); + textarea.selectionStart = textarea.selectionEnd = start + markdownImage.length; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }; + reader.readAsDataURL(file); + }, + + // Wrap / insert text in selection + wrapSelection: function(textarea, prefix, suffix, defaultText) { + if (!textarea) return; + textarea.focus(); + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const val = textarea.value; + const selected = val.substring(start, end) || defaultText || ''; + + const replacement = prefix + selected + suffix; + textarea.value = val.substring(0, start) + replacement + val.substring(end); + + textarea.selectionStart = start + prefix.length; + textarea.selectionEnd = start + prefix.length + selected.length; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }, + + // Preview enhancement: Prism syntax highlighting + Mermaid diagrams + Copy code buttons + highlightPreview: function(containerSelector) { + const container = document.querySelector(containerSelector || '.preview-content'); + if (!container) return; + + // 1. Render Prism code blocks + if (window.Prism) { + window.Prism.highlightAllUnder(container); + } + + // 2. Render Mermaid diagrams + window.notemarkEditor.renderMermaidDiagrams(container); + + // 3. Attach code copy buttons + const preBlocks = container.querySelectorAll('pre'); + preBlocks.forEach(function(pre) { + if (pre.querySelector('.code-copy-btn') || pre.classList.contains('mermaid-pre')) return; + + const btn = document.createElement('button'); + btn.className = 'code-copy-btn'; + btn.setAttribute('type', 'button'); + btn.setAttribute('title', 'Copy code'); + btn.innerHTML = ``; + + 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 = ``; + setTimeout(function() { + btn.classList.remove('copied'); + btn.innerHTML = ``; + }, 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 = ` + + + + + ${title || 'Notemark Document'} + + + +
+ ${htmlBody} +
+ +`; + + 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); + } +}; diff --git a/Notemark/wwwroot/js/theme.js b/Notemark/wwwroot/js/theme.js index 04c70d2..a35ff61 100644 --- a/Notemark/wwwroot/js/theme.js +++ b/Notemark/wwwroot/js/theme.js @@ -1,47 +1,58 @@ -// Ultra-simple theme for iOS testing +// Notemark Theme Management window.notemarkTheme = { - currentTheme: 'light', - - init: function() { - console.log('Simple theme init'); - this.applyTheme(this.currentTheme); - return this.currentTheme; - }, - - setTheme: function(theme) { - console.log('Setting theme:', theme); - this.currentTheme = theme; - this.applyTheme(theme); - }, - - applyTheme: function(theme) { - console.log('Applying theme:', theme); - const root = document.documentElement; - - if (theme === 'dark') { - root.style.setProperty('--bg', '#1a1a1a'); - root.style.setProperty('--surface', '#2d2d2d'); - root.style.setProperty('--primary-text', '#ffffff'); - root.style.setProperty('--secondary-text', '#cccccc'); - root.style.setProperty('--border', '#404040'); - root.style.setProperty('--accent', '#007acc'); - } else { - root.style.setProperty('--bg', '#ffffff'); - root.style.setProperty('--surface', '#f8f9fa'); - root.style.setProperty('--primary-text', '#333333'); - root.style.setProperty('--secondary-text', '#666666'); - root.style.setProperty('--border', '#e9ecef'); - root.style.setProperty('--accent', '#007acc'); + getPreferredTheme: function() { + try { + const cookieMatch = document.cookie.match(/(?:^|; )notemark_theme=([^;]*)/); + const fromCookie = cookieMatch ? cookieMatch[1] : null; + const fromStorage = localStorage.getItem('notemark-theme'); + const saved = fromStorage || fromCookie; + if (saved === 'dark' || saved === 'light') { + return saved; + } + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + } catch (e) { + console.warn('localStorage access failed:', e); } - console.log('Theme applied successfully'); + return 'light'; }, - + getCurrentTheme: function() { - return this.currentTheme; + const attr = document.documentElement.getAttribute('data-theme'); + if (attr === 'dark' || attr === 'light') { + return attr; + } + return this.getPreferredTheme(); + }, + + setTheme: function(theme) { + const targetTheme = (theme === 'dark') ? 'dark' : 'light'; + document.documentElement.setAttribute('data-theme', targetTheme); + try { + localStorage.setItem('notemark-theme', targetTheme); + document.cookie = `notemark_theme=${targetTheme}; path=/; max-age=31536000; SameSite=Lax`; + window.dispatchEvent(new CustomEvent('notemark-theme-changed', { detail: targetTheme })); + } catch (e) { + console.warn('localStorage/cookie save failed:', e); + } + return targetTheme; + }, + + toggleTheme: function() { + const current = this.getCurrentTheme(); + const next = (current === 'dark') ? 'light' : 'dark'; + return this.setTheme(next); + }, + + init: function() { + const theme = this.getPreferredTheme(); + document.documentElement.setAttribute('data-theme', theme); + return theme; } }; -// Simple splitter drag functionality +// Splitter Drag Functionality let splitterState = { isDragging: false, blazorInstance: null, @@ -51,19 +62,14 @@ let splitterState = { window.startSplitterDrag = function(blazorInstance) { splitterState.blazorInstance = blazorInstance; splitterState.isDragging = true; - - // Find the editor layout container splitterState.container = document.querySelector('.editor-layout'); - // Add global mouse events document.addEventListener('mousemove', handleSplitterMove); document.addEventListener('mouseup', handleSplitterUp); - // Improve visual feedback during drag document.body.style.userSelect = 'none'; document.body.style.cursor = 'col-resize'; - // Add dragging class for visual feedback const splitter = document.querySelector('.splitter'); if (splitter) { splitter.classList.add('dragging'); @@ -77,8 +83,8 @@ function handleSplitterMove(e) { const clientX = e.clientX - rect.left; const containerWidth = rect.width; - // Call Blazor method to update split ratio - splitterState.blazorInstance.invokeMethodAsync('UpdateSplitRatio', clientX, containerWidth); + splitterState.blazorInstance.invokeMethodAsync('UpdateSplitRatio', clientX, containerWidth) + .catch(err => console.debug('Splitter invoke ignored', err)); } function handleSplitterUp(e) { @@ -86,26 +92,22 @@ function handleSplitterUp(e) { splitterState.isDragging = false; - // Remove global mouse events document.removeEventListener('mousemove', handleSplitterMove); document.removeEventListener('mouseup', handleSplitterUp); - // Reset cursor and selection document.body.style.cursor = ''; document.body.style.userSelect = ''; - // Remove dragging visual state const splitter = document.querySelector('.splitter'); if (splitter) { splitter.classList.remove('dragging'); } - // Notify Blazor that dragging stopped if (splitterState.blazorInstance) { - splitterState.blazorInstance.invokeMethodAsync('StopDragging'); + splitterState.blazorInstance.invokeMethodAsync('StopDragging') + .catch(err => console.debug('StopDragging invoke ignored', err)); } - // Clean up splitterState.blazorInstance = null; splitterState.container = null; } diff --git a/Notemark/wwwroot/lib/prism/prism.min.css b/Notemark/wwwroot/lib/prism/prism.min.css new file mode 100644 index 0000000..74a497b --- /dev/null +++ b/Notemark/wwwroot/lib/prism/prism.min.css @@ -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; +} diff --git a/Notemark/wwwroot/lib/prism/prism.min.js b/Notemark/wwwroot/lib/prism/prism.min.js new file mode 100644 index 0000000..9c76f80 --- /dev/null +++ b/Notemark/wwwroot/lib/prism/prism.min.js @@ -0,0 +1,30 @@ +/* PrismJS 1.29.0 - Core + Common Languages */ +var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(u){var c=/\blang(?:uage)?-([\w-]+)\b/i,n=0,e={},M={manual:u.Prism&&u.Prism.manual,disableWorkerMessageHandler:u.Prism&&u.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof C?new C(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=l.reach);y+=m.value.length,m=m.next){var b=m.value;if(t.length>n.length)return;if(!(b instanceof C)){var k,x=1;if(h){if(!(k=z(v,y,n,f))||k.index>=n.length)break;var w=k.index,A=k.index+k[0].length,P=y;for(P+=m.value.length;P<=w;)m=m.next,P+=m.value.length;if(P-=m.value.length,y=P,m.value instanceof C)continue;for(var E=m;E!==t.tail&&(Pl.reach&&(l.reach=N);var j=m.prev;if(O&&(j=W(t,j,O),y+=O.length),I(t,j,x),m=W(t,j,new C(o,g?M.tokenize(S,g):S,d,S)),L&&W(t,m,L),1l.reach&&(l.reach=_.reach)}}}}}}(e,a,n,a.head,0),function(e){var n=[],t=e.head.next;for(;t!==e.tail;)n.push(t.value),t=t.next;return n}(a)},hooks:{all:{},add:function(e,n){var t=M.hooks.all;t[e]=t[e]||[],t[e].push(n)},run:function(e,n){var t=M.hooks.all[e];if(t&&t.length)for(var r,a=0;r=t[a++];)r(n)}}};function C(e,n,t,r){this.type=e,this.content=n,this.alias=t,this.length=0|(r||"").length}function z(e,n,t,r){e.lastIndex=n;var a=e.exec(t);if(a&&r&&a[1]){var i=a[1].length;a.index+=i,a[0]=a[0].slice(i)}return a}function i(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function W(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function I(e,n,t){for(var r=n.next,a=0;a"+r.content+""},!u.document)return u.addEventListener&&(M.disableWorkerMessageHandler||u.addEventListener("message",function(e){var n=JSON.parse(e.data),t=n.language,r=n.code,a=n.immediateClose;u.postMessage(M.highlight(r,M.languages[t],t)),a&&u.close()},!1)),M;var t=M.util.currentScript();return t&&(M.filename=t.src,t.hasAttribute("data-manual")&&(M.manual=!0)),M.manual||"loading"===document.readyState?document.addEventListener("DOMContentLoaded",function(){M.manual||M.highlightAll()}):window.requestAnimationFrame?window.requestAnimationFrame(M.highlightAll):window.setTimeout(M.highlightAll,16),M}(_self); + +/* Markup & XML/HTML */ +Prism.languages.markup={comment:{pattern://,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern://i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[^\s>\/])))+?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/i,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/i,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/i,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]};Prism.languages.html=Prism.languages.markup;Prism.languages.xml=Prism.languages.markup; + +/* CSS */ +Prism.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:/@[\w-](?:[^;{\s]|\s+(?![\s{]))*(?:;|(?=\s*\{))/,inside:{rule:/^@[\w-]+/}},url:{pattern:/url\(["']?[^"'\r\n)]+["']?\)/i,greedy:!0,inside:{function:/^url/i,punctuation:/[()]/,string:{pattern:/^["'].+["']$/,greedy:!0}}},selector:{pattern:/(?!\s)[^{};]+(?=\s*\{)/,inside:{"pseudo-element":/:(?:after|before|first-letter|first-line|selection|backdrop|placeholder|marker|spelling-error|grammar-error)/,"pseudo-class":/:[\w-]+/,class:/\.[\w-]+/,id:/#[\w-]+/,attribute:/\[[^\]]+\]/}},property:/(?!\s)[-\w_]+(?=\s*:)/,important:/!important\b/i,function:/[-a-z0-9]+(?=\()/i,punctuation:/[(){};:,]/}; + +/* JavaScript */ +Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|interface|extends|implements|trait|instanceof|new)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/,boolean:/\b(?:true|false)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/}; +Prism.languages.javascript=Prism.languages.extend("clike",{"class-name":[Prism.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/,lookbehind:!0}],keyword:[{pattern:/((?:^|\})\s*)catch\b/,lookbehind:!0},/\b(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/],number:/\b(?:(?:0[xX](?:[\dA-Fa-f](?:_[\dA-Fa-f])?)+|0[bB](?:[01](?:_[01])?)+|0[oO](?:[0-7](?:_[0-7])?)+)n?|(?:\d(?:_\d)?)+n|NaN|Infinity)\b|(?:\b(?:\d(?:_\d)?)+\.?(?:\d(?:_\d)?)*|\B\.(?:\d(?:_\d)?)+)(?:[eE][+-]?(?:\d(?:_\d)?)+)?/,operator:/--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?\?=?|\?\.?/}); +Prism.languages.js=Prism.languages.javascript; + +/* C# */ +Prism.languages.csharp=Prism.languages.extend("clike",{keyword:/\b(?:abstract|as|async|await|base|bool|break|byte|case|catch|char|checked|class|const|continue|decimal|default|delegate|do|double|else|enum|event|explicit|extern|false|finally|fixed|float|for|foreach|goto|if|implicit|in|int|interface|internal|is|lock|long|namespace|new|null|object|operator|out|override|params|private|protected|public|readonly|ref|return|sbyte|sealed|short|sizeof|stackalloc|static|string|struct|switch|this|throw|true|try|typeof|uint|ulong|unchecked|unsafe|ushort|using|virtual|void|volatile|while|add|alias|ascending|async|await|by|descending|dynamic|equals|from|get|global|group|into|join|let|nameof|not|notnull|on|orderby|partial|record|remove|select|set|unmanaged|value|var|when|where|with|yield)\b/,"class-name":/\b[A-Z]\w*(?:\.\w+)*\b/,number:/\b0x[\da-f]+(?:\.[0-9a-f]+)?(?:p[+-]?\d+)?|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?[dfm]?/i}); +Prism.languages.cs=Prism.languages.csharp; +Prism.languages.dotnet=Prism.languages.csharp; + +/* JSON */ +Prism.languages.json={property:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?=\s*:)/,lookbehind:!0,greedy:!0},string:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?!\s*:)/,lookbehind:!0,greedy:!0},comment:{pattern:/\/\/.*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},number:/-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/i,punctuation:/[{}[\],]/,operator:/:/,boolean:/\b(?:false|true)\b/,null:{pattern:/\bnull\b/,alias:"keyword"}}; + +/* Python */ +Prism.languages.python={comment:{pattern:/(^|[^\\])#.*/,lookbehind:!0,greedy:!0},"string-interpolation":{pattern:/(?:f|fr|rf)(?:("""|''')[\s\S]*?\1|("|')(?:\\.|(?!\2)[^\\\r\n])*\2)/i,greedy:!0,inside:{interpolation:{pattern:/((?:^|[^{])(?:\{\{)*)\{(?!\{)(?:[^{}]|\{[^{}]*\})*\}/,lookbehind:!0,inside:{punctuation:/^\{|\}$/,rest:null}},string:/[\s\S]+/}},string:{pattern:/(?:[rub]|rb|br)?(?:("""|''')[\s\S]*?\1|("|')(?:\\.|(?!\2)[^\\\r\n])*\2)/i,greedy:!0},keyword:/\b(?:and|as|assert|async|await|break|class|continue|def|del|elif|else|except|exec|finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|print|raise|return|try|while|with|yield)\b/,boolean:/\b(?:False|True|None)\b/,number:/\b0(?:b(?:_?[01])+|o(?:_?[0-7])+|x(?:_?[a-f0-9])+)\b|(?:\b\d+(?:_\d+)*(?:\.(?:\d+(?:_\d+)*)?)?|\B\.\d+(?:_\d+)*)(?:e[+-]?\d+(?:_\d+)*)?j?\b/i,operator:/[-+%=]=?|!=|:=|\*\*?=?|\/\/?=?|<[<=>]?|>[=>]?|[&|^~]/,punctuation:/[{}[\];(),.:]/}; +Prism.languages.py=Prism.languages.python; + +/* Bash / Shell */ +Prism.languages.bash={comment:{pattern:/(^|[\s#])#.*/,lookbehind:!0,greedy:!0},string:{pattern:/(["'])(?:\\[\s\S]|\$\([^)]+\)|\$(?!\()|`[^`]+`|(?!\1)[^\\`$])*\1/,greedy:!0,inside:{variable:/\$[a-zA-Z_]\w*/}},variable:/\$[a-zA-Z_]\w*|\$\{[^}]+\}/,keyword:/\b(?:if|then|else|elif|fi|for|while|in|do|done|case|esac|function|select|time)\b/,boolean:/\b(?:false|true)\b/,function:/\b\w+(?=\s*\()/}; +Prism.languages.sh=Prism.languages.bash; +Prism.languages.shell=Prism.languages.bash;