vault mode
This commit is contained in:
@@ -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 • @charCount chars • @readingTime min read
|
@wordCount words • @charCount chars • @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()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"AdminPin": "12131411",
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user