diff --git a/Notemark/Components/Layout/MainLayout.razor b/Notemark/Components/Layout/MainLayout.razor index 78624f3..56f8f66 100644 --- a/Notemark/Components/Layout/MainLayout.razor +++ b/Notemark/Components/Layout/MainLayout.razor @@ -1,19 +1,7 @@ @inherits LayoutComponentBase -
- - -
-
- About -
- -
- @Body -
-
+
+ @Body
diff --git a/Notemark/Components/Layout/MainLayout.razor.css b/Notemark/Components/Layout/MainLayout.razor.css index 38d1f25..888471b 100644 --- a/Notemark/Components/Layout/MainLayout.razor.css +++ b/Notemark/Components/Layout/MainLayout.razor.css @@ -1,98 +1,97 @@ -.page { - position: relative; - display: flex; - flex-direction: column; +/* Notemark CSS Variables for Light/Dark Theme */ +:root { + /* Light Mode (Default) */ + --bg: #F9FAFB; + --surface: #FFFFFF; + --border: #E5E7EB; + --primary-text: #111827; + --secondary-text: #6B7280; + --accent: #4F46E5; + --accent-hover: #4338CA; + --success: #10B981; + --error: #F43F5E; } -main { - flex: 1; +[data-theme="dark"] { + /* Dark Mode */ + --bg: #0B0F19; + --surface: #111827; + --border: #1F2937; + --primary-text: #F9FAFB; + --secondary-text: #9CA3AF; + --accent: #22D3EE; + --accent-hover: #06B6D4; + --success: #4ADE80; + --error: #FB7185; } -.sidebar { - background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +/* Auto-detect system preference */ +@media (prefers-color-scheme: dark) { + :root { + --bg: #0B0F19; + --surface: #111827; + --border: #1F2937; + --primary-text: #F9FAFB; + --secondary-text: #9CA3AF; + --accent: #22D3EE; + --accent-hover: #06B6D4; + --success: #4ADE80; + --error: #FB7185; + } } -.top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; - justify-content: flex-end; - height: 3.5rem; - display: flex; - align-items: center; +/* Override system dark mode when explicitly set to light */ +[data-theme="light"] { + --bg: #F9FAFB; + --surface: #FFFFFF; + --border: #E5E7EB; + --primary-text: #111827; + --secondary-text: #6B7280; + --accent: #4F46E5; + --accent-hover: #4338CA; + --success: #10B981; + --error: #F43F5E; } - .top-row ::deep a, .top-row ::deep .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - text-decoration: none; - } - - .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { - text-decoration: underline; - } - - .top-row ::deep a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } - -@media (max-width: 640.98px) { - .top-row { - justify-content: space-between; - } - - .top-row ::deep a, .top-row ::deep .btn-link { - margin-left: 0; - } +/* Notemark App Layout */ +.notemark-app { + height: 100vh; + width: 100vw; + background-color: var(--bg); + color: var(--primary-text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + overflow: hidden; } -@media (min-width: 641px) { - .page { - flex-direction: row; - } +/* Reset default margins and padding */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} - .sidebar { - width: 250px; - height: 100vh; - position: sticky; - top: 0; - } - - .top-row { - position: sticky; - top: 0; - z-index: 1; - } - - .top-row.auth ::deep a:first-child { - flex: 1; - text-align: right; - width: 0; - } - - .top-row, article { - padding-left: 2rem !important; - padding-right: 1.5rem !important; - } +body { + background-color: var(--bg); + color: var(--primary-text); } #blazor-error-ui { - color-scheme: light only; - background: lightyellow; - bottom: 0; - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); - box-sizing: border-box; - display: none; - left: 0; - padding: 0.6rem 1.25rem 0.7rem 1.25rem; - position: fixed; - width: 100%; - z-index: 1000; + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; } - #blazor-error-ui .dismiss { - cursor: pointer; - position: absolute; - right: 0.75rem; - top: 0.5rem; - } +#blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; +} diff --git a/Notemark/Components/Layout/NavMenu.razor b/Notemark/Components/Layout/NavMenu.razor index 3edd244..0f160cb 100644 --- a/Notemark/Components/Layout/NavMenu.razor +++ b/Notemark/Components/Layout/NavMenu.razor @@ -1,30 +1,2 @@ - - - - - +@* Notemark uses a minimalist layout with no navigation menu *@ diff --git a/Notemark/Components/Pages/Counter.razor b/Notemark/Components/Pages/Counter.razor deleted file mode 100644 index 1a4f8e7..0000000 --- a/Notemark/Components/Pages/Counter.razor +++ /dev/null @@ -1,19 +0,0 @@ -@page "/counter" -@rendermode InteractiveServer - -Counter - -

Counter

- -

Current count: @currentCount

- - - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} diff --git a/Notemark/Components/Pages/Editor.razor b/Notemark/Components/Pages/Editor.razor new file mode 100644 index 0000000..92b967a --- /dev/null +++ b/Notemark/Components/Pages/Editor.razor @@ -0,0 +1,245 @@ +@page "/" +@rendermode InteractiveServer +@using Notemark.Services +@inject ISessionService SessionService +@inject IMarkdownService MarkdownService +@inject IShareService ShareService +@inject IJSRuntime JSRuntime +@inject IHttpContextAccessor HttpContextAccessor + +Notemark + +
+ +
+
+ +
+ +
+ @saveStatus +
+ +
+ +
+
+ + +
+ +
+ +
+ + + @if (showPreview) + { +
+
+ @((MarkupString)renderedHtml) +
+
+ } +
+ + + @if (showShareModal) + { + + } +
+ +@code { + private string sessionId = ""; + private string markdownContent = ""; + private string renderedHtml = ""; + private bool showPreview = false; + private string saveStatus = "Saved"; + + // Share modal state + private bool showShareModal = false; + private bool isGeneratingLinks = false; + private string readonlyLink = ""; + private string editableLink = ""; + + // Auto-save timer + private Timer? autoSaveTimer; + private bool hasUnsavedChanges = false; + + protected override async Task OnInitializedAsync() + { + // Get or create session + var httpContext = HttpContextAccessor.HttpContext; + if (httpContext != null) + { + sessionId = SessionService.GetOrCreateSessionId(httpContext); + + // Load existing content + markdownContent = await SessionService.LoadSessionContentAsync(sessionId); + + // Render initial preview if needed + if (showPreview) + { + renderedHtml = MarkdownService.RenderToHtml(markdownContent); + } + } + } + + private async Task OnContentChanged(ChangeEventArgs e) + { + markdownContent = e.Value?.ToString() ?? ""; + hasUnsavedChanges = true; + saveStatus = "Saving..."; + + // Update preview if visible + if (showPreview) + { + renderedHtml = MarkdownService.RenderToHtml(markdownContent); + } + + // Debounce auto-save + autoSaveTimer?.Dispose(); + autoSaveTimer = new Timer(async _ => await AutoSave(), null, 1000, Timeout.Infinite); + + await InvokeAsync(StateHasChanged); + } + + private async Task OnKeyDown(KeyboardEventArgs e) + { + // Ctrl+S or Cmd+S for manual save + if ((e.CtrlKey || e.MetaKey) && e.Key == "s") + { + await SaveContent(); + } + } + + private async Task AutoSave() + { + if (hasUnsavedChanges) + { + await SaveContent(); + } + } + + private async Task SaveContent() + { + if (string.IsNullOrEmpty(sessionId)) + return; + + try + { + await SessionService.SaveSessionContentAsync(sessionId, markdownContent); + hasUnsavedChanges = false; + saveStatus = "Saved"; + } + catch + { + saveStatus = "Error"; + } + + await InvokeAsync(StateHasChanged); + } + + private void TogglePreview() + { + showPreview = !showPreview; + + if (showPreview) + { + renderedHtml = MarkdownService.RenderToHtml(markdownContent); + } + } + + private async Task ShowShareModal() + { + showShareModal = true; + isGeneratingLinks = true; + readonlyLink = ""; + editableLink = ""; + + await InvokeAsync(StateHasChanged); + + try + { + // Generate share tokens + var readonlyToken = await ShareService.CreateShareTokenAsync(sessionId, "readonly"); + var editableToken = await ShareService.CreateShareTokenAsync(sessionId, "editable"); + + // Build URLs + var httpContext = HttpContextAccessor.HttpContext; + if (httpContext != null) + { + var baseUrl = $"{httpContext.Request.Scheme}://{httpContext.Request.Host}"; + readonlyLink = $"{baseUrl}/s/{readonlyToken}"; + editableLink = $"{baseUrl}/e/{editableToken}"; + } + } + catch + { + readonlyLink = "Error generating link"; + editableLink = "Error generating link"; + } + finally + { + isGeneratingLinks = false; + await InvokeAsync(StateHasChanged); + } + } + + private void HideShareModal() + { + showShareModal = false; + } + + private async Task CopyToClipboard(string text) + { + await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", text); + } + + public void Dispose() + { + autoSaveTimer?.Dispose(); + } +} \ No newline at end of file diff --git a/Notemark/Components/Pages/Editor.razor.css b/Notemark/Components/Pages/Editor.razor.css new file mode 100644 index 0000000..937055e --- /dev/null +++ b/Notemark/Components/Pages/Editor.razor.css @@ -0,0 +1,345 @@ +/* Notemark Editor Styles */ + +.editor-container { + height: 100vh; + display: flex; + flex-direction: column; + background-color: var(--bg); +} + +/* Top Bar */ +.top-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background-color: var(--surface); + border-bottom: 1px solid var(--border); + height: 60px; + flex-shrink: 0; +} + +.top-bar-left, +.top-bar-center, +.top-bar-right { + display: flex; + align-items: center; +} + +.top-bar-center { + flex: 1; + justify-content: center; +} + +/* Buttons */ +.btn-toggle, +.btn-share, +.btn-copy, +.btn-close { + background: none; + border: 1px solid var(--border); + color: var(--primary-text); + padding: 0.5rem 1rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; + font-family: inherit; + transition: all 0.2s ease; +} + +.btn-toggle:hover, +.btn-share:hover, +.btn-copy:hover { + background-color: var(--accent); + color: white; + border-color: var(--accent); +} + +.btn-toggle.active { + background-color: var(--accent); + color: white; + border-color: var(--accent); +} + +.btn-close { + padding: 0.25rem 0.5rem; + font-size: 1.25rem; + line-height: 1; + background: none; + border: none; +} + +.btn-close:hover { + background-color: var(--error); + color: white; +} + +/* Save Status */ +.save-status { + font-size: 0.875rem; + font-weight: 500; + padding: 0.25rem 0.75rem; + border-radius: 4px; +} + +.save-status.saved { + color: var(--success); +} + +.save-status.saving { + color: var(--secondary-text); +} + +.save-status.error { + color: var(--error); +} + +/* Editor Layout */ +.editor-layout { + flex: 1; + display: flex; + min-height: 0; +} + +.editor-layout.full-view { + flex-direction: column; +} + +.editor-layout.split-view { + flex-direction: row; +} + +/* Editor Pane */ +.editor-pane { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.split-view .editor-pane { + border-right: 1px solid var(--border); +} + +.markdown-editor { + flex: 1; + width: 100%; + border: none; + outline: none; + resize: none; + font-family: 'JetBrains Mono', 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; + font-size: 14px; + line-height: 1.6; + padding: 1.5rem; + background-color: var(--bg); + color: var(--primary-text); +} + +.markdown-editor::placeholder { + color: var(--secondary-text); +} + +.markdown-editor:focus { + outline: none; +} + +/* Preview Pane */ +.preview-pane { + flex: 1; + display: flex; + flex-direction: column; + background-color: var(--surface); + min-width: 0; +} + +.preview-content { + flex: 1; + padding: 1.5rem; + overflow-y: auto; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + line-height: 1.6; +} + +/* Markdown Preview Styles */ +.preview-content h1, +.preview-content h2, +.preview-content h3, +.preview-content h4, +.preview-content h5, +.preview-content h6 { + color: var(--primary-text); + margin-top: 2em; + margin-bottom: 1em; + font-weight: 600; +} + +.preview-content h1 { font-size: 2em; } +.preview-content h2 { font-size: 1.5em; } +.preview-content h3 { font-size: 1.25em; } + +.preview-content p { + margin-bottom: 1em; + color: var(--primary-text); +} + +.preview-content code { + background-color: var(--bg); + padding: 0.2em 0.4em; + border-radius: 3px; + font-family: 'JetBrains Mono', 'SF Mono', 'Monaco', monospace; + font-size: 0.875em; + color: var(--accent); +} + +.preview-content pre { + background-color: var(--bg); + padding: 1em; + border-radius: 6px; + overflow-x: auto; + margin-bottom: 1em; + border: 1px solid var(--border); +} + +.preview-content pre code { + background: none; + padding: 0; + color: var(--primary-text); +} + +.preview-content blockquote { + border-left: 4px solid var(--accent); + padding-left: 1em; + margin-left: 0; + margin-bottom: 1em; + color: var(--secondary-text); +} + +.preview-content ul, +.preview-content ol { + margin-bottom: 1em; + padding-left: 2em; +} + +.preview-content table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1em; +} + +.preview-content th, +.preview-content td { + border: 1px solid var(--border); + padding: 0.5em 1em; + text-align: left; +} + +.preview-content th { + background-color: var(--bg); + font-weight: 600; +} + +/* Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background-color: var(--surface); + border-radius: 8px; + width: 90%; + max-width: 500px; + max-height: 80vh; + overflow: hidden; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border); +} + +.modal-header h3 { + margin: 0; + font-size: 1.25rem; + color: var(--primary-text); +} + +.modal-body { + padding: 1.5rem; +} + +.share-link-group { + margin-bottom: 1.5rem; +} + +.share-link-group:last-child { + margin-bottom: 0; +} + +.share-link-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--primary-text); +} + +.link-input-group { + display: flex; + gap: 0.5rem; +} + +.link-input { + flex: 1; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + background-color: var(--bg); + color: var(--primary-text); + font-family: monospace; + font-size: 0.875rem; +} + +.loading { + text-align: center; + color: var(--secondary-text); + padding: 2rem; +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .editor-layout.split-view { + flex-direction: column; + } + + .split-view .editor-pane { + border-right: none; + border-bottom: 1px solid var(--border); + } + + .top-bar { + padding: 0.5rem; + } + + .btn-toggle, + .btn-share { + font-size: 0.75rem; + padding: 0.375rem 0.75rem; + } + + .markdown-editor, + .preview-content { + padding: 1rem; + } +} \ No newline at end of file diff --git a/Notemark/Components/Pages/Home.razor b/Notemark/Components/Pages/Home.razor deleted file mode 100644 index 9001e0b..0000000 --- a/Notemark/Components/Pages/Home.razor +++ /dev/null @@ -1,7 +0,0 @@ -@page "/" - -Home - -

Hello, world!

- -Welcome to your new app. diff --git a/Notemark/Components/Pages/Weather.razor b/Notemark/Components/Pages/Weather.razor deleted file mode 100644 index f437e5e..0000000 --- a/Notemark/Components/Pages/Weather.razor +++ /dev/null @@ -1,64 +0,0 @@ -@page "/weather" -@attribute [StreamRendering] - -Weather - -

Weather

- -

This component demonstrates showing data.

- -@if (forecasts == null) -{ -

Loading...

-} -else -{ - - - - - - - - - - - @foreach (var forecast in forecasts) - { - - - - - - - } - -
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
-} - -@code { - private WeatherForecast[]? forecasts; - - protected override async Task OnInitializedAsync() - { - // Simulate asynchronous loading to demonstrate streaming rendering - await Task.Delay(500); - - var startDate = DateOnly.FromDateTime(DateTime.Now); - var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; - forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = startDate.AddDays(index), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = summaries[Random.Shared.Next(summaries.Length)] - }).ToArray(); - } - - private class WeatherForecast - { - public DateOnly Date { get; set; } - public int TemperatureC { get; set; } - public string? Summary { get; set; } - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - } -} diff --git a/Notemark/Notemark.csproj b/Notemark/Notemark.csproj index b5f6af0..1fd7612 100644 --- a/Notemark/Notemark.csproj +++ b/Notemark/Notemark.csproj @@ -7,4 +7,9 @@ true + + + + + diff --git a/Notemark/Program.cs b/Notemark/Program.cs index 3854a39..0adda16 100644 --- a/Notemark/Program.cs +++ b/Notemark/Program.cs @@ -1,4 +1,5 @@ using Notemark.Components; +using Notemark.Services; var builder = WebApplication.CreateBuilder(args); @@ -6,6 +7,14 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); +// Add HTTP context accessor for session management +builder.Services.AddHttpContextAccessor(); + +// Register Notemark services +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + var app = builder.Build(); // Configure the HTTP request pipeline. @@ -18,6 +27,13 @@ if (!app.Environment.IsDevelopment()) app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); app.UseHttpsRedirection(); +// Enable cookies for session management +app.UseCookiePolicy(new CookiePolicyOptions +{ + MinimumSameSitePolicy = SameSiteMode.Lax, + Secure = CookieSecurePolicy.SameAsRequest +}); + app.UseAntiforgery(); app.MapStaticAssets(); diff --git a/Notemark/Services/IMarkdownService.cs b/Notemark/Services/IMarkdownService.cs new file mode 100644 index 0000000..a26ebdf --- /dev/null +++ b/Notemark/Services/IMarkdownService.cs @@ -0,0 +1,6 @@ +namespace Notemark.Services; + +public interface IMarkdownService +{ + string RenderToHtml(string markdown); +} \ No newline at end of file diff --git a/Notemark/Services/ISessionService.cs b/Notemark/Services/ISessionService.cs new file mode 100644 index 0000000..4a6b6a8 --- /dev/null +++ b/Notemark/Services/ISessionService.cs @@ -0,0 +1,9 @@ +namespace Notemark.Services; + +public interface ISessionService +{ + string GetOrCreateSessionId(HttpContext httpContext); + Task LoadSessionContentAsync(string sessionId); + Task SaveSessionContentAsync(string sessionId, string content); + Task SessionExistsAsync(string sessionId); +} \ No newline at end of file diff --git a/Notemark/Services/IShareService.cs b/Notemark/Services/IShareService.cs new file mode 100644 index 0000000..72f69ae --- /dev/null +++ b/Notemark/Services/IShareService.cs @@ -0,0 +1,17 @@ +namespace Notemark.Services; + +public record ShareToken( + string SessionId, + string Mode, // "readonly" or "editable" + DateTime CreatedAt, + DateTime? ExpiresAt = null, + DateTime? RevokedAt = null +); + +public interface IShareService +{ + Task CreateShareTokenAsync(string sessionId, string mode); + Task GetShareTokenAsync(string token); + Task RevokeShareTokenAsync(string token); + Task IsValidTokenAsync(string token); +} \ No newline at end of file diff --git a/Notemark/Services/MarkdownService.cs b/Notemark/Services/MarkdownService.cs new file mode 100644 index 0000000..dff370b --- /dev/null +++ b/Notemark/Services/MarkdownService.cs @@ -0,0 +1,38 @@ +using Markdig; +using Ganss.Xss; + +namespace Notemark.Services; + +public class MarkdownService : IMarkdownService +{ + private readonly MarkdownPipeline _pipeline; + private readonly HtmlSanitizer _sanitizer; + + public MarkdownService() + { + // Configure Markdig pipeline with GitHub-style features + _pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() // Tables, footnotes, task lists, etc. + .UseSoftlineBreakAsHardlineBreak() // GitHub-style line breaks + .Build(); + + // Configure HTML sanitizer for security + _sanitizer = new HtmlSanitizer(); + _sanitizer.AllowedTags.Add("details"); + _sanitizer.AllowedTags.Add("summary"); + _sanitizer.AllowedAttributes.Add("class"); + _sanitizer.AllowedAttributes.Add("id"); + } + + public string RenderToHtml(string markdown) + { + if (string.IsNullOrEmpty(markdown)) + return string.Empty; + + // Convert markdown to HTML + var html = Markdown.ToHtml(markdown, _pipeline); + + // Sanitize HTML to prevent XSS attacks + return _sanitizer.Sanitize(html); + } +} \ No newline at end of file diff --git a/Notemark/Services/SessionService.cs b/Notemark/Services/SessionService.cs new file mode 100644 index 0000000..f159fa9 --- /dev/null +++ b/Notemark/Services/SessionService.cs @@ -0,0 +1,69 @@ +namespace Notemark.Services; + +public class SessionService : ISessionService +{ + private readonly string _dataPath; + private const string SessionCookieName = "mk_session"; + + public SessionService(IWebHostEnvironment environment) + { + _dataPath = Path.Combine(environment.ContentRootPath, "data", "sessions"); + Directory.CreateDirectory(_dataPath); + } + + 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 _)) + { + return existingSessionId; + } + + // Create new session + var newSessionId = Guid.NewGuid().ToString(); + + // Set cookie with 30-day expiration + var cookieOptions = new CookieOptions + { + Expires = DateTimeOffset.UtcNow.AddDays(30), + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax + }; + + httpContext.Response.Cookies.Append(SessionCookieName, newSessionId, cookieOptions); + + return newSessionId; + } + + public async Task LoadSessionContentAsync(string sessionId) + { + var filePath = GetSessionFilePath(sessionId); + + if (!File.Exists(filePath)) + return string.Empty; + + return await File.ReadAllTextAsync(filePath); + } + + public async Task SaveSessionContentAsync(string sessionId, string content) + { + var filePath = GetSessionFilePath(sessionId); + await File.WriteAllTextAsync(filePath, content ?? string.Empty); + } + + public async Task SessionExistsAsync(string sessionId) + { + var filePath = GetSessionFilePath(sessionId); + return File.Exists(filePath); + } + + private string GetSessionFilePath(string sessionId) + { + if (!Guid.TryParse(sessionId, out _)) + throw new ArgumentException("Invalid session ID format", nameof(sessionId)); + + return Path.Combine(_dataPath, $"{sessionId}.md"); + } +} \ No newline at end of file diff --git a/Notemark/Services/ShareService.cs b/Notemark/Services/ShareService.cs new file mode 100644 index 0000000..29447a9 --- /dev/null +++ b/Notemark/Services/ShareService.cs @@ -0,0 +1,107 @@ +using System.Text.Json; + +namespace Notemark.Services; + +public class ShareService : IShareService +{ + private readonly string _dataPath; + private readonly JsonSerializerOptions _jsonOptions; + + public ShareService(IWebHostEnvironment environment) + { + _dataPath = Path.Combine(environment.ContentRootPath, "data", "shares"); + Directory.CreateDirectory(_dataPath); + + _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + } + + public async Task CreateShareTokenAsync(string sessionId, string mode) + { + if (!Guid.TryParse(sessionId, out _)) + throw new ArgumentException("Invalid session ID format", nameof(sessionId)); + + if (mode != "readonly" && mode != "editable") + throw new ArgumentException("Mode must be 'readonly' or 'editable'", nameof(mode)); + + var token = GenerateToken(); + var shareToken = new ShareToken( + SessionId: sessionId, + Mode: mode, + CreatedAt: DateTime.UtcNow + ); + + var filePath = GetTokenFilePath(token); + var json = JsonSerializer.Serialize(shareToken, _jsonOptions); + await File.WriteAllTextAsync(filePath, json); + + return token; + } + + public async Task GetShareTokenAsync(string token) + { + var filePath = GetTokenFilePath(token); + + if (!File.Exists(filePath)) + return null; + + try + { + var json = await File.ReadAllTextAsync(filePath); + return JsonSerializer.Deserialize(json, _jsonOptions); + } + catch (JsonException) + { + return null; + } + } + + public async Task RevokeShareTokenAsync(string token) + { + var shareToken = await GetShareTokenAsync(token); + if (shareToken == null) + return; + + var revokedToken = shareToken with { RevokedAt = DateTime.UtcNow }; + var filePath = GetTokenFilePath(token); + var json = JsonSerializer.Serialize(revokedToken, _jsonOptions); + await File.WriteAllTextAsync(filePath, json); + } + + public async Task IsValidTokenAsync(string token) + { + var shareToken = await GetShareTokenAsync(token); + + if (shareToken == null) + return false; + + // Check if revoked + if (shareToken.RevokedAt.HasValue) + return false; + + // Check if expired + if (shareToken.ExpiresAt.HasValue && shareToken.ExpiresAt.Value < DateTime.UtcNow) + return false; + + return true; + } + + private string GenerateToken() + { + // Generate a URL-safe random token + return Convert.ToBase64String(Guid.NewGuid().ToByteArray()) + .Replace('+', '-') + .Replace('/', '_') + .Replace("=", ""); + } + + private string GetTokenFilePath(string token) + { + // Sanitize token for filename + var sanitizedToken = string.Concat(token.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_')); + return Path.Combine(_dataPath, $"{sanitizedToken}.json"); + } +} \ No newline at end of file diff --git a/Notemark/data/sessions/b5acffbe-706e-405e-b006-3415508a59a7.md b/Notemark/data/sessions/b5acffbe-706e-405e-b006-3415508a59a7.md new file mode 100644 index 0000000..992cd91 --- /dev/null +++ b/Notemark/data/sessions/b5acffbe-706e-405e-b006-3415508a59a7.md @@ -0,0 +1,22 @@ +# Notemark — .NET 10 Blazor Server Specification (Markdown Editor + Preview + Share) + +## 0) Goal +Notemark is a minimalist Markdown editor web app built with **.NET 10 Blazor Server**. + +- First visit generates a **GUID sessionId** +- Markdown is saved as a **file on disk** (no database) +- Fullscreen editor, optional preview split view +- Share links: + - **Readonly** → preview only + - **Editable** → preview only + Edit button + +--- + +## 1) Tech Stack +- .NET 10 +- Blazor Server +- Markdown rendering: Markdig +- HTML sanitization: Ganss.XSS (HtmlSanitizer) +- Persistence: File system only + + diff --git a/Notemark/data/shares/6XbclTsBI0SD_OZxxZtX5g.json b/Notemark/data/shares/6XbclTsBI0SD_OZxxZtX5g.json new file mode 100644 index 0000000..84d98c8 --- /dev/null +++ b/Notemark/data/shares/6XbclTsBI0SD_OZxxZtX5g.json @@ -0,0 +1,7 @@ +{ + "sessionId": "b5acffbe-706e-405e-b006-3415508a59a7", + "mode": "editable", + "createdAt": "2026-01-30T15:19:41.7110595Z", + "expiresAt": null, + "revokedAt": null +} \ No newline at end of file diff --git a/Notemark/data/shares/P3Xs1x-1E02PsvO90XVXuA.json b/Notemark/data/shares/P3Xs1x-1E02PsvO90XVXuA.json new file mode 100644 index 0000000..b62e897 --- /dev/null +++ b/Notemark/data/shares/P3Xs1x-1E02PsvO90XVXuA.json @@ -0,0 +1,7 @@ +{ + "sessionId": "b5acffbe-706e-405e-b006-3415508a59a7", + "mode": "readonly", + "createdAt": "2026-01-30T15:19:41.7093218Z", + "expiresAt": null, + "revokedAt": null +} \ No newline at end of file diff --git a/Notemark/data/shares/ll5J-tEyOkeKGkI6ZmDeow.json b/Notemark/data/shares/ll5J-tEyOkeKGkI6ZmDeow.json new file mode 100644 index 0000000..43e0b4f --- /dev/null +++ b/Notemark/data/shares/ll5J-tEyOkeKGkI6ZmDeow.json @@ -0,0 +1,7 @@ +{ + "sessionId": "b5acffbe-706e-405e-b006-3415508a59a7", + "mode": "readonly", + "createdAt": "2026-01-30T15:19:24.7974267Z", + "expiresAt": null, + "revokedAt": null +} \ No newline at end of file diff --git a/Notemark/data/shares/uN4HzT9eB0axkHy66QRIfQ.json b/Notemark/data/shares/uN4HzT9eB0axkHy66QRIfQ.json new file mode 100644 index 0000000..1bfde9f --- /dev/null +++ b/Notemark/data/shares/uN4HzT9eB0axkHy66QRIfQ.json @@ -0,0 +1,7 @@ +{ + "sessionId": "b5acffbe-706e-405e-b006-3415508a59a7", + "mode": "editable", + "createdAt": "2026-01-30T15:19:24.8076694Z", + "expiresAt": null, + "revokedAt": null +} \ No newline at end of file