vault mode

This commit is contained in:
Michal Vrba
2026-09-22 12:53:08 +02:00
parent 29696cea9f
commit 471c823bf7
13 changed files with 993 additions and 43 deletions
+22 -2
View File
@@ -73,7 +73,12 @@
</div> </div>
<div class="top-bar-center"> <div class="top-bar-center">
<span class="save-status @saveStatus.ToLower()">@saveStatus</span> <span class="save-status @saveStatus.ToLower()"
title="@(saveErrorMessage != null ? $"Save Error: {saveErrorMessage} (Click to retry)" : (saveStatus == "Saved" ? "All changes saved" : saveStatus))"
@onclick="ManualSave"
style="@(saveStatus == "Error" ? "cursor: pointer;" : "")">
@saveStatus @(saveStatus == "Error" ? "⚠️" : "")
</span>
<span class="doc-stats" title="Words • Characters • Reading Time"> <span class="doc-stats" title="Words • Characters • Reading Time">
@wordCount words &bull; @charCount chars &bull; @readingTime min read @wordCount words &bull; @charCount chars &bull; @readingTime min read
</span> </span>
@@ -282,11 +287,17 @@
IsOpenChanged="@((val) => showNotesDrawer = val)" IsOpenChanged="@((val) => showNotesDrawer = val)"
CurrentDocumentId="@sessionId" CurrentDocumentId="@sessionId"
OnSelectDocument="@OnDocumentSelected" /> OnSelectDocument="@OnDocumentSelected" />
<!-- Secret Master Vault Modal -->
<VaultModal IsOpen="@showVaultModal"
IsOpenChanged="@((val) => showVaultModal = val)"
OnSelectDocument="@OnDocumentSelected" />
</div> </div>
@code { @code {
[Parameter] public string? DocumentId { get; set; } [Parameter] public string? DocumentId { get; set; }
private bool showVaultModal = false;
private string sessionId = ""; private string sessionId = "";
private string documentTitle = "Untitled Note"; private string documentTitle = "Untitled Note";
private bool isFlagged = false; private bool isFlagged = false;
@@ -296,6 +307,7 @@
private string renderedHtml = ""; private string renderedHtml = "";
private bool showPreview = true; private bool showPreview = true;
private string saveStatus = "Saved"; private string saveStatus = "Saved";
private string? saveErrorMessage = null;
private bool showNotesDrawer = false; private bool showNotesDrawer = false;
private bool showExportDropdown = false; private bool showExportDropdown = false;
private ElementReference editorTextareaRef; private ElementReference editorTextareaRef;
@@ -519,6 +531,7 @@
if (string.IsNullOrEmpty(sessionId)) return; if (string.IsNullOrEmpty(sessionId)) return;
saveStatus = "Saving..."; saveStatus = "Saving...";
saveErrorMessage = null;
StateHasChanged(); StateHasChanged();
try try
@@ -529,9 +542,11 @@
saveStatus = "Saved"; saveStatus = "Saved";
await UpdateFlaggedCount(); await UpdateFlaggedCount();
} }
catch catch (Exception ex)
{ {
saveErrorMessage = ex.Message;
saveStatus = "Error"; saveStatus = "Error";
Console.Error.WriteLine($"[Notemark Save Error] Session: {sessionId} - {ex}");
} }
StateHasChanged(); StateHasChanged();
@@ -618,6 +633,11 @@
{ {
await PrintDocument(); await PrintDocument();
} }
else if ((e.CtrlKey || e.MetaKey) && (e.ShiftKey || e.AltKey) && e.Key.Equals("a", StringComparison.OrdinalIgnoreCase))
{
showVaultModal = true;
StateHasChanged();
}
} }
private async Task ManualSave() private async Task ManualSave()
+57 -1
View File
@@ -1,5 +1,7 @@
@page "/" @page "/"
@page "/vault"
@rendermode InteractiveServer @rendermode InteractiveServer
@implements IDisposable
@using Notemark.Models @using Notemark.Models
@using Notemark.Services @using Notemark.Services
@inject IDocumentService DocumentService @inject IDocumentService DocumentService
@@ -13,7 +15,7 @@
<div class="home-container"> <div class="home-container">
<!-- Top Navigation Bar --> <!-- Top Navigation Bar -->
<header class="home-topbar"> <header class="home-topbar">
<div class="topbar-brand"> <div class="topbar-brand" @onclick="OnBrandLogoClicked" style="cursor: pointer;" title="Notemark">
<div class="brand-logo"> <div class="brand-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
@@ -210,16 +212,27 @@
</section> </section>
} }
</main> </main>
<!-- Secret Master Vault Modal -->
<VaultModal IsOpen="@showVaultModal"
IsOpenChanged="@((val) => showVaultModal = val)"
OnSelectDocument="@OpenNote" />
</div> </div>
@code { @code {
private bool isLoading = true; private bool isLoading = true;
private bool showVaultModal = false;
private string searchQuery = ""; private string searchQuery = "";
private string selectedTag = ""; private string selectedTag = "";
private DocumentMetadata? localRecentDoc; private DocumentMetadata? localRecentDoc;
private List<DocumentMetadata> displayedNotes = new(); private List<DocumentMetadata> displayedNotes = new();
private List<string> activeTags = new(); private List<string> activeTags = new();
// Secret Logo Click Tracking
private int logoClickCount = 0;
private Timer? logoClickTimer;
private DotNetObjectReference<Home>? dotNetRef;
private List<DocumentMetadata> flaggedDocs => displayedNotes.Where(d => d.IsFlagged).OrderByDescending(d => d.LastModifiedAt).ToList(); private List<DocumentMetadata> flaggedDocs => displayedNotes.Where(d => d.IsFlagged).OrderByDescending(d => d.LastModifiedAt).ToList();
private List<DocumentMetadata> filteredNotes private List<DocumentMetadata> filteredNotes
@@ -250,6 +263,11 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
if (Navigation.Uri.Contains("/vault", StringComparison.OrdinalIgnoreCase))
{
showVaultModal = true;
}
await LoadDashboard(); await LoadDashboard();
} }
@@ -259,6 +277,15 @@
{ {
try try
{ {
// Register global hotkey callback
dotNetRef = DotNetObjectReference.Create(this);
await JSRuntime.InvokeVoidAsync("eval", @"
window._homeVaultHandler = function() {
DotNet.invokeMethodAsync('Notemark', 'TriggerHomeVault');
};
window.addEventListener('open-notemark-vault', window._homeVaultHandler);
");
// Read last note ID worked on this specific browser/PC // Read last note ID worked on this specific browser/PC
var lastDocId = await JSRuntime.InvokeAsync<string?>("localStorage.getItem", "notemark-last-doc-id"); var lastDocId = await JSRuntime.InvokeAsync<string?>("localStorage.getItem", "notemark-last-doc-id");
if (!string.IsNullOrEmpty(lastDocId)) if (!string.IsNullOrEmpty(lastDocId))
@@ -275,6 +302,29 @@
} }
} }
private void OnBrandLogoClicked()
{
logoClickCount++;
logoClickTimer?.Dispose();
logoClickTimer = new Timer(_ =>
{
logoClickCount = 0;
}, null, 2000, Timeout.Infinite);
if (logoClickCount >= 5)
{
logoClickCount = 0;
showVaultModal = true;
StateHasChanged();
}
}
public void TriggerVault()
{
showVaultModal = true;
StateHasChanged();
}
private async Task LoadDashboard() private async Task LoadDashboard()
{ {
isLoading = true; isLoading = true;
@@ -352,4 +402,10 @@
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago"; if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago";
return date.ToLocalTime().ToString("MMM dd, yyyy"); return date.ToLocalTime().ToString("MMM dd, yyyy");
} }
public void Dispose()
{
logoClickTimer?.Dispose();
dotNetRef?.Dispose();
}
} }
+305
View File
@@ -0,0 +1,305 @@
@using Notemark.Models
@using Notemark.Services
@inject IVaultService VaultService
@inject IJSRuntime JSRuntime
@inject NavigationManager Navigation
@if (IsOpen)
{
<div class="vault-backdrop" @onclick="Close"></div>
<div class="vault-modal @(isUnlocked ? "vault-wide" : "vault-compact")">
@if (!isUnlocked)
{
<!-- Locked PIN Entry Screen -->
<div class="vault-pin-screen">
<div class="vault-pin-icon">🔐</div>
<h2>Master Vault</h2>
<p>Enter administrator PIN to access all server documents.</p>
<div class="pin-form">
<input type="password"
class="pin-input @(hasError ? "pin-error" : "")"
placeholder="Enter PIN..."
@bind="pinInput"
@bind:event="oninput"
@onkeydown="OnPinKeyDown"
autofocus />
<button type="button" class="btn-unlock" @onclick="SubmitPin">
Unlock
</button>
</div>
@if (hasError)
{
<div class="pin-error-msg">⚠️ Incorrect PIN code. Access denied.</div>
}
<div class="vault-pin-footer">
<button type="button" class="btn-cancel" @onclick="Close">Cancel</button>
</div>
</div>
}
else
{
<!-- Unlocked Document Archive -->
<div class="vault-content">
<div class="vault-header">
<div class="vault-header-title">
<span class="vault-badge">🗄️ Master Vault</span>
<h2>All Server Documents (@allDocs.Count)</h2>
</div>
<div class="vault-header-actions">
<button type="button" class="btn-lock-vault" @onclick="LockVault" title="Lock the vault again">
🔒 Lock Vault
</button>
<button type="button" class="btn-close-vault" @onclick="Close" title="Close">✕</button>
</div>
</div>
<div class="vault-toolbar">
<div class="vault-search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" placeholder="Search across all server documents, snippets or #tags..." @bind="searchQuery" @bind:event="oninput" />
@if (!string.IsNullOrEmpty(searchQuery))
{
<button class="btn-clear-search" @onclick="() => searchQuery = string.Empty">✕</button>
}
</div>
@if (allTags.Any())
{
<div class="vault-tags">
<button class="tag-chip @(string.IsNullOrEmpty(selectedTag) ? "active" : "")" @onclick="() => selectedTag = string.Empty">
All
</button>
@foreach (var tag in allTags)
{
<button class="tag-chip @(selectedTag == tag ? "active" : "")" @onclick="() => selectedTag = (selectedTag == tag ? string.Empty : tag)">
#@tag
</button>
}
</div>
}
</div>
<div class="vault-body">
@if (isLoadingDocs)
{
<div class="vault-loading">
<div class="spinner"></div>
<p>Loading documents...</p>
</div>
}
else
{
var filtered = GetFilteredDocs();
if (!filtered.Any())
{
<div class="vault-empty">
<p>No documents found matching your filter.</p>
</div>
}
else
{
<div class="vault-grid">
@foreach (var doc in filtered)
{
<div class="vault-card" @onclick="() => OpenDocument(doc.Id)">
<div class="vault-card-top">
<h3 class="vault-card-title">@doc.Title</h3>
<div class="vault-card-actions" @onclick:stopPropagation="true">
@if (doc.IsFlagged)
{
<span class="flag-icon" title="Flagged">🚩</span>
}
<button class="btn-doc-delete" @onclick="() => DeleteDocument(doc.Id)" title="Delete note permanently">
🗑️
</button>
</div>
</div>
<p class="vault-card-snippet">@doc.Snippet</p>
@if (doc.Tags.Any())
{
<div class="vault-card-tags" @onclick:stopPropagation="true">
@foreach (var tag in doc.Tags)
{
<span class="vault-tag-pill" @onclick="() => selectedTag = tag">#@tag</span>
}
</div>
}
<div class="vault-card-meta">
<span class="vault-card-date">Modified @FormatDate(doc.LastModifiedAt)</span>
<span class="vault-card-id" title="Session ID"><code>@doc.Id[..Math.Min(8, doc.Id.Length)]...</code></span>
</div>
</div>
}
</div>
}
}
</div>
</div>
}
</div>
}
@code {
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
[Parameter] public EventCallback<string> OnSelectDocument { get; set; }
private bool isUnlocked = false;
private string pinInput = "";
private bool hasError = false;
private bool isLoadingDocs = false;
private string searchQuery = "";
private string selectedTag = "";
private List<DocumentMetadata> allDocs = new();
private List<string> allTags = new();
protected override async Task OnParametersSetAsync()
{
if (IsOpen)
{
try
{
var stored = await JSRuntime.InvokeAsync<string?>("sessionStorage.getItem", "notemark_vault_unlocked");
if (stored == "true")
{
isUnlocked = true;
await LoadAllDocs();
}
else
{
isUnlocked = false;
pinInput = "";
hasError = false;
}
}
catch
{
isUnlocked = false;
}
}
}
private async Task OnPinKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
await SubmitPin();
}
}
private async Task SubmitPin()
{
hasError = false;
if (VaultService.VerifyPin(pinInput))
{
isUnlocked = true;
pinInput = "";
try
{
await JSRuntime.InvokeVoidAsync("sessionStorage.setItem", "notemark_vault_unlocked", "true");
}
catch { }
await LoadAllDocs();
}
else
{
hasError = true;
}
}
private async Task LoadAllDocs()
{
isLoadingDocs = true;
StateHasChanged();
try
{
allDocs = await VaultService.GetAllDocumentsAsync();
allTags = allDocs.SelectMany(d => d.Tags)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(t => t)
.ToList();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[Vault Load Error] {ex.Message}");
}
finally
{
isLoadingDocs = false;
StateHasChanged();
}
}
private List<DocumentMetadata> GetFilteredDocs()
{
var list = allDocs.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.Id.ToLowerInvariant().Contains(q) ||
d.Tags.Any(t => t.ToLowerInvariant().Contains(q)));
}
return list.OrderByDescending(d => d.LastModifiedAt).ToList();
}
private async Task OpenDocument(string id)
{
await OnSelectDocument.InvokeAsync(id);
await Close();
}
private async Task DeleteDocument(string id)
{
try
{
await VaultService.DeleteDocumentAsync(id);
await LoadAllDocs();
}
catch { }
}
private async Task LockVault()
{
isUnlocked = false;
pinInput = "";
hasError = false;
try
{
await JSRuntime.InvokeVoidAsync("sessionStorage.removeItem", "notemark_vault_unlocked");
}
catch { }
}
private async Task Close()
{
IsOpen = false;
await IsOpenChanged.InvokeAsync(false);
}
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 HH:mm");
}
}
@@ -0,0 +1,406 @@
.vault-backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
z-index: 10000;
animation: fadeIn 0.15s ease-out;
}
.vault-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow-lg), 0 20px 40px rgba(0, 0, 0, 0.4);
z-index: 10001;
overflow: hidden;
display: flex;
flex-direction: column;
animation: popIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
box-sizing: border-box;
}
.vault-compact {
width: 90%;
max-width: 400px;
padding: 2rem 1.75rem;
}
.vault-wide {
width: 90%;
max-width: 960px;
height: 85vh;
max-height: 800px;
}
/* PIN Screen */
.vault-pin-screen {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.vault-pin-icon {
font-size: 2.8rem;
margin-bottom: 0.5rem;
}
.vault-pin-screen h2 {
margin: 0 0 0.5rem 0;
font-size: 1.4rem;
font-weight: 700;
color: var(--primary-text);
}
.vault-pin-screen p {
margin: 0 0 1.5rem 0;
font-size: 0.875rem;
color: var(--secondary-text);
line-height: 1.5;
}
.pin-form {
width: 100%;
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.pin-input {
flex: 1;
padding: 0.65rem 1rem;
border-radius: 8px;
border: 1px solid var(--border);
background-color: var(--bg);
color: var(--primary-text);
font-size: 1.1rem;
letter-spacing: 2px;
text-align: center;
outline: none;
transition: all 0.15s ease;
}
.pin-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light);
}
.pin-input.pin-error {
border-color: var(--error);
animation: shake 0.3s ease-in-out;
}
.btn-unlock {
padding: 0.65rem 1.25rem;
background-color: var(--accent);
color: #ffffff;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease;
}
.btn-unlock:hover {
background-color: var(--accent-hover);
}
.pin-error-msg {
font-size: 0.8125rem;
color: var(--error);
margin-bottom: 1rem;
}
.vault-pin-footer {
margin-top: 0.5rem;
}
.btn-cancel {
background: none;
border: none;
color: var(--secondary-text);
font-size: 0.85rem;
cursor: pointer;
}
.btn-cancel:hover {
color: var(--primary-text);
}
/* Vault Content Unlocked */
.vault-content {
display: flex;
flex-direction: column;
height: 100%;
}
.vault-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border);
background-color: var(--surface);
}
.vault-header-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.vault-badge {
background-color: var(--accent-light);
color: var(--accent);
font-size: 0.75rem;
font-weight: 700;
padding: 0.25rem 0.5rem;
border-radius: 6px;
text-transform: uppercase;
}
.vault-header-title h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--primary-text);
}
.vault-header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.btn-lock-vault {
padding: 0.4rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: none;
color: var(--secondary-text);
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-lock-vault:hover {
background-color: var(--surface-hover);
color: var(--primary-text);
}
.btn-close-vault {
background: none;
border: none;
font-size: 1.2rem;
color: var(--secondary-text);
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 6px;
}
.btn-close-vault:hover {
background-color: var(--surface-hover);
color: var(--primary-text);
}
/* Toolbar */
.vault-toolbar {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-subtle);
background-color: var(--bg);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.vault-search {
display: flex;
align-items: center;
gap: 0.5rem;
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.45rem 0.75rem;
}
.vault-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;
}
.vault-tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.tag-chip {
padding: 0.25rem 0.6rem;
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--secondary-text);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
}
.tag-chip:hover,
.tag-chip.active {
background-color: var(--accent);
color: #ffffff;
border-color: var(--accent);
}
/* Body */
.vault-body {
flex: 1;
overflow-y: auto;
padding: 1.25rem 1.5rem;
}
.vault-loading,
.vault-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 1rem;
color: var(--secondary-text);
}
.vault-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.vault-card {
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem 1.25rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
}
.vault-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.vault-card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.vault-card-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--primary-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vault-card-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.btn-doc-delete {
background: none;
border: none;
cursor: pointer;
font-size: 0.85rem;
opacity: 0.4;
transition: opacity 0.15s ease;
}
.btn-doc-delete:hover {
opacity: 1;
}
.vault-card-snippet {
margin: 0 0 0.75rem 0;
font-size: 0.8125rem;
color: var(--secondary-text);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.vault-card-tags {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-bottom: 0.75rem;
}
.vault-tag-pill {
font-size: 0.7rem;
background-color: var(--accent-light);
color: var(--accent);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-weight: 500;
}
.vault-card-meta {
margin-top: auto;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.72rem;
color: var(--secondary-text);
border-top: 1px solid var(--border-subtle);
padding-top: 0.5rem;
}
.vault-card-id code {
font-size: 0.68rem;
background: var(--code-bg);
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes popIn {
from { opacity: 0; transform: translate(-50%, -46%) scale(0.96); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-6px); }
40%, 80% { transform: translateX(6px); }
}
+13 -11
View File
@@ -5,7 +5,18 @@ var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // Add services to the container.
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents(options =>
{
options.DetailedErrors = true;
})
.AddHubOptions(options =>
{
options.MaximumReceiveMessageSize = 32 * 1024 * 1024; // 32MB for images and large markdown notes
options.ClientTimeoutInterval = TimeSpan.FromMinutes(2);
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
options.HandshakeTimeout = TimeSpan.FromSeconds(30);
options.EnableDetailedErrors = true;
});
// Add HTTP context accessor for session management // Add HTTP context accessor for session management
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
@@ -15,16 +26,7 @@ builder.Services.AddSingleton<IMarkdownService, MarkdownService>();
builder.Services.AddSingleton<IDocumentService, DocumentService>(); builder.Services.AddSingleton<IDocumentService, DocumentService>();
builder.Services.AddScoped<ISessionService, SessionService>(); builder.Services.AddScoped<ISessionService, SessionService>();
builder.Services.AddScoped<IShareService, ShareService>(); builder.Services.AddScoped<IShareService, ShareService>();
builder.Services.AddScoped<IVaultService, VaultService>();
// Configure SignalR for better WebSocket support
builder.Services.AddSignalR(options =>
{
// Increase timeouts for large content
options.ClientTimeoutInterval = TimeSpan.FromMinutes(2);
options.KeepAliveInterval = TimeSpan.FromSeconds(30);
options.HandshakeTimeout = TimeSpan.FromSeconds(30);
options.MaximumReceiveMessageSize = 1024 * 1024; // 1MB
});
var app = builder.Build(); var app = builder.Build();
+39 -7
View File
@@ -15,9 +15,22 @@ public partial class DocumentService : IDocumentService
public DocumentService(IWebHostEnvironment environment) public DocumentService(IWebHostEnvironment environment)
{ {
var dataDir = Path.Combine(environment.ContentRootPath, "data"); var baseDataPath = Environment.GetEnvironmentVariable("NOTEMARK_DATA_DIR");
Directory.CreateDirectory(dataDir); if (string.IsNullOrWhiteSpace(baseDataPath))
_metadataFilePath = Path.Combine(dataDir, "documents.json"); {
baseDataPath = Path.Combine(environment.ContentRootPath, "data");
}
try
{
Directory.CreateDirectory(baseDataPath);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DocumentService Init Error] Failed to create {baseDataPath}: {ex.Message}");
}
_metadataFilePath = Path.Combine(baseDataPath, "documents.json");
_jsonOptions = new JsonSerializerOptions _jsonOptions = new JsonSerializerOptions
{ {
@@ -265,18 +278,37 @@ public partial class DocumentService : IDocumentService
try try
{ {
var json = await File.ReadAllTextAsync(_metadataFilePath); await using var stream = new FileStream(_metadataFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
return JsonSerializer.Deserialize<List<DocumentMetadata>>(json, _jsonOptions) ?? new List<DocumentMetadata>(); return await JsonSerializer.DeserializeAsync<List<DocumentMetadata>>(stream, _jsonOptions) ?? new List<DocumentMetadata>();
} }
catch catch (Exception ex)
{ {
Console.Error.WriteLine($"[DocumentService Read Error] {_metadataFilePath}: {ex.Message}");
return new List<DocumentMetadata>(); return new List<DocumentMetadata>();
} }
} }
private async Task WriteMetadataListInternalAsync(List<DocumentMetadata> list) private async Task WriteMetadataListInternalAsync(List<DocumentMetadata> list)
{ {
var dataDir = Path.GetDirectoryName(_metadataFilePath);
if (!string.IsNullOrEmpty(dataDir) && !Directory.Exists(dataDir))
{
Directory.CreateDirectory(dataDir);
}
var json = JsonSerializer.Serialize(list, _jsonOptions); var json = JsonSerializer.Serialize(list, _jsonOptions);
await File.WriteAllTextAsync(_metadataFilePath, json);
try
{
await using var fs = new FileStream(_metadataFilePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite, bufferSize: 4096, useAsync: true);
await using var writer = new StreamWriter(fs, System.Text.Encoding.UTF8);
await writer.WriteAsync(json);
await writer.FlushAsync();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DocumentService Write Error] {_metadataFilePath}: {ex}");
throw;
}
} }
} }
+10
View File
@@ -0,0 +1,10 @@
using Notemark.Models;
namespace Notemark.Services;
public interface IVaultService
{
bool VerifyPin(string? enteredPin);
Task<List<DocumentMetadata>> GetAllDocumentsAsync();
Task DeleteDocumentAsync(string id);
}
+50 -17
View File
@@ -9,15 +9,27 @@ public class SessionService : ISessionService
public SessionService(IWebHostEnvironment environment) public SessionService(IWebHostEnvironment environment)
{ {
_dataPath = Path.Combine(environment.ContentRootPath, "data", "sessions"); var baseDataPath = Environment.GetEnvironmentVariable("NOTEMARK_DATA_DIR");
Directory.CreateDirectory(_dataPath); if (string.IsNullOrWhiteSpace(baseDataPath))
{
baseDataPath = Path.Combine(environment.ContentRootPath, "data");
}
_dataPath = Path.Combine(baseDataPath, "sessions");
try
{
Directory.CreateDirectory(_dataPath);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[SessionService Init Error] Failed to create {_dataPath}: {ex.Message}");
}
} }
public string GetOrCreateSessionId(HttpContext httpContext) public string GetOrCreateSessionId(HttpContext httpContext)
{ {
// Try to get existing session from cookie // Try to get existing session from cookie
if (httpContext.Request.Cookies.TryGetValue(SessionCookieName, out var existingSessionId) if (httpContext.Request.Cookies.TryGetValue(SessionCookieName, out var existingSessionId)
&& Guid.TryParse(existingSessionId, out _)) && !string.IsNullOrWhiteSpace(existingSessionId))
{ {
return existingSessionId; return existingSessionId;
} }
@@ -30,11 +42,18 @@ public class SessionService : ISessionService
{ {
Expires = DateTimeOffset.UtcNow.AddDays(30), Expires = DateTimeOffset.UtcNow.AddDays(30),
HttpOnly = true, HttpOnly = true,
Secure = true, Secure = httpContext.Request.IsHttps,
SameSite = SameSiteMode.Lax SameSite = SameSiteMode.Lax
}; };
httpContext.Response.Cookies.Append(SessionCookieName, newSessionId, cookieOptions); try
{
if (!httpContext.Response.HasStarted)
{
httpContext.Response.Cookies.Append(SessionCookieName, newSessionId, cookieOptions);
}
}
catch { }
return newSessionId; return newSessionId;
} }
@@ -46,7 +65,17 @@ public class SessionService : ISessionService
if (!File.Exists(filePath)) if (!File.Exists(filePath))
return string.Empty; return string.Empty;
return await File.ReadAllTextAsync(filePath); try
{
await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(fileStream, Encoding.UTF8);
return await reader.ReadToEndAsync();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[SessionService Load Error] {filePath}: {ex.Message}");
return string.Empty;
}
} }
public async Task SaveSessionContentAsync(string sessionId, string content) public async Task SaveSessionContentAsync(string sessionId, string content)
@@ -60,16 +89,15 @@ public class SessionService : ISessionService
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
} }
// For large content, use streaming to avoid memory issues // Write content safely with FileShare.ReadWrite
try try
{ {
if (string.IsNullOrEmpty(content)) if (string.IsNullOrEmpty(content))
{ {
await File.WriteAllTextAsync(filePath, string.Empty, Encoding.UTF8); await using var emptyStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite);
return; return;
} }
// 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 fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite, bufferSize: 8192, useAsync: true);
await using var writer = new StreamWriter(fileStream, Encoding.UTF8); await using var writer = new StreamWriter(fileStream, Encoding.UTF8);
@@ -81,19 +109,19 @@ public class SessionService : ISessionService
{ {
int length = Math.Min(chunkSize, content.Length - i); int length = Math.Min(chunkSize, content.Length - i);
await writer.WriteAsync(content.AsMemory(i, length)); await writer.WriteAsync(content.AsMemory(i, length));
await writer.FlushAsync(); // Ensure data is written await writer.FlushAsync();
} }
} }
else else
{ {
await writer.WriteAsync(content); await writer.WriteAsync(content);
await writer.FlushAsync();
} }
} }
catch (DirectoryNotFoundException) catch (Exception ex)
{ {
// Retry after creating directory Console.Error.WriteLine($"[SessionService Save Error] {filePath}: {ex}");
Directory.CreateDirectory(directory); throw;
await File.WriteAllTextAsync(filePath, content ?? string.Empty, Encoding.UTF8);
} }
} }
@@ -105,9 +133,14 @@ public class SessionService : ISessionService
private string GetSessionFilePath(string sessionId) private string GetSessionFilePath(string sessionId)
{ {
if (!Guid.TryParse(sessionId, out _)) if (string.IsNullOrWhiteSpace(sessionId))
throw new ArgumentException("Invalid session ID format", nameof(sessionId)); sessionId = Guid.NewGuid().ToString();
return Path.Combine(_dataPath, $"{sessionId}.md"); // Sanitize sessionId for cross-platform file paths
var safeId = string.Concat(sessionId.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'));
if (string.IsNullOrEmpty(safeId))
safeId = Guid.NewGuid().ToString();
return Path.Combine(_dataPath, $"{safeId}.md");
} }
} }
+24 -5
View File
@@ -9,8 +9,21 @@ public class ShareService : IShareService
public ShareService(IWebHostEnvironment environment) public ShareService(IWebHostEnvironment environment)
{ {
_dataPath = Path.Combine(environment.ContentRootPath, "data", "shares"); var baseDataPath = Environment.GetEnvironmentVariable("NOTEMARK_DATA_DIR");
Directory.CreateDirectory(_dataPath); if (string.IsNullOrWhiteSpace(baseDataPath))
{
baseDataPath = Path.Combine(environment.ContentRootPath, "data");
}
_dataPath = Path.Combine(baseDataPath, "shares");
try
{
Directory.CreateDirectory(_dataPath);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ShareService Init Error] Failed to create {_dataPath}: {ex.Message}");
}
_jsonOptions = new JsonSerializerOptions _jsonOptions = new JsonSerializerOptions
{ {
@@ -21,8 +34,8 @@ public class ShareService : IShareService
public async Task<string> CreateShareTokenAsync(string sessionId, string mode) public async Task<string> CreateShareTokenAsync(string sessionId, string mode)
{ {
if (!Guid.TryParse(sessionId, out _)) if (string.IsNullOrWhiteSpace(sessionId))
throw new ArgumentException("Invalid session ID format", nameof(sessionId)); throw new ArgumentException("Session ID cannot be empty", nameof(sessionId));
if (mode != "readonly" && mode != "editable") if (mode != "readonly" && mode != "editable")
throw new ArgumentException("Mode must be 'readonly' or 'editable'", nameof(mode)); throw new ArgumentException("Mode must be 'readonly' or 'editable'", nameof(mode));
@@ -35,8 +48,14 @@ public class ShareService : IShareService
); );
var filePath = GetTokenFilePath(token); var filePath = GetTokenFilePath(token);
var dir = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
var json = JsonSerializer.Serialize(shareToken, _jsonOptions); var json = JsonSerializer.Serialize(shareToken, _jsonOptions);
await File.WriteAllTextAsync(filePath, json); await File.WriteAllTextAsync(filePath, json, System.Text.Encoding.UTF8);
return token; return token;
} }
+37
View File
@@ -0,0 +1,37 @@
using Notemark.Models;
namespace Notemark.Services;
public class VaultService : IVaultService
{
private readonly IConfiguration _configuration;
private readonly IDocumentService _documentService;
public VaultService(IConfiguration configuration, IDocumentService documentService)
{
_configuration = configuration;
_documentService = documentService;
}
public bool VerifyPin(string? enteredPin)
{
if (string.IsNullOrWhiteSpace(enteredPin))
return false;
var configuredPin = Environment.GetEnvironmentVariable("NOTEMARK_ADMIN_PIN")
?? _configuration["AdminPin"]
?? "12131411";
return string.Equals(enteredPin.Trim(), configuredPin.Trim(), StringComparison.Ordinal);
}
public async Task<List<DocumentMetadata>> GetAllDocumentsAsync()
{
return await _documentService.GetAllDocumentsAsync();
}
public async Task DeleteDocumentAsync(string id)
{
await _documentService.DeleteDocumentAsync(id);
}
}
+1
View File
@@ -1,4 +1,5 @@
{ {
"AdminPin": "12131411",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<location path="." inheritInChildApplications="false">
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
</handlers>
<aspNetCore processPath="dotnet" arguments=".\Notemark.dll" stdoutLogEnabled="true" stdoutLogFile=".\logs\stdout" hostingModel="inprocess">
<environmentVariables>
<environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Production" />
</environmentVariables>
</aspNetCore>
<security>
<requestFiltering>
<!-- Allow up to 100 MB uploads (base64 images etc.) -->
<requestLimits maxAllowedContentLength="104857600" />
</requestFiltering>
</security>
</system.webServer>
</location>
</configuration>
+8
View File
@@ -299,3 +299,11 @@ window.notemarkEditor = {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
}; };
// Global Hotkey for Vault (Ctrl + Shift + A or Ctrl + Alt + A)
window.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && (e.shiftKey || e.altKey) && (e.key === 'a' || e.key === 'A')) {
e.preventDefault();
window.dispatchEvent(new CustomEvent('open-notemark-vault'));
}
});