vault mode
This commit is contained in:
@@ -73,7 +73,12 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
@wordCount words • @charCount chars • @readingTime min read
|
||||
</span>
|
||||
@@ -282,11 +287,17 @@
|
||||
IsOpenChanged="@((val) => showNotesDrawer = val)"
|
||||
CurrentDocumentId="@sessionId"
|
||||
OnSelectDocument="@OnDocumentSelected" />
|
||||
|
||||
<!-- Secret Master Vault Modal -->
|
||||
<VaultModal IsOpen="@showVaultModal"
|
||||
IsOpenChanged="@((val) => showVaultModal = val)"
|
||||
OnSelectDocument="@OnDocumentSelected" />
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? DocumentId { get; set; }
|
||||
|
||||
private bool showVaultModal = false;
|
||||
private string sessionId = "";
|
||||
private string documentTitle = "Untitled Note";
|
||||
private bool isFlagged = false;
|
||||
@@ -296,6 +307,7 @@
|
||||
private string renderedHtml = "";
|
||||
private bool showPreview = true;
|
||||
private string saveStatus = "Saved";
|
||||
private string? saveErrorMessage = null;
|
||||
private bool showNotesDrawer = false;
|
||||
private bool showExportDropdown = false;
|
||||
private ElementReference editorTextareaRef;
|
||||
@@ -519,6 +531,7 @@
|
||||
if (string.IsNullOrEmpty(sessionId)) return;
|
||||
|
||||
saveStatus = "Saving...";
|
||||
saveErrorMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
@@ -529,9 +542,11 @@
|
||||
saveStatus = "Saved";
|
||||
await UpdateFlaggedCount();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
saveErrorMessage = ex.Message;
|
||||
saveStatus = "Error";
|
||||
Console.Error.WriteLine($"[Notemark Save Error] Session: {sessionId} - {ex}");
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
@@ -618,6 +633,11 @@
|
||||
{
|
||||
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()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@page "/"
|
||||
@page "/vault"
|
||||
@rendermode InteractiveServer
|
||||
@implements IDisposable
|
||||
@using Notemark.Models
|
||||
@using Notemark.Services
|
||||
@inject IDocumentService DocumentService
|
||||
@@ -13,7 +15,7 @@
|
||||
<div class="home-container">
|
||||
<!-- Top Navigation Bar -->
|
||||
<header class="home-topbar">
|
||||
<div class="topbar-brand">
|
||||
<div class="topbar-brand" @onclick="OnBrandLogoClicked" style="cursor: pointer;" title="Notemark">
|
||||
<div class="brand-logo">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
@@ -210,16 +212,27 @@
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
|
||||
<!-- Secret Master Vault Modal -->
|
||||
<VaultModal IsOpen="@showVaultModal"
|
||||
IsOpenChanged="@((val) => showVaultModal = val)"
|
||||
OnSelectDocument="@OpenNote" />
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool isLoading = true;
|
||||
private bool showVaultModal = false;
|
||||
private string searchQuery = "";
|
||||
private string selectedTag = "";
|
||||
private DocumentMetadata? localRecentDoc;
|
||||
private List<DocumentMetadata> displayedNotes = 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> filteredNotes
|
||||
@@ -250,6 +263,11 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (Navigation.Uri.Contains("/vault", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
showVaultModal = true;
|
||||
}
|
||||
|
||||
await LoadDashboard();
|
||||
}
|
||||
|
||||
@@ -259,6 +277,15 @@
|
||||
{
|
||||
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
|
||||
var lastDocId = await JSRuntime.InvokeAsync<string?>("localStorage.getItem", "notemark-last-doc-id");
|
||||
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()
|
||||
{
|
||||
isLoading = true;
|
||||
@@ -352,4 +402,10 @@
|
||||
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago";
|
||||
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.
|
||||
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
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
@@ -15,16 +26,7 @@ builder.Services.AddSingleton<IMarkdownService, MarkdownService>();
|
||||
builder.Services.AddSingleton<IDocumentService, DocumentService>();
|
||||
builder.Services.AddScoped<ISessionService, SessionService>();
|
||||
builder.Services.AddScoped<IShareService, ShareService>();
|
||||
|
||||
// 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
|
||||
});
|
||||
builder.Services.AddScoped<IVaultService, VaultService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -15,9 +15,22 @@ public partial class DocumentService : IDocumentService
|
||||
|
||||
public DocumentService(IWebHostEnvironment environment)
|
||||
{
|
||||
var dataDir = Path.Combine(environment.ContentRootPath, "data");
|
||||
Directory.CreateDirectory(dataDir);
|
||||
_metadataFilePath = Path.Combine(dataDir, "documents.json");
|
||||
var baseDataPath = Environment.GetEnvironmentVariable("NOTEMARK_DATA_DIR");
|
||||
if (string.IsNullOrWhiteSpace(baseDataPath))
|
||||
{
|
||||
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
|
||||
{
|
||||
@@ -265,18 +278,37 @@ public partial class DocumentService : IDocumentService
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(_metadataFilePath);
|
||||
return JsonSerializer.Deserialize<List<DocumentMetadata>>(json, _jsonOptions) ?? new List<DocumentMetadata>();
|
||||
await using var stream = new FileStream(_metadataFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
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>();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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)
|
||||
{
|
||||
_dataPath = Path.Combine(environment.ContentRootPath, "data", "sessions");
|
||||
var baseDataPath = Environment.GetEnvironmentVariable("NOTEMARK_DATA_DIR");
|
||||
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)
|
||||
{
|
||||
// Try to get existing session from cookie
|
||||
if (httpContext.Request.Cookies.TryGetValue(SessionCookieName, out var existingSessionId)
|
||||
&& Guid.TryParse(existingSessionId, out _))
|
||||
&& !string.IsNullOrWhiteSpace(existingSessionId))
|
||||
{
|
||||
return existingSessionId;
|
||||
}
|
||||
@@ -30,11 +42,18 @@ public class SessionService : ISessionService
|
||||
{
|
||||
Expires = DateTimeOffset.UtcNow.AddDays(30),
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
Secure = httpContext.Request.IsHttps,
|
||||
SameSite = SameSiteMode.Lax
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
if (!httpContext.Response.HasStarted)
|
||||
{
|
||||
httpContext.Response.Cookies.Append(SessionCookieName, newSessionId, cookieOptions);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return newSessionId;
|
||||
}
|
||||
@@ -46,7 +65,17 @@ public class SessionService : ISessionService
|
||||
if (!File.Exists(filePath))
|
||||
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)
|
||||
@@ -60,16 +89,15 @@ public class SessionService : ISessionService
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
// For large content, use streaming to avoid memory issues
|
||||
// Write content safely with FileShare.ReadWrite
|
||||
try
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -81,19 +109,19 @@ public class SessionService : ISessionService
|
||||
{
|
||||
int length = Math.Min(chunkSize, content.Length - i);
|
||||
await writer.WriteAsync(content.AsMemory(i, length));
|
||||
await writer.FlushAsync(); // Ensure data is written
|
||||
await writer.FlushAsync();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await writer.WriteAsync(content);
|
||||
await writer.FlushAsync();
|
||||
}
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Retry after creating directory
|
||||
Directory.CreateDirectory(directory);
|
||||
await File.WriteAllTextAsync(filePath, content ?? string.Empty, Encoding.UTF8);
|
||||
Console.Error.WriteLine($"[SessionService Save Error] {filePath}: {ex}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,9 +133,14 @@ public class SessionService : ISessionService
|
||||
|
||||
private string GetSessionFilePath(string sessionId)
|
||||
{
|
||||
if (!Guid.TryParse(sessionId, out _))
|
||||
throw new ArgumentException("Invalid session ID format", nameof(sessionId));
|
||||
if (string.IsNullOrWhiteSpace(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)
|
||||
{
|
||||
_dataPath = Path.Combine(environment.ContentRootPath, "data", "shares");
|
||||
var baseDataPath = Environment.GetEnvironmentVariable("NOTEMARK_DATA_DIR");
|
||||
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
|
||||
{
|
||||
@@ -21,8 +34,8 @@ public class ShareService : IShareService
|
||||
|
||||
public async Task<string> CreateShareTokenAsync(string sessionId, string mode)
|
||||
{
|
||||
if (!Guid.TryParse(sessionId, out _))
|
||||
throw new ArgumentException("Invalid session ID format", nameof(sessionId));
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
throw new ArgumentException("Session ID cannot be empty", nameof(sessionId));
|
||||
|
||||
if (mode != "readonly" && mode != "editable")
|
||||
throw new ArgumentException("Mode must be 'readonly' or 'editable'", nameof(mode));
|
||||
@@ -35,8 +48,14 @@ public class ShareService : IShareService
|
||||
);
|
||||
|
||||
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);
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
await File.WriteAllTextAsync(filePath, json, System.Text.Encoding.UTF8);
|
||||
|
||||
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": {
|
||||
"LogLevel": {
|
||||
"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);
|
||||
}
|
||||
};
|
||||
|
||||
// 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