Author SHA1 Message Date
Josh-Heaps 9c4ff64070 Fix jitter 2026-08-20 09:54:18 -06:00
Josh-Heaps 639ace0030 Add transistors 2026-08-18 12:18:54 -06:00
Josh-Heaps cb0df5728c Add basic breadboard simulator 2026-08-17 14:28:52 -06:00
jheapsandClaude Sonnet 4.6 0393dacf63 Embed user roles as JWT claims in SSO token
IssueToken is now async so it can query app.user_roles before signing
the JWT. Role names are added as ClaimTypes.Role claims, enabling
role-based authorization in downstream apps (e.g. retirementAdmin).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-18 19:29:54 -06:00
Josh Heaps 7bbd679171 Merge pull request #15 from JoshHeaps/feature/blog
Add sso for other websites
2026-04-21 16:15:45 -06:00
Josh-Heaps e3d9c8f273 Add sso for other websites 2026-04-21 16:13:26 -06:00
Josh Heaps 64107d2e52 Merge pull request #14 from JoshHeaps/feature/blog
add cache invalidation
2026-03-06 17:06:51 -07:00
Josh-Heaps 15a84b2027 add cache invalidation 2026-03-06 17:05:36 -07:00
Josh Heaps f1e2aad8bf Merge pull request #13 from JoshHeaps/feature/blog
Add image upload capabilities
2026-03-06 16:35:39 -07:00
Josh-Heaps cb86e15cc0 Add image upload capabilities 2026-03-06 16:31:20 -07:00
Josh Heaps 9b182eaedf Merge pull request #12 from JoshHeaps/feature/blog
Add blog management: migration, API, service, and admin UI
2026-03-06 16:13:30 -07:00
Josh-Heaps 9f3f610025 Update blog button 2026-03-06 16:12:58 -07:00
Josh-HeapsandClaude Opus 4.6 a3c90ad06e Add blog management: migration, API, service, and admin UI
Blog posts stored in PostgreSQL with markdown rendering via Markdig.
Public API for reads, admin-only writes with dual auth (JWT + session).
Admin page for creating, editing, and deleting posts.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-06 15:33:49 -07:00
Josh Heaps 7dc70767d2 Merge pull request #11 from JoshHeaps/features/MultiFeatureBranch
Prescription ai update
2026-02-17 09:35:56 -07:00
Josh-Heaps bb35460097 Prescription ai update 2026-02-17 09:32:43 -07:00
Josh Heaps dea8d1bf90 Merge pull request #10 from JoshHeaps/features/MultiFeatureBranch
bugfix
2026-02-17 09:13:09 -07:00
Josh-Heaps d0c8c1249b bugfix 2026-02-17 09:11:49 -07:00
Josh Heaps 7e6ce61f03 Merge pull request #9 from JoshHeaps/features/MultiFeatureBranch
Features/multi feature branch
2026-02-13 14:15:12 -07:00
Josh-Heaps e971a5f329 Add custom colors 2026-02-13 14:13:32 -07:00
Josh-Heaps 91ef4f41dd Make an all patients view. 2026-02-13 10:11:44 -07:00
Josh Heaps e3d94b9f43 Merge pull request #8 from JoshHeaps/features/MultiFeatureBranch
Features/multi feature branch
2026-02-12 15:18:10 -07:00
Josh-Heaps cfd6bb530b Fix so people are scoped to users 2026-02-12 15:14:49 -07:00
Josh-Heaps c173677f57 Add password reset functionality 2026-02-12 13:14:01 -07:00
Josh Heaps dda6ac68dc Merge pull request #7 from JoshHeaps/mobileUi
Deploy Media Server / build (push) Successful in 39s
Deploy Media Server / deploy (push) Failing after 7s
Add document viewer
2026-02-12 09:55:24 -07:00
Josh-Heaps 8982011155 Add document viewer 2026-02-12 09:53:25 -07:00
Josh Heaps af309ca8d6 Merge pull request #6 from JoshHeaps/mobileUi
mobile ui
2026-02-10 17:31:09 -07:00
Josh-Heaps 811f732495 mobile ui 2026-02-10 17:30:26 -07:00
Josh Heaps 6b83d7df0f Merge pull request #5 from JoshHeaps/feature/AdminPage
Feature/admin page
2026-02-10 16:55:41 -07:00
Josh-Heaps 6814270b7c Fully implement medical documentation 2026-02-10 16:54:51 -07:00
Josh-Heaps 9ede3ae53f Implement first four phases of medical docs roadmap 2026-02-09 16:35:45 -07:00
Josh Heaps c35396f906 Merge pull request #4 from JoshHeaps/feature/NoteGraph
Create node graph page
2026-01-02 17:15:16 -07:00
Josh Heaps 9e9439eec9 Merge pull request #3 from JoshHeaps/feature/Folders
Add folder sharing
2025-10-21 17:47:31 -06:00
Josh Heaps dd6995bfc7 Merge pull request #2 from JoshHeaps/feature/Folders
Add folders
2025-10-21 16:51:28 -06:00
134 changed files with 23943 additions and 143 deletions
+3 -1
View File
@@ -11,7 +11,9 @@
"Bash(tree:*)",
"Bash(del Index.cshtml Index.cshtml.cs)",
"Bash(dotnet build:*)",
"Bash(find:*)"
"Bash(find:*)",
"Bash(grep:*)",
"Bash(ls:*)"
],
"deny": [],
"ask": []
+45
View File
@@ -0,0 +1,45 @@
# Medical Documentation System — Roadmap
A dedicated admin-only section for organizing medical documents (receipts, doctor notes, recorded conversations, lab results, etc.) for multiple family members. Uses Claude AI to auto-classify, tag, and extract structured data from uploaded documents. Completely separate from the existing media/gallery system.
---
## Phase 1: Database Foundation & Core Document Management ✅
Tables for people, documents, tags, doctors, conditions, prescriptions, costs. Basic CRUD service. Admin-gated Razor Page with file upload and plain-text note entry.
## Phase 2: People & Document Browsing ✅
UI for managing people (family members). Document list with filtering by person, type icons, download/preview.
## Phase 3: Claude AI Integration ✅
`MedicalAiService` using Claude API. On upload: OCR, text extraction, auto-classification, auto-tagging, structured data extraction. Manual transcript field for audio files.
## Phase 4: Doctors, Conditions & Prescription Tracking ✅
Doctor/condition management (global doctors, per-person conditions). Prescription tracking with doctor linkage, expandable pickup history, and "last pickup" display. Inline add/edit/delete for all entities.
## Phase 5: AI CLI Migration ✅
Replaced Claude HTTP API with `claude -p` CLI pipe mode. Sequential `Channel<long>` background queue replaces fire-and-forget `Task.Run`. Rate limit detection parses reset time from CLI output and pauses the queue. Temp file approach for image/PDF OCR via CLI with `--allowedTools Read`.
## Phase 6: Bills & Payments ✅
Replaced the flat `medical_document_costs` model (which double/triple-counted AI-extracted line items) with a proper billing system. Bills represent unique charges; payments track money applied toward them (patient payments, insurance payments, adjustments, write-offs). Summary card shows out-of-pocket vs total charged. Bills support linked documents, expandable payment lists, and filter by paid/unpaid status. Old costs API endpoints preserved for backward compatibility with AI processing.
## Phase 7: AI Bills Integration ✅
Updated AI extraction prompt to create bills + payments instead of flat costs. On re-process: deletes AI-sourced bills/payments for document, re-creates (prevents duplicates). Smart matching: if extracted charge matches existing bill for same person (same amount, category, date within 30 days), links document instead of creating new. Removed `AddCostsAsync`, all costs CRUD methods, costs API endpoints, and `MedicalDocumentCost` model. DB table retained per policy.
## Phase 8: Search & Filtering ⬅️ **Up Next**
Full-text search on extracted text. Filter by person, doctor, condition, tags, document type, date range. Combined filters.
## Phase 9: AI-Enhanced Insights
Medical timeline per person. Visit prep summaries. Batch re-analysis when AI improves.
## Phase 10: Polish & Hardening
Pagination/lazy-loading. Export (PDF summary, CSV costs). Mobile-responsive UI.
---
## Feature requests (I'm writing these down for me. I may ask you to do these in the future, so please design any changes with the fact in mind that they may need to acommodate these)
## Calendar
A calendar that's easy to navigate, and shows what documents are on each day. If I see the month of January, at the very least, I should see something on the calendar indicating which days in January a document is associated with.
## Custom Colors
An easy way to customize colors instead of just having a light mode/dark mode. I still want to have light mode/dark mode as defaults, but custom colors should be an option as well.
+206
View File
@@ -0,0 +1,206 @@
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace Media.JoshHeaps.Net.Api;
[ApiController]
[Route("api/blog")]
public class BlogApi(BlogService blogService, DbExecutor dbExecutor, IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger<BlogApi> logger) : ControllerBase
{
[HttpGet("posts")]
public async Task<IActionResult> GetPosts()
{
var posts = await blogService.GetAllPostsAsync();
return Ok(posts.Select(MapToPublicDto));
}
[HttpGet("posts/{slug}")]
public async Task<IActionResult> GetPostBySlug(string slug)
{
var post = await blogService.GetPostBySlugAsync(slug);
if (post is null) return NotFound();
return Ok(MapToPublicDto(post));
}
[HttpGet("posts/tags/{tag}")]
public async Task<IActionResult> GetPostsByTag(string tag)
{
var posts = await blogService.GetPostsByTagAsync(tag);
return Ok(posts.Select(MapToPublicDto));
}
[HttpGet("posts/admin/{id}")]
public async Task<IActionResult> GetPostForAdmin(long id)
{
var userId = GetUserIdFromAuth();
if (userId is null) return Unauthorized();
if (!await IsAdmin(userId.Value)) return Forbid();
var post = await blogService.GetPostByIdAsync(id);
if (post is null) return NotFound();
return Ok(MapToAdminDto(post));
}
[HttpPost("posts")]
public async Task<IActionResult> CreatePost([FromBody] CreateBlogPostRequest request)
{
var userId = GetUserIdFromAuth();
if (userId is null) return Unauthorized();
if (!await IsAdmin(userId.Value)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required" });
if (string.IsNullOrWhiteSpace(request.MarkdownContent))
return BadRequest(new { error = "Content is required" });
var post = await blogService.CreatePostAsync(
request.Title.Trim(),
request.Summary?.Trim() ?? "",
request.MarkdownContent,
request.Tags ?? [],
userId.Value,
request.PublishedDate ?? DateTime.UtcNow);
if (post is null)
return StatusCode(500, new { error = "Failed to create post" });
_ = InvalidateFrontendCacheAsync();
return Ok(MapToAdminDto(post));
}
[HttpPut("posts/{id}")]
public async Task<IActionResult> UpdatePost(long id, [FromBody] UpdateBlogPostRequest request)
{
var userId = GetUserIdFromAuth();
if (userId is null) return Unauthorized();
if (!await IsAdmin(userId.Value)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required" });
if (string.IsNullOrWhiteSpace(request.MarkdownContent))
return BadRequest(new { error = "Content is required" });
var post = await blogService.UpdatePostAsync(
id,
request.Title.Trim(),
request.Summary?.Trim() ?? "",
request.MarkdownContent,
request.Tags ?? [],
request.PublishedDate ?? DateTime.UtcNow);
if (post is null)
return NotFound(new { error = "Post not found" });
_ = InvalidateFrontendCacheAsync();
return Ok(MapToAdminDto(post));
}
[HttpDelete("posts/{id}")]
public async Task<IActionResult> DeletePost(long id)
{
var userId = GetUserIdFromAuth();
if (userId is null) return Unauthorized();
if (!await IsAdmin(userId.Value)) return Forbid();
var deleted = await blogService.DeletePostAsync(id);
if (!deleted) return NotFound(new { error = "Post not found" });
_ = InvalidateFrontendCacheAsync();
return Ok(new { success = true });
}
[HttpPost("images")]
public async Task<IActionResult> UploadImage(IFormFile file)
{
var userId = GetUserIdFromAuth();
if (userId is null) return Unauthorized();
if (!await IsAdmin(userId.Value)) return Forbid();
if (file is null || file.Length == 0)
return BadRequest(new { error = "No file provided" });
var (url, error) = await blogService.SaveImageAsync(file);
if (url is null)
return BadRequest(new { error });
return Ok(new { url });
}
[HttpGet("images/{fileName}")]
public IActionResult GetImage(string fileName)
{
var (filePath, mimeType) = blogService.GetImagePath(fileName);
if (filePath is null)
return NotFound();
return PhysicalFile(filePath, mimeType!);
}
private static object MapToPublicDto(Models.BlogPost post) => new
{
post.Id,
post.Slug,
post.Title,
post.Summary,
post.Tags,
post.PublishedDate,
post.HtmlContent
};
private static object MapToAdminDto(Models.BlogPost post) => new
{
post.Id,
post.Slug,
post.Title,
post.Summary,
post.MarkdownContent,
post.Tags,
post.PublishedDate,
post.CreatedAt,
post.UpdatedAt
};
private async Task InvalidateFrontendCacheAsync()
{
try
{
var url = configuration["BlogCache:InvalidateUrl"];
var key = configuration["BlogCache:InvalidateKey"];
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(key)) return;
var client = httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(5);
var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Add("X-Invalidate-Key", key);
await client.SendAsync(request);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to invalidate frontend blog cache");
}
}
private async Task<bool> IsAdmin(long userId)
{
return await dbExecutor.ExecuteAsync<bool>(
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'admin')",
new { UserId = userId });
}
private long? GetUserIdFromAuth()
{
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
return jwtUserId;
var userIdString = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
return sessionUserId;
return null;
}
}
public record CreateBlogPostRequest(string Title, string? Summary, string MarkdownContent, List<string>? Tags, DateTime? PublishedDate);
public record UpdateBlogPostRequest(string Title, string? Summary, string MarkdownContent, List<string>? Tags, DateTime? PublishedDate);
+141
View File
@@ -0,0 +1,141 @@
using System.Security.Claims;
using System.Text.Json;
using Media.JoshHeaps.Net.Models;
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Api;
[ApiController]
[Route("api/breadboard")]
public class BreadboardApi(BreadboardService breadboardService) : ControllerBase
{
/// <summary>
/// Pipeline-level guard so an oversized body is rejected before it is buffered into a
/// string. The validator's 2 MB circuit cap is the real limit; the extra megabyte is
/// headroom for the JSON envelope around it.
/// </summary>
private const long MaxRequestBodyBytes = 3L * 1024 * 1024;
[HttpGet("projects")]
public async Task<IActionResult> ListProjects()
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(Problems("Not authenticated"));
}
var projects = await breadboardService.GetProjectsAsync(userId.Value);
return Ok(projects);
}
[HttpPost("projects")]
[RequestSizeLimit(MaxRequestBodyBytes)]
public async Task<IActionResult> CreateProject([FromBody] CreateBreadboardProjectRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(Problems("Not authenticated"));
}
var result = await breadboardService.CreateProjectAsync(userId.Value, request.Name, request.Description);
return MapResult(result);
}
[HttpGet("projects/{projectId:long}")]
public async Task<IActionResult> GetProject(long projectId)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(Problems("Not authenticated"));
}
var project = await breadboardService.GetProjectAsync(projectId, userId.Value);
if (project == null)
{
return NotFound(Problems(BreadboardResult.NotFoundMessage));
}
return Ok(project);
}
[HttpPut("projects/{projectId:long}")]
[RequestSizeLimit(MaxRequestBodyBytes)]
public async Task<IActionResult> UpdateProject(long projectId, [FromBody] UpdateBreadboardProjectRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(Problems("Not authenticated"));
}
var result = await breadboardService.UpdateProjectAsync(
projectId,
userId.Value,
request.Name,
request.Description,
RawCircuit(request.Circuit));
return MapResult(result);
}
[HttpDelete("projects/{projectId:long}")]
public async Task<IActionResult> DeleteProject(long projectId)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(Problems("Not authenticated"));
}
var result = await breadboardService.DeleteProjectAsync(projectId, userId.Value);
return MapResult(result);
}
/// <summary>A project that belongs to someone else is reported as missing, never as forbidden.</summary>
private IActionResult MapResult(BreadboardResult result) => result.Outcome switch
{
BreadboardOutcome.Success => result.Project is null ? NoContent() : Ok(result.Project),
BreadboardOutcome.NotFound => NotFound(new { errors = result.Errors }),
BreadboardOutcome.Invalid => BadRequest(new { errors = result.Errors }),
_ => StatusCode(StatusCodes.Status500InternalServerError, new { errors = result.Errors })
};
private static object Problems(string message) => new { errors = new[] { message } };
/// <summary>
/// An omitted circuit and an explicit JSON null both mean "leave the circuit alone".
/// Anything else is handed to the service as raw text — the server never reshapes the document.
/// </summary>
private static string? RawCircuit(JsonElement? circuit) =>
circuit is { ValueKind: not JsonValueKind.Undefined and not JsonValueKind.Null } element
? element.GetRawText()
: null;
/// <summary>
/// JWT first, then the session cookie — the repo-wide pattern.
/// CSRF note: the session path carries no antiforgery token, and is safe today only
/// because of three framework defaults — session cookies are SameSite=Lax, no CORS policy
/// is registered, and an application/json body forces a preflight. Adding a permissive
/// CORS policy or SameSite=None anywhere in this app makes these writes CSRF-able.
/// </summary>
private long? GetUserIdFromAuth()
{
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
return jwtUserId;
var userIdString = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
return sessionUserId;
return null;
}
}
public record CreateBreadboardProjectRequest(string? Name, string? Description);
public record UpdateBreadboardProjectRequest(string? Name, string? Description, JsonElement? Circuit);
+943
View File
@@ -0,0 +1,943 @@
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace Media.JoshHeaps.Net.Api;
[ApiController]
[Route("api/medical-docs")]
public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDocsService, MedicalAiService medicalAiService) : ControllerBase
{
// --- People ---
[HttpGet("people")]
public async Task<IActionResult> GetPeople()
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
var people = await medicalDocsService.GetPeopleAsync(userId.Value);
return Ok(people);
}
[HttpPost("people")]
public async Task<IActionResult> CreatePerson([FromBody] CreatePersonRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "Name is required" });
var person = await medicalDocsService.CreatePersonAsync(userId.Value, request.Name.Trim(), request.DateOfBirth, request.Notes);
if (person == null)
return StatusCode(500, new { error = "Failed to create person" });
return Ok(person);
}
// --- People Access ---
[HttpGet("people/{personId}/access")]
public async Task<IActionResult> GetPersonAccess(long personId)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
var users = await medicalDocsService.GetPeopleAccessAsync(personId);
return Ok(users);
}
[HttpPost("people/{personId}/access")]
public async Task<IActionResult> GrantPersonAccess(long personId, [FromBody] GrantAccessRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Username))
return BadRequest(new { error = "Username is required" });
var targetUser = await dbExecutor.ExecuteReaderAsync(
"SELECT id FROM app.users WHERE LOWER(username) = LOWER(@username)",
reader => reader.GetInt64(0),
new { username = request.Username.Trim() });
if (targetUser == 0)
return NotFound(new { error = "User not found" });
var success = await medicalDocsService.GrantAccessAsync(personId, targetUser);
if (!success)
return StatusCode(500, new { error = "Failed to grant access" });
return Ok(new { success = true });
}
[HttpDelete("people/{personId}/access/{targetUserId}")]
public async Task<IActionResult> RevokePersonAccess(long personId, long targetUserId)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
var success = await medicalDocsService.RevokeAccessAsync(personId, targetUserId);
if (!success)
return BadRequest(new { error = "Cannot revoke access — at least one user must have access" });
return Ok(new { success = true });
}
// --- Documents ---
[HttpGet("documents")]
public async Task<IActionResult> GetDocuments([FromQuery] long? personId, [FromQuery] int offset = 0, [FromQuery] int limit = 50)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
if (limit < 1 || limit > 100) limit = 50;
if (offset < 0) offset = 0;
var documents = await medicalDocsService.GetDocumentsAsync(personId, offset, limit);
return Ok(documents);
}
[HttpGet("documents/search")]
public async Task<IActionResult> SearchDocuments(
[FromQuery] long? personId = null,
[FromQuery] string? search = null,
[FromQuery] string? classification = null,
[FromQuery] string? documentType = null,
[FromQuery] long? doctorId = null,
[FromQuery] long? tagId = null,
[FromQuery] long? conditionId = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] bool? aiProcessed = null,
[FromQuery] int offset = 0,
[FromQuery] int limit = 50)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
if (limit < 1 || limit > 100) limit = 50;
if (offset < 0) offset = 0;
var documents = await medicalDocsService.SearchDocumentsAsync(personId, search, classification, documentType, doctorId, tagId, conditionId, fromDate, toDate, aiProcessed, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit);
return Ok(documents);
}
[HttpGet("tags")]
public async Task<IActionResult> GetPersonTags([FromQuery] long? personId = null)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
var tags = await medicalDocsService.GetPersonTagsAsync(personId, accessUserId: personId.HasValue ? null : userId);
return Ok(tags);
}
[HttpPost("documents/upload")]
[RequestSizeLimit(52_428_800)] // 50MB
public async Task<IActionResult> UploadDocument([FromForm] long personId, [FromForm] string? title, [FromForm] string? description, [FromForm] DateTime? documentDate, [FromForm] string? classification, IFormFile file)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
if (file == null || file.Length == 0)
return BadRequest(new { error = "No file provided" });
var doc = await medicalDocsService.SaveDocumentAsync(personId, file, title, description, documentDate, classification);
if (doc == null)
return StatusCode(500, new { error = "Failed to save document" });
medicalAiService.EnqueueProcessing(doc.Id);
return Ok(doc);
}
[HttpPost("documents/note")]
public async Task<IActionResult> CreateNote([FromBody] CreateNoteRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (request.PersonId <= 0)
return BadRequest(new { error = "Person is required" });
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required" });
var doc = await medicalDocsService.SaveNoteAsync(request.PersonId, request.Title.Trim(), request.Description ?? "", request.DocumentDate, request.Classification);
if (doc == null)
return StatusCode(500, new { error = "Failed to create note" });
medicalAiService.EnqueueProcessing(doc.Id);
return Ok(doc);
}
[HttpGet("documents/{id}")]
public async Task<IActionResult> GetDocument(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
var doc = await medicalDocsService.GetDocumentByIdAsync(id);
if (doc == null) return NotFound(new { error = "Document not found" });
return Ok(doc);
}
[HttpGet("documents/{id}/download")]
public async Task<IActionResult> DownloadDocument(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
var doc = await medicalDocsService.GetDocumentByIdAsync(id);
if (doc == null) return NotFound(new { error = "Document not found" });
if (doc.DocumentType != "file")
return BadRequest(new { error = "Cannot download a note" });
var data = await medicalDocsService.GetDecryptedDocumentDataAsync(id);
if (data == null) return NotFound(new { error = "File not found" });
return File(data, doc.MimeType ?? "application/octet-stream", doc.FileName);
}
[HttpPut("documents/{id}")]
public async Task<IActionResult> UpdateDocument(long id, [FromBody] UpdateDocumentRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
var success = await medicalDocsService.UpdateDocumentAsync(id, request.Title, request.Description, request.DocumentDate, request.Classification, request.DoctorId);
if (!success) return NotFound(new { error = "Document not found" });
return Ok(new { success = true });
}
[HttpDelete("documents/{id}")]
public async Task<IActionResult> DeleteDocument(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
var success = await medicalDocsService.DeleteDocumentAsync(id);
if (!success) return NotFound(new { error = "Document not found" });
return Ok(new { success = true });
}
// --- AI Processing ---
[HttpPost("documents/{id}/process")]
public async Task<IActionResult> ProcessDocument(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
var doc = await medicalDocsService.GetDocumentByIdAsync(id);
if (doc == null) return NotFound(new { error = "Document not found" });
medicalAiService.EnqueueProcessing(id);
return Ok(new { success = true, message = "AI processing started" });
}
[HttpPost("documents/process-all")]
public async Task<IActionResult> ProcessAllDocuments()
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
var unprocessedIds = await medicalDocsService.GetUnprocessedDocumentIdsAsync();
foreach (var docId in unprocessedIds)
{
medicalAiService.EnqueueProcessing(docId);
}
return Ok(new { success = true, queued = unprocessedIds.Count });
}
[HttpPost("documents/process-batch")]
public async Task<IActionResult> ProcessBatch([FromBody] ProcessBatchRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (request.DocumentIds == null || request.DocumentIds.Count == 0)
return BadRequest(new { error = "No document IDs provided" });
var queued = 0;
foreach (var docId in request.DocumentIds)
{
var doc = await medicalDocsService.GetDocumentByIdAsync(docId);
if (doc != null)
{
medicalAiService.EnqueueProcessing(docId);
queued++;
}
}
return Ok(new { queued });
}
[HttpGet("documents/{id}/tags")]
public async Task<IActionResult> GetDocumentTags(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
var tags = await medicalDocsService.GetDocumentTagsAsync(id);
return Ok(tags);
}
// --- Doctors (shared, no per-person access check) ---
[HttpGet("doctors")]
public async Task<IActionResult> GetDoctors([FromQuery] long? personId = null)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
var doctors = await medicalDocsService.GetDoctorsAsync(personId, accessUserId: personId.HasValue ? null : userId);
return Ok(doctors);
}
[HttpPost("doctors")]
public async Task<IActionResult> CreateDoctor([FromBody] CreateDoctorRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "Name is required" });
var doctor = await medicalDocsService.CreateDoctorAsync(request.PersonId, request.Name.Trim(), request.Specialty, request.Phone, request.Address, request.Notes);
if (doctor == null)
return StatusCode(500, new { error = "Failed to create doctor" });
return Ok(doctor);
}
[HttpPut("doctors/{id}")]
public async Task<IActionResult> UpdateDoctor(long id, [FromBody] CreateDoctorRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "doctor", id)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "Name is required" });
var success = await medicalDocsService.UpdateDoctorAsync(id, request.Name.Trim(), request.Specialty, request.Phone, request.Address, request.Notes);
if (!success) return NotFound(new { error = "Doctor not found" });
return Ok(new { success = true });
}
[HttpDelete("doctors/{id}")]
public async Task<IActionResult> DeleteDoctor(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "doctor", id)) return Forbid();
var success = await medicalDocsService.DeleteDoctorAsync(id);
if (!success) return NotFound(new { error = "Doctor not found" });
return Ok(new { success = true });
}
// --- Conditions ---
[HttpGet("conditions")]
public async Task<IActionResult> GetConditions([FromQuery] long? personId = null)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
var conditions = await medicalDocsService.GetConditionsAsync(personId, accessUserId: personId.HasValue ? null : userId);
return Ok(conditions);
}
[HttpPost("conditions")]
public async Task<IActionResult> CreateCondition([FromBody] CreateConditionRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (request.PersonId <= 0)
return BadRequest(new { error = "Person is required" });
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "Name is required" });
var condition = await medicalDocsService.CreateConditionAsync(request.PersonId, request.Name.Trim(), request.DiagnosedDate, request.Notes);
if (condition == null)
return StatusCode(500, new { error = "Failed to create condition" });
return Ok(condition);
}
[HttpPut("conditions/{id}")]
public async Task<IActionResult> UpdateCondition(long id, [FromBody] UpdateConditionRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "condition", id)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "Name is required" });
var success = await medicalDocsService.UpdateConditionAsync(id, request.Name.Trim(), request.DiagnosedDate, request.Notes, request.IsActive);
if (!success) return NotFound(new { error = "Condition not found" });
return Ok(new { success = true });
}
[HttpDelete("conditions/{id}")]
public async Task<IActionResult> DeleteCondition(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "condition", id)) return Forbid();
var success = await medicalDocsService.DeleteConditionAsync(id);
if (!success) return NotFound(new { error = "Condition not found" });
return Ok(new { success = true });
}
// --- Prescriptions ---
[HttpGet("prescriptions")]
public async Task<IActionResult> GetPrescriptions([FromQuery] long? personId = null)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
var prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId, accessUserId: personId.HasValue ? null : userId);
return Ok(prescriptions);
}
[HttpPost("prescriptions")]
public async Task<IActionResult> CreatePrescription([FromBody] CreatePrescriptionRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (request.PersonId <= 0)
return BadRequest(new { error = "Person is required" });
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
if (string.IsNullOrWhiteSpace(request.MedicationName))
return BadRequest(new { error = "Medication name is required" });
var prescription = await medicalDocsService.CreatePrescriptionAsync(request.PersonId, request.MedicationName.Trim(), request.Dosage, request.Frequency, request.DoctorId, request.StartDate, request.Notes, request.RxNumber?.Trim());
if (prescription == null)
return StatusCode(500, new { error = "Failed to create prescription" });
return Ok(prescription);
}
[HttpPut("prescriptions/{id}")]
public async Task<IActionResult> UpdatePrescription(long id, [FromBody] UpdatePrescriptionRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
if (string.IsNullOrWhiteSpace(request.MedicationName))
return BadRequest(new { error = "Medication name is required" });
var success = await medicalDocsService.UpdatePrescriptionAsync(id, request.MedicationName.Trim(), request.Dosage, request.Frequency, request.DoctorId, request.StartDate, request.EndDate, request.Notes, request.IsActive, request.RxNumber?.Trim());
if (!success) return NotFound(new { error = "Prescription not found" });
return Ok(new { success = true });
}
[HttpDelete("prescriptions/{id}")]
public async Task<IActionResult> DeletePrescription(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
var success = await medicalDocsService.DeletePrescriptionAsync(id);
if (!success) return NotFound(new { error = "Prescription not found" });
return Ok(new { success = true });
}
// --- Pickups ---
[HttpGet("prescriptions/{id}/pickups")]
public async Task<IActionResult> GetPickups(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
var pickups = await medicalDocsService.GetPickupsAsync(id);
return Ok(pickups);
}
[HttpPost("prescriptions/{id}/pickups")]
public async Task<IActionResult> CreatePickup(long id, [FromBody] CreatePickupRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
var pickup = await medicalDocsService.CreatePickupAsync(id, request.PickupDate, request.Quantity, request.Pharmacy, request.Cost, request.Notes);
if (pickup == null)
return StatusCode(500, new { error = "Failed to create pickup" });
return Ok(pickup);
}
[HttpDelete("pickups/{id}")]
public async Task<IActionResult> DeletePickup(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "pickup", id)) return Forbid();
var success = await medicalDocsService.DeletePickupAsync(id);
if (!success) return NotFound(new { error = "Pickup not found" });
return Ok(new { success = true });
}
// --- Billing Providers ---
[HttpGet("providers")]
public async Task<IActionResult> GetProviders([FromQuery] long? personId = null)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
var providers = await medicalDocsService.GetProvidersAsync(personId, accessUserId: personId.HasValue ? null : userId);
return Ok(providers);
}
[HttpPost("providers")]
public async Task<IActionResult> CreateProvider([FromBody] CreateProviderRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (request.PersonId <= 0)
return BadRequest(new { error = "Person is required" });
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "Name is required" });
var provider = await medicalDocsService.CreateProviderAsync(request.PersonId, request.Name.Trim(), request.Notes);
if (provider == null)
return StatusCode(500, new { error = "Failed to create provider" });
return Ok(provider);
}
[HttpPut("providers/{id}")]
public async Task<IActionResult> UpdateProvider(long id, [FromBody] UpdateProviderRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "Name is required" });
var success = await medicalDocsService.UpdateProviderAsync(id, request.Name.Trim(), request.Notes);
if (!success) return NotFound(new { error = "Provider not found" });
return Ok(new { success = true });
}
[HttpDelete("providers/{id}")]
public async Task<IActionResult> DeleteProvider(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
var success = await medicalDocsService.DeleteProviderAsync(id);
if (!success) return NotFound(new { error = "Provider not found" });
return Ok(new { success = true });
}
// --- Provider Payments ---
[HttpGet("providers/{id}/payments")]
public async Task<IActionResult> GetProviderPayments(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
var payments = await medicalDocsService.GetProviderPaymentsAsync(id);
return Ok(payments);
}
[HttpPost("providers/{id}/payments")]
public async Task<IActionResult> CreateProviderPayment(long id, [FromBody] CreateProviderPaymentRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
if (request.Amount <= 0)
return BadRequest(new { error = "Amount must be greater than 0" });
var payment = await medicalDocsService.CreateProviderPaymentAsync(id, request.Amount, request.PaymentDate, request.Description);
if (payment == null)
return StatusCode(500, new { error = "Failed to create payment" });
return Ok(payment);
}
[HttpDelete("provider-payments/{id}")]
public async Task<IActionResult> DeleteProviderPayment(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "provider-payment", id)) return Forbid();
var success = await medicalDocsService.DeleteProviderPaymentAsync(id);
if (!success) return NotFound(new { error = "Payment not found" });
return Ok(new { success = true });
}
// --- Bills ---
[HttpGet("bills")]
public async Task<IActionResult> GetBills([FromQuery] long? personId = null, [FromQuery] long? providerId = null)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
var bills = await medicalDocsService.GetBillsAsync(personId, providerId, accessUserId: personId.HasValue ? null : userId);
return Ok(bills);
}
[HttpPost("bills")]
public async Task<IActionResult> CreateBill([FromBody] CreateBillRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (request.PersonId <= 0)
return BadRequest(new { error = "Person is required" });
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
if (request.TotalAmount <= 0)
return BadRequest(new { error = "Amount must be greater than 0" });
var bill = await medicalDocsService.CreateBillAsync(request.PersonId, request.TotalAmount, request.Summary, request.Category, request.BillDate, request.DoctorId, request.ProviderId);
if (bill == null)
return StatusCode(500, new { error = "Failed to create bill" });
return Ok(bill);
}
[HttpPut("bills/{id}")]
public async Task<IActionResult> UpdateBill(long id, [FromBody] UpdateBillRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
if (request.TotalAmount <= 0)
return BadRequest(new { error = "Amount must be greater than 0" });
var success = await medicalDocsService.UpdateBillAsync(id, request.TotalAmount, request.Summary, request.Category, request.BillDate, request.DoctorId, request.ProviderId);
if (!success) return NotFound(new { error = "Bill not found" });
return Ok(new { success = true });
}
[HttpDelete("bills/{id}")]
public async Task<IActionResult> DeleteBill(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
var success = await medicalDocsService.DeleteBillAsync(id);
if (!success) return NotFound(new { error = "Bill not found" });
return Ok(new { success = true });
}
[HttpPost("bills/{id}/documents")]
public async Task<IActionResult> LinkDocumentToBill(long id, [FromBody] LinkDocumentRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
if (request.DocumentId <= 0)
return BadRequest(new { error = "Document is required" });
var success = await medicalDocsService.LinkDocumentToBillAsync(id, request.DocumentId);
if (!success)
return StatusCode(500, new { error = "Failed to link document" });
return Ok(new { success = true });
}
[HttpDelete("bills/{billId}/documents/{docId}")]
public async Task<IActionResult> UnlinkDocumentFromBill(long billId, long docId)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "bill", billId)) return Forbid();
var success = await medicalDocsService.UnlinkDocumentFromBillAsync(billId, docId);
if (!success) return NotFound(new { error = "Link not found" });
return Ok(new { success = true });
}
// --- Bill Charges ---
[HttpGet("bills/{id}/charges")]
public async Task<IActionResult> GetBillCharges(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
var charges = await medicalDocsService.GetChargesAsync(id);
return Ok(charges);
}
[HttpPost("bills/{id}/charges")]
public async Task<IActionResult> CreateCharge(long id, [FromBody] CreateChargeRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Description))
return BadRequest(new { error = "Description is required" });
if (request.Amount <= 0)
return BadRequest(new { error = "Amount must be greater than 0" });
var charge = await medicalDocsService.CreateChargeAsync(id, request.Description.Trim(), request.Amount);
if (charge == null)
return StatusCode(500, new { error = "Failed to create charge" });
return Ok(charge);
}
[HttpDelete("bill-charges/{id}")]
public async Task<IActionResult> DeleteCharge(long id)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "bill-charge", id)) return Forbid();
var success = await medicalDocsService.DeleteChargeAsync(id);
if (!success) return NotFound(new { error = "Charge not found" });
return Ok(new { success = true });
}
// --- Timeline ---
[HttpGet("timeline")]
public async Task<IActionResult> GetTimeline([FromQuery] long? personId = null, [FromQuery] int offset = 0, [FromQuery] int limit = 100)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
if (limit < 1 || limit > 200) limit = 100;
if (offset < 0) offset = 0;
var events = await medicalDocsService.GetTimelineAsync(personId, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit);
return Ok(events);
}
// --- Visit Prep ---
[HttpGet("visit-prep")]
public async Task<IActionResult> GetVisitPrep([FromQuery] long personId, [FromQuery] long doctorId)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId <= 0 || doctorId <= 0)
return BadRequest(new { error = "personId and doctorId are required" });
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
var data = await medicalDocsService.GetVisitPrepAsync(personId, doctorId);
return Ok(data);
}
[HttpPost("visit-prep/summary")]
public async Task<IActionResult> GenerateVisitPrepSummary([FromBody] VisitPrepSummaryRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (request.PersonId <= 0 || request.DoctorId <= 0)
return BadRequest(new { error = "personId and doctorId are required" });
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
var data = await medicalDocsService.GetVisitPrepAsync(request.PersonId, request.DoctorId);
var doctor = await medicalDocsService.GetDoctorByIdAsync(request.DoctorId);
if (doctor == null)
return NotFound(new { error = "Doctor not found" });
var summary = await medicalAiService.GenerateVisitPrepSummaryAsync(doctor.Name, doctor.Specialty, data);
return Ok(new { summary });
}
[HttpGet("bills/summary")]
public async Task<IActionResult> GetBillSummary([FromQuery] long? personId = null)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
var summary = await medicalDocsService.GetBillSummaryAsync(personId, accessUserId: personId.HasValue ? null : userId);
return Ok(summary);
}
// --- Auth helpers ---
private async Task<bool> HasMedicalAccess(long userId)
{
return await dbExecutor.ExecuteAsync<bool>(
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'medical')",
new { UserId = userId });
}
private async Task<bool> HasPersonAccess(long userId, long personId)
{
return await medicalDocsService.HasAccessToPersonAsync(userId, personId);
}
private async Task<bool> HasResourceAccess(long userId, string resourceType, long resourceId)
{
var personId = await medicalDocsService.GetPersonIdForResourceAsync(resourceType, resourceId);
if (personId == null) return false;
return await medicalDocsService.HasAccessToPersonAsync(userId, personId.Value);
}
private long? GetUserIdFromAuth()
{
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
{
return jwtUserId;
}
var userIdString = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
{
return sessionUserId;
}
return null;
}
}
public record CreatePersonRequest(string Name, DateTime? DateOfBirth = null, string? Notes = null);
public record CreateNoteRequest(long PersonId, string Title, string? Description = null, DateTime? DocumentDate = null, string? Classification = null);
public record UpdateDocumentRequest(string? Title = null, string? Description = null, DateTime? DocumentDate = null, string? Classification = null, long? DoctorId = null);
public record CreateDoctorRequest(long PersonId, string Name, string? Specialty = null, string? Phone = null, string? Address = null, string? Notes = null);
public record CreateConditionRequest(long PersonId, string Name, DateTime? DiagnosedDate = null, string? Notes = null);
public record UpdateConditionRequest(string Name, DateTime? DiagnosedDate = null, string? Notes = null, bool IsActive = true);
public record CreatePrescriptionRequest(long PersonId, string MedicationName, string? Dosage = null, string? Frequency = null, long? DoctorId = null, DateTime? StartDate = null, string? Notes = null, string? RxNumber = null);
public record UpdatePrescriptionRequest(string MedicationName, string? Dosage = null, string? Frequency = null, long? DoctorId = null, DateTime? StartDate = null, DateTime? EndDate = null, string? Notes = null, bool IsActive = true, string? RxNumber = null);
public record CreatePickupRequest(DateTime PickupDate, string? Quantity = null, string? Pharmacy = null, decimal? Cost = null, string? Notes = null);
public record CreateProviderRequest(long PersonId, string Name, string? Notes = null);
public record UpdateProviderRequest(string Name, string? Notes = null);
public record CreateProviderPaymentRequest(decimal Amount, DateTime? PaymentDate = null, string? Description = null);
public record CreateBillRequest(long PersonId, decimal TotalAmount, string? Summary = null, string? Category = null, DateTime? BillDate = null, long? DoctorId = null, long? ProviderId = null);
public record UpdateBillRequest(decimal TotalAmount, string? Summary = null, string? Category = null, DateTime? BillDate = null, long? DoctorId = null, long? ProviderId = null);
public record LinkDocumentRequest(long DocumentId);
public record CreateChargeRequest(string Description, decimal Amount);
public record ProcessBatchRequest(List<long> DocumentIds);
public record VisitPrepSummaryRequest(long PersonId, long DoctorId);
public record GrantAccessRequest(string Username);
+190
View File
@@ -0,0 +1,190 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using Npgsql;
namespace Media.JoshHeaps.Net.Api;
[ApiController]
[Route("sso")]
public class SsoApi(DbExecutor db, IConfiguration config, ILogger<SsoApi> logger) : ControllerBase
{
[HttpPost("token")]
public async Task<IActionResult> Exchange([FromBody] SsoTokenRequest request)
{
if (request == null || string.IsNullOrWhiteSpace(request.ClientId) || string.IsNullOrWhiteSpace(request.Code))
{
return BadRequest(new { error = "client_id and code are required" });
}
if (!Request.Headers.TryGetValue("X-Client-Secret", out var providedSecret) || string.IsNullOrWhiteSpace(providedSecret))
{
return Unauthorized(new { error = "missing client credentials" });
}
var client = SsoClientRegistry.Find(config, request.ClientId);
if (client == null || !BCrypt.Net.BCrypt.Verify(providedSecret!, client.ClientSecretHash))
{
logger.LogWarning("SSO token exchange failed: bad client credentials for {ClientId}", request.ClientId);
return Unauthorized(new { error = "invalid client credentials" });
}
var codeHash = HashCode(request.Code);
var row = await ConsumeCodeAsync(codeHash);
if (row == null)
{
return BadRequest(new { error = "invalid, expired, or already-used code" });
}
if (!string.Equals(row.Value.ClientId, request.ClientId, StringComparison.Ordinal))
{
return BadRequest(new { error = "code was issued for a different client" });
}
if (!client.AllowsRedirectUri(row.Value.RedirectUri))
{
return BadRequest(new { error = "redirect_uri mismatch" });
}
var user = await LoadUserAsync(row.Value.UserId);
if (user == null)
{
return BadRequest(new { error = "user no longer exists" });
}
var jwt = await IssueTokenAsync(user, request.ClientId);
return Ok(new SsoTokenResponse
{
AccessToken = jwt,
TokenType = "Bearer",
ExpiresIn = 300
});
}
private async Task<(long UserId, string ClientId, string RedirectUri)?> ConsumeCodeAsync(string codeHash)
{
var connectionString = config["connectionString"]!;
await using var conn = new NpgsqlConnection(connectionString);
await conn.OpenAsync();
await using var tx = await conn.BeginTransactionAsync();
long userId;
string clientId;
string redirectUri;
await using (var select = new NpgsqlCommand(
@"SELECT user_id, client_id, redirect_uri
FROM app.sso_authorization_codes
WHERE code_hash = @h
AND consumed_at IS NULL
AND expires_at > NOW()
FOR UPDATE", conn, tx))
{
select.Parameters.AddWithValue("@h", codeHash);
await using var reader = await select.ExecuteReaderAsync();
if (!await reader.ReadAsync()) return null;
userId = reader.GetInt64(0);
clientId = reader.GetString(1);
redirectUri = reader.GetString(2);
}
await using (var update = new NpgsqlCommand(
"UPDATE app.sso_authorization_codes SET consumed_at = NOW() WHERE code_hash = @h",
conn, tx))
{
update.Parameters.AddWithValue("@h", codeHash);
await update.ExecuteNonQueryAsync();
}
await tx.CommitAsync();
return (userId, clientId, redirectUri);
}
private async Task<SsoUser?> LoadUserAsync(long userId)
{
return await db.ExecuteReaderAsync(
"SELECT id, email, username, email_verified FROM app.users WHERE id = @userId AND is_active = true",
reader => new SsoUser
{
Id = reader.GetInt64(0),
Email = reader.GetString(1),
Username = reader.GetString(2),
EmailVerified = reader.GetBoolean(3)
},
new { userId });
}
private async Task<string> IssueTokenAsync(SsoUser user, string audience)
{
var jwtKey = config["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured");
var jwtIssuer = config["Jwt:Issuer"] ?? throw new InvalidOperationException("JWT Issuer not configured");
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var roles = await LoadUserRolesAsync(user.Id);
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Name, user.Username),
new Claim("EmailVerified", user.EmailVerified.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))
};
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
var token = new JwtSecurityToken(
issuer: jwtIssuer,
audience: audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(5),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private async Task<List<string>> LoadUserRolesAsync(long userId)
{
return await db.ExecuteListReaderAsync(
@"SELECT r.name
FROM app.user_roles ur
JOIN app.roles r ON ur.role_id = r.id
WHERE ur.user_id = @userId",
reader => reader.GetString(0),
new { userId });
}
private static string HashCode(string code)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(code));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
public sealed class SsoTokenRequest
{
public string ClientId { get; set; } = string.Empty;
public string Code { get; set; } = string.Empty;
}
public sealed class SsoTokenResponse
{
public string AccessToken { get; set; } = string.Empty;
public string TokenType { get; set; } = "Bearer";
public int ExpiresIn { get; set; }
}
internal sealed class SsoUser
{
public long Id { get; set; }
public string Email { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public bool EmailVerified { get; set; }
}
+74
View File
@@ -0,0 +1,74 @@
using System.Text.RegularExpressions;
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace Media.JoshHeaps.Net.Api;
[ApiController]
[Route("api/theme")]
public partial class ThemeApi(ThemeService themeService) : ControllerBase
{
private static readonly HashSet<string> ValidCssVariables =
[
"--bg-primary", "--bg-secondary", "--bg-tertiary", "--bg-hover",
"--text-primary", "--text-secondary",
"--border-primary", "--border-secondary",
"--accent-primary", "--accent-hover",
"--danger", "--danger-hover", "--success"
];
[GeneratedRegex(@"^#[0-9a-fA-F]{6}$")]
private static partial Regex HexColorRegex();
[HttpGet("my")]
public async Task<IActionResult> GetMyTheme()
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
var theme = await themeService.GetUserThemeAsync(userId.Value);
if (theme == null)
{
return Ok(new { baseTheme = "light", colorOverrides = new Dictionary<string, string>() });
}
return Ok(new { baseTheme = theme.BaseTheme, colorOverrides = theme.ColorOverrides });
}
[HttpPut("my")]
public async Task<IActionResult> SaveMyTheme([FromBody] SaveThemeRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (request.BaseTheme != "dark" && request.BaseTheme != "light")
return BadRequest("baseTheme must be 'dark' or 'light'");
foreach (var (key, value) in request.ColorOverrides)
{
if (!ValidCssVariables.Contains(key))
return BadRequest($"Invalid CSS variable: {key}");
if (!HexColorRegex().IsMatch(value))
return BadRequest($"Invalid hex color for {key}: {value}");
}
await themeService.SaveUserThemeAsync(userId.Value, request.BaseTheme, request.ColorOverrides);
return Ok();
}
private long? GetUserIdFromAuth()
{
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
return jwtUserId;
var userIdString = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
return sessionUserId;
return null;
}
}
public record SaveThemeRequest(string BaseTheme, Dictionary<string, string> ColorOverrides);
@@ -0,0 +1,9 @@
-- Medical people (family members, not tied to app users)
CREATE TABLE IF NOT EXISTS app.medical_people (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
date_of_birth DATE NULL,
notes TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
@@ -0,0 +1,11 @@
-- Medical doctors
CREATE TABLE IF NOT EXISTS app.medical_doctors (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
specialty VARCHAR(255) NULL,
phone VARCHAR(50) NULL,
address TEXT NULL,
notes TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
@@ -0,0 +1,13 @@
-- Medical conditions linked to people
CREATE TABLE IF NOT EXISTS app.medical_conditions (
id BIGSERIAL PRIMARY KEY,
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
diagnosed_date DATE NULL,
notes TEXT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_medical_conditions_person_id ON app.medical_conditions(person_id);
@@ -0,0 +1,29 @@
-- Core medical documents table
CREATE TABLE IF NOT EXISTS app.medical_documents (
id BIGSERIAL PRIMARY KEY,
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
document_type VARCHAR(10) NOT NULL DEFAULT 'file', -- 'file' or 'note'
file_name VARCHAR(255) NULL,
file_path VARCHAR(500) NULL,
file_size BIGINT NULL,
mime_type VARCHAR(100) NULL,
is_encrypted BOOLEAN DEFAULT true,
title VARCHAR(500) NULL,
description TEXT NULL,
document_date DATE NULL, -- the date OF the document
classification VARCHAR(100) NULL, -- receipt, lab_result, prescription, imaging, etc.
extracted_text TEXT NULL,
ai_processed BOOLEAN DEFAULT false,
ai_processed_at TIMESTAMP NULL,
ai_raw_response JSONB NULL,
doctor_id BIGINT NULL REFERENCES app.medical_doctors(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_medical_documents_person_id ON app.medical_documents(person_id);
CREATE INDEX IF NOT EXISTS idx_medical_documents_doctor_id ON app.medical_documents(doctor_id);
CREATE INDEX IF NOT EXISTS idx_medical_documents_classification ON app.medical_documents(classification);
CREATE INDEX IF NOT EXISTS idx_medical_documents_document_date ON app.medical_documents(document_date);
CREATE INDEX IF NOT EXISTS idx_medical_documents_created_at ON app.medical_documents(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_medical_documents_ai_processed ON app.medical_documents(ai_processed);
@@ -0,0 +1,16 @@
-- Medical tags and document-tag junction
CREATE TABLE IF NOT EXISTS app.medical_tags (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS app.medical_document_tags (
document_id BIGINT NOT NULL REFERENCES app.medical_documents(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES app.medical_tags(id) ON DELETE CASCADE,
source VARCHAR(10) NOT NULL DEFAULT 'manual', -- 'ai' or 'manual'
CONSTRAINT uq_medical_document_tag UNIQUE (document_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_medical_document_tags_document_id ON app.medical_document_tags(document_id);
CREATE INDEX IF NOT EXISTS idx_medical_document_tags_tag_id ON app.medical_document_tags(tag_id);
@@ -0,0 +1,32 @@
-- Medical prescriptions and pickup tracking
CREATE TABLE IF NOT EXISTS app.medical_prescriptions (
id BIGSERIAL PRIMARY KEY,
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
doctor_id BIGINT NULL REFERENCES app.medical_doctors(id) ON DELETE SET NULL,
medication_name VARCHAR(255) NOT NULL,
dosage VARCHAR(100) NULL,
frequency VARCHAR(100) NULL,
is_active BOOLEAN DEFAULT true,
start_date DATE NULL,
end_date DATE NULL,
notes TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_medical_prescriptions_person_id ON app.medical_prescriptions(person_id);
CREATE INDEX IF NOT EXISTS idx_medical_prescriptions_doctor_id ON app.medical_prescriptions(doctor_id);
CREATE TABLE IF NOT EXISTS app.medical_prescription_pickups (
id BIGSERIAL PRIMARY KEY,
prescription_id BIGINT NOT NULL REFERENCES app.medical_prescriptions(id) ON DELETE CASCADE,
document_id BIGINT NULL REFERENCES app.medical_documents(id) ON DELETE SET NULL,
pickup_date DATE NOT NULL,
quantity VARCHAR(100) NULL,
pharmacy VARCHAR(255) NULL,
cost DECIMAL(10,2) NULL,
notes TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_medical_prescription_pickups_prescription_id ON app.medical_prescription_pickups(prescription_id);
@@ -0,0 +1,16 @@
-- Medical document costs
CREATE TABLE IF NOT EXISTS app.medical_document_costs (
id BIGSERIAL PRIMARY KEY,
document_id BIGINT NOT NULL REFERENCES app.medical_documents(id) ON DELETE CASCADE,
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
amount DECIMAL(10,2) NOT NULL,
cost_type VARCHAR(50) NULL, -- copay, deductible, out_of_pocket, etc.
category VARCHAR(50) NULL, -- office_visit, lab, pharmacy, etc.
cost_date DATE NULL,
description TEXT NULL,
source VARCHAR(10) NOT NULL DEFAULT 'manual', -- 'ai' or 'manual'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_medical_document_costs_document_id ON app.medical_document_costs(document_id);
CREATE INDEX IF NOT EXISTS idx_medical_document_costs_person_id ON app.medical_document_costs(person_id);
@@ -0,0 +1,9 @@
-- Junction table linking medical documents to conditions
CREATE TABLE IF NOT EXISTS app.medical_document_conditions (
document_id BIGINT NOT NULL REFERENCES app.medical_documents(id) ON DELETE CASCADE,
condition_id BIGINT NOT NULL REFERENCES app.medical_conditions(id) ON DELETE CASCADE,
CONSTRAINT uq_medical_document_condition UNIQUE (document_id, condition_id)
);
CREATE INDEX IF NOT EXISTS idx_medical_document_conditions_document_id ON app.medical_document_conditions(document_id);
CREATE INDEX IF NOT EXISTS idx_medical_document_conditions_condition_id ON app.medical_document_conditions(condition_id);
@@ -0,0 +1,36 @@
-- Migration 021: Medical Bills & Payments
-- Replaces the flat medical_document_costs model with proper billing:
-- Bills (charges) with Payments (receipts) tracked against them.
CREATE TABLE IF NOT EXISTS app.medical_bills (
id BIGSERIAL PRIMARY KEY,
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
total_amount DECIMAL(10,2) NOT NULL,
summary TEXT,
category VARCHAR(50),
bill_date DATE,
doctor_id BIGINT REFERENCES app.medical_doctors(id) ON DELETE SET NULL,
source VARCHAR(10) NOT NULL DEFAULT 'manual',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS app.medical_bill_documents (
id BIGSERIAL PRIMARY KEY,
bill_id BIGINT NOT NULL REFERENCES app.medical_bills(id) ON DELETE CASCADE,
document_id BIGINT NOT NULL REFERENCES app.medical_documents(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(bill_id, document_id)
);
CREATE TABLE IF NOT EXISTS app.medical_bill_payments (
id BIGSERIAL PRIMARY KEY,
bill_id BIGINT NOT NULL REFERENCES app.medical_bills(id) ON DELETE CASCADE,
document_id BIGINT REFERENCES app.medical_documents(id) ON DELETE SET NULL,
amount DECIMAL(10,2) NOT NULL,
payment_type VARCHAR(30) NOT NULL,
payment_date DATE,
description TEXT,
source VARCHAR(10) NOT NULL DEFAULT 'manual',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
@@ -0,0 +1,11 @@
-- Migration 022: Bill Line Items (Charges)
-- Breaks down bill totals into individual named charges.
CREATE TABLE IF NOT EXISTS app.medical_bill_charges (
id BIGSERIAL PRIMARY KEY,
bill_id BIGINT NOT NULL REFERENCES app.medical_bills(id) ON DELETE CASCADE,
description TEXT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
source VARCHAR(10) NOT NULL DEFAULT 'manual',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
@@ -0,0 +1,28 @@
-- 023: Medical billing providers
-- Providers are the top-level billing entity. Bills belong to a provider, payments go to a provider.
CREATE TABLE IF NOT EXISTS app.medical_billing_providers (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_billing_providers_name_person
ON app.medical_billing_providers (LOWER(name), person_id);
ALTER TABLE app.medical_bills ADD COLUMN IF NOT EXISTS provider_id BIGINT
REFERENCES app.medical_billing_providers(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS app.medical_provider_payments (
id BIGSERIAL PRIMARY KEY,
provider_id BIGINT NOT NULL REFERENCES app.medical_billing_providers(id) ON DELETE CASCADE,
document_id BIGINT REFERENCES app.medical_documents(id) ON DELETE SET NULL,
amount DECIMAL(10,2) NOT NULL,
payment_date DATE,
description TEXT,
source VARCHAR(10) NOT NULL DEFAULT 'manual',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
@@ -0,0 +1 @@
ALTER TABLE app.medical_prescriptions ADD COLUMN IF NOT EXISTS rx_number VARCHAR(50) NULL;
@@ -0,0 +1,12 @@
CREATE TABLE app.password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
token_hash VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ NULL,
FOREIGN KEY (user_id) REFERENCES app.users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_prt_token_hash ON app.password_reset_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_prt_user_id ON app.password_reset_tokens(user_id);
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS app.medical_people_access (
id BIGSERIAL PRIMARY KEY,
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(person_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_mpa_user_id ON app.medical_people_access(user_id);
CREATE INDEX IF NOT EXISTS idx_mpa_person_id ON app.medical_people_access(person_id);
@@ -0,0 +1,7 @@
-- Add person_id to medical_doctors to scope doctors per person
ALTER TABLE app.medical_doctors ADD COLUMN IF NOT EXISTS person_id BIGINT REFERENCES app.medical_people(id);
CREATE INDEX IF NOT EXISTS idx_medical_doctors_person_id ON app.medical_doctors(person_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_medical_doctors_person_name
ON app.medical_doctors(person_id, LOWER(name)) WHERE person_id IS NOT NULL;
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS app.user_theme_overrides (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
base_theme TEXT NOT NULL DEFAULT 'light',
color_overrides JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id)
);
CREATE INDEX IF NOT EXISTS idx_uto_user_id ON app.user_theme_overrides(user_id);
@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS app.blog_posts (
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(200) UNIQUE NOT NULL,
title VARCHAR(500) NOT NULL,
summary TEXT NOT NULL DEFAULT '',
markdown_content TEXT NOT NULL,
html_content TEXT NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
author_id BIGINT NOT NULL REFERENCES app.users(id),
published_date TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_blog_posts_slug ON app.blog_posts(slug);
CREATE INDEX IF NOT EXISTS idx_blog_posts_published_date ON app.blog_posts(published_date DESC);
-- Seed existing blog post
INSERT INTO app.blog_posts (slug, title, summary, markdown_content, html_content, tags, author_id, published_date)
SELECT
'my-first-post',
'My First Post',
'Welcome to my blog! Here I''ll share thoughts on software development, projects I''m working on, and things I find interesting.',
E'# Welcome to My Blog\n\nI''ve been meaning to start writing about the things I build and learn, and I''m finally getting around to it.\n\n## What to Expect\n\nI plan to write about:\n\n- **Projects** I''m working on, like this website and the other things on my portfolio\n- **Software development** tips and patterns I find useful\n- **Problem solving** approaches that have helped me grow as a developer\n\n## Why a Blog?\n\nBuilding things is great, but explaining *how* and *why* you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.\n\nStay tuned for more posts!',
E'<h1>Welcome to My Blog</h1>\n<p>I''ve been meaning to start writing about the things I build and learn, and I''m finally getting around to it.</p>\n<h2>What to Expect</h2>\n<p>I plan to write about:</p>\n<ul>\n<li><strong>Projects</strong> I''m working on, like this website and the other things on my portfolio</li>\n<li><strong>Software development</strong> tips and patterns I find useful</li>\n<li><strong>Problem solving</strong> approaches that have helped me grow as a developer</li>\n</ul>\n<h2>Why a Blog?</h2>\n<p>Building things is great, but explaining <em>how</em> and <em>why</em> you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.</p>\n<p>Stay tuned for more posts!</p>',
ARRAY['dev', 'personal'],
(SELECT id FROM app.users WHERE id = 1),
'2026-03-06T00:00:00Z'
WHERE EXISTS (SELECT 1 FROM app.users WHERE id = 1)
AND NOT EXISTS (SELECT 1 FROM app.blog_posts WHERE slug = 'my-first-post');
@@ -0,0 +1,15 @@
-- SSO authorization codes for the OAuth2 authorization-code flow.
-- The raw code is never stored; we persist the SHA-256 hash only.
-- Codes are single-use and short-lived (see Sso:CodeLifetimeSeconds in config).
CREATE TABLE IF NOT EXISTS app.sso_authorization_codes (
code_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
redirect_uri TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sso_codes_expires ON app.sso_authorization_codes(expires_at);
@@ -0,0 +1,26 @@
-- Breadboard circuit simulator.
-- A project owns one circuit document (schema v1) stored as JSONB: boards,
-- components and wires laid out on full-size 830-point breadboards.
-- Memory images hold the contents of memory components keyed by the component's
-- uid within the circuit document; no endpoints use them yet.
CREATE TABLE IF NOT EXISTS app.breadboard_projects (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES app.users(id),
name TEXT NOT NULL,
description TEXT,
circuit JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_breadboard_projects_user_id ON app.breadboard_projects(user_id);
CREATE TABLE IF NOT EXISTS app.breadboard_memory_images (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES app.breadboard_projects(id) ON DELETE CASCADE,
component_uid TEXT NOT NULL,
data BYTEA NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_breadboard_memory_images UNIQUE (project_id, component_uid)
);
@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="MailKit" Version="4.8.0" />
<PackageReference Include="Markdig" Version="1.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
<PackageReference Include="Npgsql" Version="9.0.4" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
+16
View File
@@ -0,0 +1,16 @@
namespace Media.JoshHeaps.Net.Models;
public class BlogPost
{
public long Id { get; set; }
public string Slug { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public string MarkdownContent { get; set; } = string.Empty;
public string HtmlContent { get; set; } = string.Empty;
public List<string> Tags { get; set; } = [];
public long AuthorId { get; set; }
public DateTime PublishedDate { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
@@ -0,0 +1,24 @@
using System.Text.Json.Nodes;
namespace Media.JoshHeaps.Net.Models;
/// <summary>Row shape for the project list — deliberately excludes the circuit document.</summary>
public sealed record BreadboardProjectSummary(
long Id,
string Name,
string? Description,
DateTime CreatedAt,
DateTime UpdatedAt);
/// <summary>
/// A single project including its circuit document. The circuit is opaque to C# —
/// it is stored and validated as text and only parsed here so the API emits it as a
/// real JSON object rather than a JSON-encoded string.
/// </summary>
public sealed record BreadboardProject(
long Id,
string Name,
string? Description,
JsonNode? Circuit,
DateTime CreatedAt,
DateTime UpdatedAt);
@@ -0,0 +1,35 @@
namespace Media.JoshHeaps.Net.Models;
public enum BreadboardOutcome
{
Success,
NotFound,
Invalid,
Failed
}
/// <summary>
/// Outcome of a write against a breadboard project. The API layer maps the outcome to a
/// status code; every rule that produces <see cref="BreadboardOutcome.Invalid"/> lives in
/// the service (or the validator it delegates to), never in the controller.
/// </summary>
public sealed record BreadboardResult(
BreadboardOutcome Outcome,
IReadOnlyList<string> Errors,
BreadboardProject? Project)
{
/// <summary>The single wording for "gone or never yours" — read and write paths share it.</summary>
public const string NotFoundMessage = "Project not found";
public static BreadboardResult Succeeded(BreadboardProject? project = null) =>
new(BreadboardOutcome.Success, [], project);
public static BreadboardResult Missing() =>
new(BreadboardOutcome.NotFound, [NotFoundMessage], null);
public static BreadboardResult Invalid(IReadOnlyList<string> errors) =>
new(BreadboardOutcome.Invalid, errors, null);
public static BreadboardResult Failed(string error) =>
new(BreadboardOutcome.Failed, [error], null);
}
+21
View File
@@ -0,0 +1,21 @@
namespace Media.JoshHeaps.Net.Models;
public class MedicalBill
{
public long Id { get; set; }
public long PersonId { get; set; }
public decimal TotalAmount { get; set; }
public string? Summary { get; set; }
public string? Category { get; set; }
public DateTime? BillDate { get; set; }
public long? DoctorId { get; set; }
public long? ProviderId { get; set; }
public string Source { get; set; } = "manual";
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
// Populated via JOIN, not stored in DB
public string? DoctorName { get; set; }
public string? ProviderName { get; set; }
public string? DocumentNames { get; set; }
}
@@ -0,0 +1,11 @@
namespace Media.JoshHeaps.Net.Models;
public class MedicalBillCharge
{
public long Id { get; set; }
public long BillId { get; set; }
public string Description { get; set; } = "";
public decimal Amount { get; set; }
public string Source { get; set; } = "manual";
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,17 @@
namespace Media.JoshHeaps.Net.Models;
public class MedicalBillPayment
{
public long Id { get; set; }
public long BillId { get; set; }
public long? DocumentId { get; set; }
public decimal Amount { get; set; }
public string PaymentType { get; set; } = string.Empty;
public DateTime? PaymentDate { get; set; }
public string? Description { get; set; }
public string Source { get; set; } = "manual";
public DateTime CreatedAt { get; set; }
// Populated via JOIN, not stored in DB
public string? DocumentName { get; set; }
}
@@ -0,0 +1,18 @@
namespace Media.JoshHeaps.Net.Models;
public class MedicalBillingProvider
{
public long Id { get; set; }
public string Name { get; set; } = "";
public long PersonId { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
// Populated via aggregation, not stored in DB
public decimal TotalCharged { get; set; }
public decimal TotalPaid { get; set; }
public int BillCount { get; set; }
public decimal Balance => TotalCharged - TotalPaid;
}
@@ -0,0 +1,13 @@
namespace Media.JoshHeaps.Net.Models;
public class MedicalCondition
{
public long Id { get; set; }
public long PersonId { get; set; }
public string Name { get; set; } = string.Empty;
public DateTime? DiagnosedDate { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
@@ -0,0 +1,14 @@
namespace Media.JoshHeaps.Net.Models;
public class MedicalDoctor
{
public long Id { get; set; }
public long PersonId { get; set; }
public string Name { get; set; } = string.Empty;
public string? Specialty { get; set; }
public string? Phone { get; set; }
public string? Address { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
@@ -0,0 +1,27 @@
namespace Media.JoshHeaps.Net.Models;
public class MedicalDocument
{
public long Id { get; set; }
public long PersonId { get; set; }
public string DocumentType { get; set; } = "file"; // "file" or "note"
public string? FileName { get; set; }
public string? FilePath { get; set; }
public long? FileSize { get; set; }
public string? MimeType { get; set; }
public bool IsEncrypted { get; set; } = true;
public string? Title { get; set; }
public string? Description { get; set; }
public DateTime? DocumentDate { get; set; }
public string? Classification { get; set; }
public string? ExtractedText { get; set; }
public bool AiProcessed { get; set; }
public DateTime? AiProcessedAt { get; set; }
public string? AiRawResponse { get; set; }
public long? DoctorId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
// Convenience properties populated separately
public List<MedicalTag> Tags { get; set; } = [];
}
@@ -0,0 +1,17 @@
namespace Media.JoshHeaps.Net.Models;
public class MedicalPerson
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public DateTime? DateOfBirth { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
public class PersonAccessUser
{
public long Id { get; set; }
public string Username { get; set; } = string.Empty;
}
@@ -0,0 +1,22 @@
namespace Media.JoshHeaps.Net.Models;
public class MedicalPrescription
{
public long Id { get; set; }
public long PersonId { get; set; }
public long? DoctorId { get; set; }
public string MedicationName { get; set; } = string.Empty;
public string? Dosage { get; set; }
public string? Frequency { get; set; }
public string? RxNumber { get; set; }
public bool IsActive { get; set; } = true;
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
// Populated via JOIN, not stored in DB
public string? DoctorName { get; set; }
public DateTime? LastPickupDate { get; set; }
}
@@ -0,0 +1,14 @@
namespace Media.JoshHeaps.Net.Models;
public class MedicalPrescriptionPickup
{
public long Id { get; set; }
public long PrescriptionId { get; set; }
public long? DocumentId { get; set; }
public DateTime PickupDate { get; set; }
public string? Quantity { get; set; }
public string? Pharmacy { get; set; }
public decimal? Cost { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,13 @@
namespace Media.JoshHeaps.Net.Models;
public class MedicalProviderPayment
{
public long Id { get; set; }
public long ProviderId { get; set; }
public long? DocumentId { get; set; }
public decimal Amount { get; set; }
public DateTime? PaymentDate { get; set; }
public string? Description { get; set; }
public string Source { get; set; } = "manual";
public DateTime CreatedAt { get; set; }
}
+8
View File
@@ -0,0 +1,8 @@
namespace Media.JoshHeaps.Net.Models;
public class MedicalTag
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,14 @@
namespace Media.JoshHeaps.Net.Models;
public class TimelineEvent
{
public string EventType { get; set; } = "";
public long Id { get; set; }
public long PersonId { get; set; }
public string? Label { get; set; }
public string? Detail { get; set; }
public string? SubType { get; set; }
public DateTime? EventDate { get; set; }
public long? DoctorId { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,11 @@
namespace Media.JoshHeaps.Net.Models;
public class UserThemeOverrides
{
public long Id { get; set; }
public long UserId { get; set; }
public string BaseTheme { get; set; } = "light";
public Dictionary<string, string> ColorOverrides { get; set; } = new();
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
@@ -0,0 +1,27 @@
namespace Media.JoshHeaps.Net.Models;
public class VisitPrepData
{
public List<VisitPrepDocument> RecentDocuments { get; set; } = [];
public List<MedicalCondition> ActiveConditions { get; set; } = [];
public List<MedicalPrescription> ActivePrescriptions { get; set; } = [];
public List<VisitPrepBill> RecentBills { get; set; } = [];
}
public class VisitPrepDocument
{
public long Id { get; set; }
public string? Title { get; set; }
public string? FileName { get; set; }
public DateTime? DocumentDate { get; set; }
public string? Classification { get; set; }
}
public class VisitPrepBill
{
public long Id { get; set; }
public decimal TotalAmount { get; set; }
public string? Summary { get; set; }
public string? Category { get; set; }
public DateTime? BillDate { get; set; }
}
@@ -0,0 +1,87 @@
@page
@model Media.JoshHeaps.Net.Pages.BlogAdminModel
@{
ViewData["Title"] = "Blog Admin";
Layout = "_Layout";
}
@section Styles {
<link rel="stylesheet" href="~/css/blog-admin.css" asp-append-version="true" />
}
<div class="dashboard-container">
<div class="welcome-section">
<div class="welcome-left">
<a href="/Admin" class="back-button" title="Back to Admin">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
</a>
<h1>Blog Admin</h1>
</div>
<div class="quick-actions">
<button id="newPostBtn" class="btn btn-primary">New Post</button>
</div>
</div>
<div id="postsList" class="admin-section">
<div class="section-header">
<h2>Posts</h2>
</div>
<div class="table-container">
<table class="admin-table">
<thead>
<tr>
<th>Title</th>
<th>Slug</th>
<th>Tags</th>
<th>Published</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="postsTableBody"></tbody>
</table>
</div>
</div>
<div id="postEditor" class="admin-section" style="display: none;">
<div class="section-header">
<h2 id="editorTitle">New Post</h2>
</div>
<input type="hidden" id="editPostId" />
<div class="form-group">
<label for="postTitle">Title</label>
<input type="text" id="postTitle" class="form-input" placeholder="Post title" />
</div>
<div class="form-group">
<label for="postSummary">Summary</label>
<input type="text" id="postSummary" class="form-input" placeholder="Brief summary" />
</div>
<div class="form-group">
<label for="postTags">Tags (comma-separated)</label>
<input type="text" id="postTags" class="form-input" placeholder="dev, personal" />
</div>
<div class="form-group">
<label for="postDate">Published Date</label>
<input type="date" id="postDate" class="form-input" />
</div>
<div class="form-group">
<label for="postContent">Markdown Content</label>
<div class="content-toolbar">
<button type="button" id="uploadImageBtn" class="btn btn-secondary btn-sm">Upload Image</button>
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp" style="display: none;" />
<span id="uploadStatus" class="upload-status"></span>
</div>
<textarea id="postContent" class="form-input form-textarea" placeholder="Write your post in markdown..."></textarea>
</div>
<div class="editor-actions">
<button id="savePostBtn" class="btn btn-primary">Save</button>
<button id="cancelEditBtn" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
@section Scripts {
<script src="~/js/blog-admin.js" asp-append-version="true"></script>
}
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Pages;
public class BlogAdminModel(DbExecutor dbExecutor) : AuthenticatedPageModel
{
private readonly DbExecutor _dbExecutor = dbExecutor;
public async Task<IActionResult> OnGetAsync()
{
RequireAuthentication();
LoadUserSession();
var denied = await RequireRole("admin", _dbExecutor);
if (denied != null) return denied;
return Page();
}
}
+176
View File
@@ -0,0 +1,176 @@
@page
@model Media.JoshHeaps.Net.Pages.BreadboardModel
@{
ViewData["Title"] = "Breadboard Simulator";
Layout = "_Layout";
}
@section Styles {
<link rel="stylesheet" href="~/css/breadboard-projects.css" asp-append-version="true" />
}
@* Class names are this page's own (bbp- prefix, styled in breadboard-projects.css).
This app never links Bootstrap's stylesheet, so Bootstrap class names here would imply
styling that does not exist. Colours come from the site.css custom properties, so the
page follows the theme toggle without any theme-specific rules. *@
<div class="bbp-page">
<div class="bbp-header">
<div class="bbp-header-left">
<a href="/Landing" class="bbp-back" title="Back to Home">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
</a>
<h1>Breadboard Simulator</h1>
</div>
<a href="/Logout" class="bbp-btn bbp-btn-danger">Logout</a>
</div>
<div id="bb-error" class="bbp-error" role="alert"></div>
<div class="bbp-card">
<h2 class="bbp-card-title">New project</h2>
<form id="bb-create-form" class="bbp-create-form">
<div class="bbp-field">
<label for="bb-new-name">Name</label>
<input type="text" class="bbp-input" id="bb-new-name" maxlength="200" required />
</div>
<div class="bbp-field bbp-field-grow">
<label for="bb-new-description">Description</label>
<input type="text" class="bbp-input" id="bb-new-description" maxlength="2000" />
</div>
<button type="submit" class="bbp-btn bbp-btn-primary">Create</button>
</form>
</div>
@if (Model.Projects.Count == 0)
{
<p class="bbp-empty">No projects yet. Create one above to start wiring.</p>
}
else
{
<div class="bbp-list" id="bb-project-list">
@foreach (var project in Model.Projects)
{
<div class="bbp-item" data-project-id="@project.Id" data-project-name="@project.Name">
<div class="bbp-item-main">
<a class="bbp-item-name" href="/[email protected]">@project.Name</a>
@if (!string.IsNullOrWhiteSpace(project.Description))
{
<div class="bbp-item-description">@project.Description</div>
}
@* Rendered in the viewer's timezone by the module script below, not the server's. *@
<div class="bbp-item-meta bbp-updated" data-updated-utc="@project.UpdatedAt.ToUniversalTime().ToString("O")"></div>
</div>
<div class="bbp-item-actions">
@* An <a>, not a <button data-action>, so the delegated handler below ignores it. *@
<a class="bbp-btn bbp-btn-primary" href="/[email protected]">Open</a>
<button type="button" class="bbp-btn" data-action="rename">Rename</button>
<button type="button" class="bbp-btn bbp-btn-danger" data-action="delete">Delete</button>
</div>
</div>
}
</div>
}
</div>
@section Scripts {
<script type="module">
const projectsUrl = '/api/breadboard/projects';
const errorBox = document.getElementById('bb-error');
function showError(message) {
errorBox.textContent = message;
errorBox.classList.add('is-visible');
}
function clearError() {
errorBox.textContent = '';
errorBox.classList.remove('is-visible');
}
async function send(method, url, body) {
clearError();
let response;
try {
response = await fetch(url, {
method,
credentials: 'same-origin',
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body)
});
} catch {
// A silent no-op would read as success, which is the worst outcome for a delete.
showError('Network error - could not reach the server. Nothing was changed.');
return false;
}
if (response.ok) {
return true;
}
let message = `Request failed (${response.status})`;
try {
const payload = await response.json();
if (Array.isArray(payload?.errors) && payload.errors.length > 0) {
message = payload.errors.join(' ');
}
} catch {
// Non-JSON error body; the status-based message stands.
}
showError(message);
return false;
}
for (const cell of document.querySelectorAll('.bbp-updated')) {
const updated = new Date(cell.dataset.updatedUtc);
cell.textContent = `Updated ${updated.toLocaleString()}`;
}
document.getElementById('bb-create-form').addEventListener('submit', async (event) => {
event.preventDefault();
const name = document.getElementById('bb-new-name').value.trim();
const description = document.getElementById('bb-new-description').value.trim();
if (name.length === 0) {
showError('Name is required');
return;
}
if (await send('POST', projectsUrl, { name, description: description || null })) {
window.location.reload();
}
});
document.getElementById('bb-project-list')?.addEventListener('click', async (event) => {
const button = event.target.closest('button[data-action]');
if (button === null) {
return;
}
const row = button.closest('[data-project-id]');
const id = row.dataset.projectId;
const currentName = row.dataset.projectName;
if (button.dataset.action === 'rename') {
const name = window.prompt('New name', currentName);
if (name === null || name.trim().length === 0) {
return;
}
if (await send('PUT', `${projectsUrl}/${id}`, { name: name.trim() })) {
window.location.reload();
}
return;
}
if (window.confirm(`Delete "${currentName}"? This cannot be undone.`)) {
if (await send('DELETE', `${projectsUrl}/${id}`)) {
window.location.reload();
}
}
});
</script>
}
@@ -0,0 +1,26 @@
using Media.JoshHeaps.Net.Models;
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Pages;
public class BreadboardModel(BreadboardService breadboardService) : AuthenticatedPageModel
{
public List<BreadboardProjectSummary> Projects { get; private set; } = [];
public async Task<IActionResult> OnGetAsync()
{
RequireAuthentication();
LoadUserSession();
// RequireAuthentication only queues a redirect, so bail out explicitly rather than
// rendering the page against a zero user id.
if (UserId == 0)
{
return Redirect("/Login");
}
Projects = await breadboardService.GetProjectsAsync(UserId);
return Page();
}
}
@@ -0,0 +1,24 @@
@page
@model Media.JoshHeaps.Net.Pages.BreadboardEditorModel
@{
ViewData["Title"] = $"Breadboard - {Model.ProjectName}";
Layout = "_Layout";
}
@section Styles {
<link rel="stylesheet" href="~/css/breadboard.css" asp-append-version="true" />
}
@* Shell only. Everything inside #breadboard-editor is built by the editor module, so the
component palette can grow without a Razor edit. Contract agreed with frontend-impl:
the root id and the three data-attributes below are the entire server-to-editor surface. *@
<div class="bb-fullbleed">
<div id="breadboard-editor"
data-project-id="@Model.ProjectId"
data-project-name="@Model.ProjectName"
data-api-base="/api/breadboard"></div>
</div>
@section Scripts {
<script type="module" src="~/js/breadboard/editor/main.js"></script>
}
@@ -0,0 +1,34 @@
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Pages;
public class BreadboardEditorModel(BreadboardService breadboardService) : AuthenticatedPageModel
{
public long ProjectId { get; private set; }
public string ProjectName { get; private set; } = string.Empty;
public async Task<IActionResult> OnGetAsync([FromQuery] long projectId)
{
RequireAuthentication();
LoadUserSession();
if (UserId == 0)
{
return Redirect("/Login");
}
// Ownership check lives in SQL, so someone else's project is simply not found. The
// summary lookup deliberately skips the circuit — the editor module fetches the
// document itself, and pulling it here would parse a multi-megabyte payload to throw away.
var project = await breadboardService.GetProjectSummaryAsync(projectId, UserId);
if (project == null)
{
return NotFound();
}
ProjectId = project.Id;
ProjectName = project.Name;
return Page();
}
}
+1
View File
@@ -21,6 +21,7 @@
</a>
<h1>Welcome back, @Model.Dashboard?.Username!</h1>
</div>
<div class="quick-actions">
@if (Model.Dashboard?.EmailVerified == false)
{
+86
View File
@@ -93,6 +93,35 @@
</div>
</a>
<a href="/Breadboard" class="landing-card">
<div class="card-content">
<div class="card-icon-wrapper">
<div class="card-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
<line x1="9" y1="4" x2="9" y2="20"></line>
<line x1="15" y1="4" x2="15" y2="20"></line>
<line x1="4" y1="9" x2="20" y2="9"></line>
<line x1="4" y1="15" x2="20" y2="15"></line>
</svg>
</div>
</div>
<div class="card-text">
<h2>Breadboard Simulator</h2>
<p class="card-description">Build and simulate logic circuits on a virtual solderless breadboard</p>
</div>
</div>
<div class="card-footer">
<span class="card-link-text">Open workspace</span>
<div class="card-arrow">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</div>
</div>
</a>
@if (Model.IsAdmin)
{
<a href="/Admin" class="landing-card">
@@ -119,6 +148,63 @@
</div>
</div>
</a>
<a href="/BlogAdmin" class="landing-card">
<div class="card-content">
<div class="card-icon-wrapper">
<div class="card-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
</div>
</div>
<div class="card-text">
<h2>Blog</h2>
<p class="card-description">Create and manage blog posts for your website</p>
</div>
</div>
<div class="card-footer">
<span class="card-link-text">Open workspace</span>
<div class="card-arrow">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</div>
</div>
</a>
}
@if (Model.HasMedicalRole)
{
<a href="/MedicalDocs" class="landing-card">
<div class="card-content">
<div class="card-icon-wrapper">
<div class="card-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="12" y1="11" x2="12" y2="17"></line>
<line x1="9" y1="14" x2="15" y2="14"></line>
</svg>
</div>
</div>
<div class="card-text">
<h2>Medical Documents</h2>
<p class="card-description">Organize medical records, receipts, and notes for the family</p>
</div>
</div>
<div class="card-footer">
<span class="card-link-text">Open workspace</span>
<div class="card-arrow">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</div>
</div>
</a>
}
</div>
@@ -5,6 +5,7 @@ namespace Media.JoshHeaps.Net.Pages
public class LandingModel(DbExecutor dbExecutor) : AuthenticatedPageModel
{
public bool IsAdmin { get; set; }
public bool HasMedicalRole { get; set; }
public async Task<IActionResult> OnGetAsync()
{
@@ -15,6 +16,10 @@ namespace Media.JoshHeaps.Net.Pages
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'admin')",
new { UserId });
HasMedicalRole = await dbExecutor.ExecuteAsync<bool>(
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'medical')",
new { UserId });
return Page();
}
}
+11 -4
View File
@@ -43,6 +43,10 @@
<form id="loginForm" method="post">
@Html.AntiForgeryToken()
@if (!string.IsNullOrEmpty(Model.ReturnUrl))
{
<input type="hidden" name="ReturnUrl" value="@Model.ReturnUrl" />
}
<div class="form-group">
<label for="email" class="form-label">Email or Username</label>
<input type="text" class="form-control" id="email" name="email"
@@ -60,10 +64,13 @@
<div class="invalid-feedback"></div>
</div>
<div class="checkbox-wrapper">
<input type="checkbox" id="rememberMe" name="rememberMe"
@(Model.RememberMe ? "checked" : "") />
<label for="rememberMe">Remember me</label>
<div class="form-options">
<div class="checkbox-wrapper">
<input type="checkbox" id="rememberMe" name="rememberMe"
@(Model.RememberMe ? "checked" : "") />
<label for="rememberMe">Remember me</label>
</div>
<a href="/LoginHelp" class="forgot-password-link">Forgot password?</a>
</div>
<button type="submit" class="btn-primary">Sign In</button>
+15 -3
View File
@@ -16,17 +16,20 @@ public class LoginModel(AuthService authService) : PageModel
[BindProperty]
public bool RememberMe { get; set; }
[BindProperty(SupportsGet = true)]
public string? ReturnUrl { get; set; }
public string? ErrorMessage { get; set; }
public string? SuccessMessage { get; set; }
public string? WarningMessage { get; set; }
public void OnGet([FromQuery] string? registered, [FromQuery] string? verified)
public void OnGet([FromQuery] string? registered, [FromQuery] string? verified, [FromQuery] string? reset)
{
// Check if user is already logged in
var userId = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userId))
{
Response.Redirect("/Landing");
Response.Redirect(SafeReturnUrl() ?? "/Landing");
return;
}
@@ -41,6 +44,12 @@ public class LoginModel(AuthService authService) : PageModel
{
SuccessMessage = "Email verified! You can now sign in.";
}
// Show success message if password was just reset
if (reset == "true")
{
SuccessMessage = "Your password has been reset. You can now sign in with your new password.";
}
}
public async Task<IActionResult> OnPostAsync()
@@ -84,6 +93,9 @@ public class LoginModel(AuthService authService) : PageModel
Response.Cookies.Append("RememberMe", userInfo.Id.ToString(), cookieOptions);
}
return Redirect("/Landing");
return Redirect(SafeReturnUrl() ?? "/Landing");
}
private string? SafeReturnUrl() =>
!string.IsNullOrWhiteSpace(ReturnUrl) && Url.IsLocalUrl(ReturnUrl) ? ReturnUrl : null;
}
+102
View File
@@ -0,0 +1,102 @@
@page
@model Media.JoshHeaps.Net.Pages.LoginHelpModel
@{
ViewData["Title"] = "Login Help";
Layout = "_Layout";
}
@section Styles {
<link rel="stylesheet" href="~/css/auth.css" asp-append-version="true" />
}
@section Scripts {
<script src="~/js/auth.js" asp-append-version="true"></script>
}
<div class="auth-container">
<div class="auth-card">
@if (Model.ShowResetForm)
{
<div class="auth-header">
<h1>Reset Password</h1>
<p>Enter your new password below</p>
</div>
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger">
@Model.ErrorMessage
</div>
}
<form id="resetPasswordForm" method="post" asp-page-handler="ResetPassword">
@Html.AntiForgeryToken()
<input type="hidden" name="Token" value="@Model.Token" />
<div class="form-group">
<label for="newPassword" class="form-label">New Password</label>
<div class="password-wrapper">
<input type="password" class="form-control" id="newPassword" name="NewPassword"
autocomplete="new-password" required minlength="8" />
<button type="button" class="password-toggle">Show</button>
</div>
<div class="invalid-feedback"></div>
<div class="password-strength">
<div class="password-strength-bar"></div>
</div>
<div class="password-strength-text"></div>
</div>
<div class="form-group">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<div class="password-wrapper">
<input type="password" class="form-control" id="confirmPassword" name="ConfirmPassword"
autocomplete="new-password" required minlength="8" />
<button type="button" class="password-toggle">Show</button>
</div>
<div class="invalid-feedback"></div>
</div>
<button type="submit" class="btn-primary">Reset Password</button>
</form>
}
else
{
<div class="auth-header">
<h1>Forgot Password</h1>
<p>Enter your email to receive a reset link</p>
</div>
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger">
@Model.ErrorMessage
</div>
}
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success">
@Model.SuccessMessage
</div>
}
<form id="requestResetForm" method="post" asp-page-handler="RequestReset">
@Html.AntiForgeryToken()
<div class="form-group">
<label for="email" class="form-label">Email Address</label>
<input type="email" class="form-control" id="email" name="Email"
value="@Model.Email" autocomplete="email" required />
<div class="invalid-feedback"></div>
</div>
<button type="submit" class="btn-primary">Send Reset Link</button>
</form>
}
<div class="auth-footer">
<p>Remember your password? <a href="/Login">Sign in</a></p>
</div>
</div>
</div>
@@ -0,0 +1,111 @@
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Media.JoshHeaps.Net.Pages;
public class LoginHelpModel(AuthService authService, EmailService emailService, ILogger<LoginHelpModel> logger) : PageModel
{
[BindProperty]
public string Email { get; set; } = string.Empty;
[BindProperty]
public string Token { get; set; } = string.Empty;
[BindProperty]
public string NewPassword { get; set; } = string.Empty;
[BindProperty]
public string ConfirmPassword { get; set; } = string.Empty;
public string? ErrorMessage { get; set; }
public string? SuccessMessage { get; set; }
public bool ShowResetForm { get; set; }
public async Task<IActionResult> OnGetAsync([FromQuery] string? token)
{
// Redirect if already logged in
var userId = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userId))
return Redirect("/Landing");
if (!string.IsNullOrEmpty(token))
{
var (valid, error) = await authService.ValidatePasswordResetTokenAsync(token);
if (valid)
{
ShowResetForm = true;
Token = token;
}
else
{
ErrorMessage = error;
}
}
return Page();
}
public async Task<IActionResult> OnPostRequestResetAsync()
{
// Redirect if already logged in
var userId = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userId))
return Redirect("/Landing");
if (string.IsNullOrWhiteSpace(Email))
{
ErrorMessage = "Please enter your email address.";
return Page();
}
var (success, error, token, username) = await authService.RequestPasswordResetAsync(Email.Trim());
if (!success)
{
logger.LogError("Password reset request failed for {Email}: {Error}", Email, error);
}
// Send email if we got a token back (user exists and is eligible)
if (token != null)
{
await emailService.SendPasswordResetEmailAsync(Email.Trim(), username ?? Email.Split('@')[0], token);
}
// Always show the same message regardless of whether the email exists
SuccessMessage = "If an account exists with that email, you will receive a password reset link shortly.";
return Page();
}
public async Task<IActionResult> OnPostResetPasswordAsync()
{
// Redirect if already logged in
var userId = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userId))
return Redirect("/Landing");
if (string.IsNullOrWhiteSpace(NewPassword) || NewPassword.Length < 8)
{
ErrorMessage = "Password must be at least 8 characters.";
ShowResetForm = true;
return Page();
}
if (NewPassword != ConfirmPassword)
{
ErrorMessage = "Passwords do not match.";
ShowResetForm = true;
return Page();
}
var (success, error) = await authService.ResetPasswordAsync(Token, NewPassword);
if (!success)
{
ErrorMessage = error;
return Page();
}
return Redirect("/Login?reset=true");
}
}
@@ -0,0 +1,316 @@
@page
@model Media.JoshHeaps.Net.Pages.MedicalDocsModel
@{
ViewData["Title"] = "Medical Documents";
Layout = "_Layout";
}
@section Styles {
<link rel="stylesheet" href="~/css/medical-docs.css" asp-append-version="true" />
}
<div class="dashboard-container">
<div class="welcome-section">
<div class="welcome-left">
<a href="/Landing" class="back-button" title="Back to Home">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
</a>
<h1>Medical Documents</h1>
</div>
<div class="quick-actions">
<a href="/Profile" class="btn btn-secondary">Profile</a>
<a href="/Logout" class="btn btn-danger">Logout</a>
</div>
</div>
<div class="medical-layout">
<!-- Sidebar: People -->
<div class="medical-sidebar">
<h3>People</h3>
<div class="sidebar-people-list" id="peopleList"></div>
<div class="sidebar-add-person">
<input type="text" id="newPersonName" placeholder="Add person..." class="form-input" />
<button id="addPersonBtn" class="btn btn-primary btn-sm">Add</button>
</div>
</div>
<!-- Main Content -->
<div class="medical-main">
<!-- Summary Cards -->
<div class="summary-cards" id="summaryCards" style="display: none;">
<div class="summary-card active" data-tab="documents" onclick="medDocsSwitchTab('documents')">
<div class="count" id="summaryDocCount">0</div>
<div class="label">Documents</div>
</div>
<div class="summary-card" data-tab="conditions" onclick="medDocsSwitchTab('conditions')">
<div class="count" id="summaryCondCount">0</div>
<div class="label">Conditions</div>
</div>
<div class="summary-card" data-tab="prescriptions" onclick="medDocsSwitchTab('prescriptions')">
<div class="count" id="summaryRxCount">0</div>
<div class="label">Prescriptions</div>
</div>
<div class="summary-card" data-tab="doctors" onclick="medDocsSwitchTab('doctors')">
<div class="count" id="summaryDrCount">0</div>
<div class="label">Doctors</div>
</div>
<div class="summary-card" data-tab="bills" onclick="medDocsSwitchTab('bills')">
<div class="count" id="summaryBillOop">$0</div>
<div class="label">Total Paid</div>
<div class="sublabel" id="summaryBillCharged">of $0 charged</div>
<div class="sublabel" id="summaryBillDue"></div>
</div>
<div class="summary-card" data-tab="timeline" onclick="medDocsSwitchTab('timeline')">
<div class="count" id="summaryTimelineIcon">&#128197;</div>
<div class="label">Timeline</div>
</div>
</div>
<!-- Main Tabs -->
<div class="main-tabs" id="mainTabs">
<button class="main-tab" data-tab="documents" onclick="medDocsSwitchTab('documents')">Documents</button>
<button class="main-tab" data-tab="conditions" onclick="medDocsSwitchTab('conditions')">Conditions</button>
<button class="main-tab" data-tab="prescriptions" onclick="medDocsSwitchTab('prescriptions')">Prescriptions</button>
<button class="main-tab active" data-tab="doctors" onclick="medDocsSwitchTab('doctors')">Doctors</button>
<button class="main-tab" data-tab="bills" onclick="medDocsSwitchTab('bills')">Bills</button>
<button class="main-tab" data-tab="timeline" onclick="medDocsSwitchTab('timeline')">Timeline</button>
</div>
<!-- Tab Panels -->
<div class="tab-panels">
<!-- Documents Panel -->
<div class="tab-panel" id="panel-documents">
<div class="add-form-toggle">
<button class="btn btn-secondary" onclick="medDocsToggleAddForm('panel-documents')">+ Add Document</button>
<span class="doc-count" id="docCount"></span>
<button class="btn btn-secondary btn-sm" id="processAllBtn" style="display: none;" onclick="medDocsProcessAll()">Process All with AI</button>
<button class="btn btn-secondary btn-sm" id="batchModeBtn" style="display: none;" onclick="medDocsToggleBatchMode()">Select &amp; Reprocess</button>
</div>
<div class="add-form-collapsible">
<div class="upload-sub-tabs">
<button class="tab-btn active" data-tab="file">Upload File</button>
<button class="tab-btn" data-tab="note">Text Note</button>
</div>
<!-- File Upload -->
<div class="tab-content active" id="tab-file">
<div class="upload-area" id="dropZone">
<div class="upload-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</div>
<p>Drag & drop files here or <button class="link-btn" id="browseBtn">browse</button></p>
<p class="upload-hint">Any file type, up to 50MB</p>
<input type="file" id="fileInput" multiple style="display: none;" />
</div>
<div class="upload-fields">
<input type="text" id="fileTitle" placeholder="Title (optional)" class="form-input" />
<input type="text" id="fileDescription" placeholder="Description (optional)" class="form-input" />
<input type="date" id="fileDate" class="form-input" />
<select id="fileClassification" class="form-input">
<option value="">Classification (optional)</option>
<option value="receipt">Receipt</option>
<option value="lab_result">Lab Result</option>
<option value="prescription">Prescription</option>
<option value="imaging">Imaging</option>
<option value="dr_note">Doctor Note</option>
<option value="insurance">Insurance</option>
<option value="referral">Referral</option>
<option value="discharge">Discharge Summary</option>
<option value="recording">Recording/Transcript</option>
<option value="other">Other</option>
</select>
</div>
<div class="upload-queue" id="uploadQueue"></div>
</div>
<!-- Text Note -->
<div class="tab-content" id="tab-note">
<div class="note-form">
<input type="text" id="noteTitle" placeholder="Title *" class="form-input" />
<textarea id="noteDescription" placeholder="Note content..." class="form-input note-textarea" rows="6"></textarea>
<div class="note-fields">
<input type="date" id="noteDate" class="form-input" />
<select id="noteClassification" class="form-input">
<option value="">Classification (optional)</option>
<option value="receipt">Receipt</option>
<option value="lab_result">Lab Result</option>
<option value="prescription">Prescription</option>
<option value="dr_note">Doctor Note</option>
<option value="recording">Recording/Transcript</option>
<option value="other">Other</option>
</select>
</div>
<button id="saveNoteBtn" class="btn btn-primary">Save Note</button>
</div>
</div>
</div>
<div class="filter-bar" id="filterBar" style="display: none;">
<input type="text" id="filterSearch" placeholder="Search documents..." class="form-input filter-search" />
<select id="filterClassification" class="form-input">
<option value="">All Classifications</option>
<option value="receipt">Receipt</option>
<option value="lab_result">Lab Result</option>
<option value="prescription">Prescription</option>
<option value="imaging">Imaging</option>
<option value="dr_note">Doctor Note</option>
<option value="insurance">Insurance</option>
<option value="referral">Referral</option>
<option value="discharge">Discharge Summary</option>
<option value="recording">Recording/Transcript</option>
<option value="other">Other</option>
</select>
<select id="filterDocType" class="form-input">
<option value="">All Types</option>
<option value="file">Files</option>
<option value="note">Notes</option>
</select>
<select id="filterDoctor" class="form-input">
<option value="">All Doctors</option>
</select>
<select id="filterTag" class="form-input">
<option value="">All Tags</option>
</select>
<select id="filterCondition" class="form-input">
<option value="">All Conditions</option>
</select>
<input type="date" id="filterFromDate" class="form-input" title="From date" />
<input type="date" id="filterToDate" class="form-input" title="To date" />
<button class="btn btn-secondary btn-sm" onclick="medDocsClearFilters()">Clear</button>
</div>
<div class="batch-toolbar" id="batchToolbar" style="display:none">
<label><input type="checkbox" id="selectAllCheckbox" onchange="medDocsToggleSelectAll(this.checked)"> Select All</label>
<span id="batchCount">0 selected</span>
<button class="btn btn-primary btn-sm" onclick="medDocsProcessBatch()">Reprocess Selected</button>
<button class="btn btn-secondary btn-sm" onclick="medDocsToggleBatchMode()">Cancel</button>
</div>
<div class="documents-list" id="documentsList"></div>
</div>
<!-- Conditions Panel -->
<div class="tab-panel" id="panel-conditions">
<div class="add-form-toggle">
<button class="btn btn-secondary" onclick="medDocsToggleAddForm('panel-conditions')">+ Add Condition</button>
</div>
<div class="add-form-collapsible">
<div class="inline-form-row">
<input type="text" id="newConditionName" placeholder="Condition name *" class="form-input" />
<input type="date" id="newConditionDate" class="form-input" title="Diagnosed date" />
<input type="text" id="newConditionNotes" placeholder="Notes" class="form-input" />
<button class="btn btn-primary btn-sm" onclick="medDocsAddCondition()">Add</button>
</div>
</div>
<div class="conditions-list" id="conditionsList"></div>
</div>
<!-- Prescriptions Panel -->
<div class="tab-panel" id="panel-prescriptions">
<div class="add-form-toggle">
<button class="btn btn-secondary" onclick="medDocsToggleAddForm('panel-prescriptions')">+ Add Prescription</button>
</div>
<div class="add-form-collapsible">
<div class="inline-form-row">
<input type="text" id="newRxMedication" placeholder="Medication name *" class="form-input" />
<input type="text" id="newRxNumber" placeholder="RX#" class="form-input rx-number-input" />
<input type="text" id="newRxDosage" placeholder="Dosage" class="form-input" />
<input type="text" id="newRxFrequency" placeholder="Frequency" class="form-input" />
<select id="newRxDoctor" class="form-input">
<option value="">Doctor (optional)</option>
</select>
<input type="date" id="newRxStartDate" class="form-input" title="Start date" />
<button class="btn btn-primary btn-sm" onclick="medDocsAddPrescription()">Add</button>
</div>
</div>
<div class="prescriptions-list" id="prescriptionsList"></div>
</div>
<!-- Doctors Panel -->
<div class="tab-panel active" id="panel-doctors">
<div class="add-form-toggle">
<button class="btn btn-secondary" onclick="medDocsToggleAddForm('panel-doctors')">+ Add Doctor</button>
</div>
<div class="add-form-collapsible">
<div class="inline-form-row">
<input type="text" id="newDoctorName" placeholder="Name *" class="form-input" />
<input type="text" id="newDoctorSpecialty" placeholder="Specialty" class="form-input" />
<input type="text" id="newDoctorPhone" placeholder="Phone" class="form-input" />
<input type="text" id="newDoctorAddress" placeholder="Address" class="form-input" />
<button class="btn btn-primary btn-sm" onclick="medDocsAddDoctor()">Add</button>
</div>
</div>
<div class="doctors-list" id="doctorsList"></div>
</div>
<!-- Bills Panel -->
<div class="tab-panel" id="panel-bills">
<div class="add-form-toggle">
<button class="btn btn-secondary" onclick="medDocsToggleAddForm('panel-bills')">+ Add Provider</button>
</div>
<div class="add-form-collapsible">
<div class="inline-form-row">
<input type="text" id="newProviderName" placeholder="Provider name *" class="form-input" />
<input type="text" id="newProviderNotes" placeholder="Notes (optional)" class="form-input" />
<button class="btn btn-primary btn-sm" onclick="medDocsAddProvider()">Add</button>
</div>
</div>
<div id="billSummarySection"></div>
<div class="providers-list" id="providersList"></div>
</div>
<!-- Timeline Panel -->
<div class="tab-panel" id="panel-timeline">
<div id="timelineList"></div>
<button class="btn btn-secondary" id="timelineLoadMore" style="display:none" onclick="medDocsLoadMoreTimeline()">Load More</button>
</div>
</div>
</div>
</div>
</div>
<!-- Document Viewer Modal -->
<div class="doc-viewer-overlay" id="docViewerOverlay" style="display:none" onclick="medDocsCloseViewer(event)">
<div class="doc-viewer-modal" onclick="event.stopPropagation()">
<div class="doc-viewer-header">
<span class="doc-viewer-title" id="docViewerTitle"></span>
<button class="doc-viewer-close" onclick="medDocsCloseViewer()" title="Close">&times;</button>
</div>
<div class="doc-viewer-body" id="docViewerBody">
</div>
</div>
</div>
<!-- Share Access Modal -->
<div class="doc-viewer-overlay" id="shareAccessOverlay" style="display:none" onclick="medDocsCloseShareModal(event)">
<div class="doc-viewer-modal" style="max-width:420px;max-height:400px;" onclick="event.stopPropagation()">
<div class="doc-viewer-header">
<span class="doc-viewer-title">Share Patient Access</span>
<button class="doc-viewer-close" onclick="medDocsCloseShareModal()" title="Close">&times;</button>
</div>
<div class="doc-viewer-body" style="padding:1rem;overflow-y:auto;">
<div id="shareAccessList" style="margin-bottom:1rem;"></div>
<div style="display:flex;gap:0.5rem;">
<input type="text" id="shareUsername" placeholder="Username..." class="form-input" style="flex:1;" />
<button class="btn btn-primary btn-sm" onclick="medDocsGrantAccess()">Grant</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/js/medical-docs/state.js" asp-append-version="true"></script>
<script src="~/js/medical-docs/tabs.js" asp-append-version="true"></script>
<script src="~/js/medical-docs/people.js" asp-append-version="true"></script>
<script src="~/js/medical-docs/documents.js" asp-append-version="true"></script>
<script src="~/js/medical-docs/doctors.js" asp-append-version="true"></script>
<script src="~/js/medical-docs/conditions.js" asp-append-version="true"></script>
<script src="~/js/medical-docs/prescriptions.js" asp-append-version="true"></script>
<script src="~/js/medical-docs/bills.js" asp-append-version="true"></script>
<script src="~/js/medical-docs/timeline.js" asp-append-version="true"></script>
<script src="~/js/medical-docs/init.js" asp-append-version="true"></script>
}
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Pages
{
public class MedicalDocsModel(DbExecutor dbExecutor) : AuthenticatedPageModel
{
private readonly DbExecutor _dbExecutor = dbExecutor;
public async Task<IActionResult> OnGetAsync()
{
RequireAuthentication();
LoadUserSession();
var denied = await RequireRole("medical", _dbExecutor);
if (denied != null) return denied;
return Page();
}
}
}
+11 -127
View File
@@ -7,133 +7,8 @@
@section Styles {
<link rel="stylesheet" href="~/css/Home/page.css" asp-append-version="true" />
<style>
.profile-container {
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
}
.profile-section {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 24px;
margin-bottom: 20px;
}
.profile-section h2 {
margin: 0 0 20px 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
border-bottom: 1px solid var(--border-primary);
padding-bottom: 12px;
}
.profile-field {
margin-bottom: 16px;
}
.profile-field label {
display: block;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 4px;
font-size: 14px;
}
.profile-field-value {
color: var(--text-primary);
font-size: 15px;
}
.theme-toggle-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
}
.theme-toggle-label {
display: flex;
flex-direction: column;
}
.theme-toggle-label strong {
color: var(--text-primary);
font-size: 15px;
margin-bottom: 4px;
}
.theme-toggle-label span {
color: var(--text-secondary);
font-size: 13px;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary);
border: 1px solid var(--border-primary);
transition: 0.3s;
border-radius: 34px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: var(--text-secondary);
transition: 0.3s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: var(--accent-primary);
border-color: var(--accent-primary);
}
input:checked + .toggle-slider:before {
transform: translateX(24px);
background-color: var(--bg-primary);
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent-primary);
text-decoration: none;
font-size: 14px;
margin-bottom: 20px;
transition: color 0.2s ease;
}
.back-link:hover {
color: var(--accent-hover);
}
</style>
<link rel="stylesheet" href="~/css/profile.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/theme-customizer.css" asp-append-version="true" />
}
<div class="profile-container">
@@ -176,5 +51,14 @@
<span class="toggle-slider"></span>
</label>
</div>
<div class="theme-toggle-container">
<div class="theme-toggle-label">
<strong>Custom Colors</strong>
<span>Personalize individual theme colors</span>
</div>
<button class="customize-btn" onclick="openThemeCustomizer()">Customize</button>
</div>
</div>
</div>
<script src="~/js/theme-customizer.js" asp-append-version="true"></script>
@@ -0,0 +1,5 @@
@page "/sso/authorize"
@model Media.JoshHeaps.Net.Pages.Sso.AuthorizeModel
@{
Layout = null;
}
@@ -0,0 +1,59 @@
using System.Security.Cryptography;
using System.Text;
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Pages.Sso;
public class AuthorizeModel(DbExecutor db, IConfiguration config, ILogger<AuthorizeModel> logger) : AuthenticatedPageModel
{
public async Task<IActionResult> OnGetAsync(
[FromQuery(Name = "client_id")] string? clientId,
[FromQuery(Name = "redirect_uri")] string? redirectUri,
[FromQuery] string? state)
{
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(redirectUri) || string.IsNullOrWhiteSpace(state))
{
return BadRequest("client_id, redirect_uri, and state are required");
}
var client = SsoClientRegistry.Find(config, clientId);
if (client == null) return BadRequest("unknown client_id");
if (!client.AllowsRedirectUri(redirectUri)) return BadRequest("redirect_uri is not registered for this client");
if (!IsAuthenticated())
{
var original = $"/sso/authorize?client_id={Uri.EscapeDataString(clientId)}&redirect_uri={Uri.EscapeDataString(redirectUri)}&state={Uri.EscapeDataString(state)}";
return Redirect($"/Login?ReturnUrl={Uri.EscapeDataString(original)}");
}
LoadUserSession();
var code = GenerateCode();
var codeHash = HashCode(code);
var lifetime = int.TryParse(config["Sso:CodeLifetimeSeconds"], out var s) ? s : 60;
var expiresAt = DateTimeOffset.UtcNow.AddSeconds(lifetime);
await db.ExecuteNonQueryAsync(
@"INSERT INTO app.sso_authorization_codes (code_hash, client_id, user_id, redirect_uri, expires_at)
VALUES (@codeHash, @clientId, @userId, @redirectUri, @expiresAt)",
new { codeHash, clientId, userId = UserId, redirectUri, expiresAt });
logger.LogInformation("SSO code issued for user {UserId} to client {ClientId}", UserId, clientId);
var separator = redirectUri.Contains('?') ? '&' : '?';
return Redirect($"{redirectUri}{separator}code={Uri.EscapeDataString(code)}&state={Uri.EscapeDataString(state)}");
}
private static string GenerateCode()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes).Replace("+", "-").Replace("/", "_").TrimEnd('=');
}
private static string HashCode(string code)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(code));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
+7
View File
@@ -17,6 +17,13 @@ builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<MediaService>();
builder.Services.AddScoped<FolderService>();
builder.Services.AddScoped<GraphService>();
builder.Services.AddScoped<MedicalDocsService>();
builder.Services.AddSingleton<MedicalAiService>();
builder.Services.AddScoped<ThemeService>();
builder.Services.AddScoped<BlogService>();
builder.Services.AddScoped<BreadboardValidator>();
builder.Services.AddScoped<BreadboardService>();
builder.Services.AddHttpClient();
// Add session support
builder.Services.AddDistributedMemoryCache();
+171
View File
@@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using Media.JoshHeaps.Net;
using Media.JoshHeaps.Net.Models;
@@ -302,4 +304,173 @@ public class AuthService(DbExecutor db)
new { userId, lastLogin = DateTime.UtcNow }
);
}
public static string GenerateSecureToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
}
public static string HashToken(string token)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
public async Task<(bool Success, string? Error, string? Token, string? Username)> RequestPasswordResetAsync(string email)
{
try
{
var userRow = await db.ExecuteReaderAsync(
"SELECT id, username, is_active, locked_until FROM app.users WHERE email = @email",
reader => new
{
UserId = reader.GetInt64(0),
Username = reader.GetString(1),
IsActive = reader.GetBoolean(2),
LockedUntil = reader.IsDBNull(3) ? (DateTime?)null : reader.GetDateTime(3)
},
new { email }
);
if (userRow == null)
{
// Artificial delay to prevent timing-based email enumeration
await Task.Delay(Random.Shared.Next(100, 300));
return (true, null, null, null);
}
// Silently succeed for inactive/locked accounts (don't reveal state)
if (!userRow.IsActive ||
(userRow.LockedUntil.HasValue && userRow.LockedUntil.Value > DateTime.UtcNow))
{
return (true, null, null, null);
}
// Rate limit: max 3 requests per hour
var recentCount = await db.ExecuteAsync<long>(
@"SELECT COUNT(*) FROM app.password_reset_tokens
WHERE user_id = @userId AND created_at > @cutoff",
new { userId = userRow.UserId, cutoff = DateTimeOffset.UtcNow.AddHours(-1) }
);
if (recentCount >= 3)
{
return (true, null, null, null);
}
// Invalidate all existing unused tokens for this user
await db.ExecuteNonQueryAsync(
@"UPDATE app.password_reset_tokens
SET used_at = @now
WHERE user_id = @userId AND used_at IS NULL",
new { userId = userRow.UserId, now = DateTimeOffset.UtcNow }
);
// Generate and store new token
var token = GenerateSecureToken();
var tokenHash = HashToken(token);
var expiresAt = DateTimeOffset.UtcNow.AddHours(1);
await db.ExecuteNonQueryAsync(
@"INSERT INTO app.password_reset_tokens (user_id, token_hash, expires_at)
VALUES (@userId, @tokenHash, @expiresAt)",
new { userId = userRow.UserId, tokenHash, expiresAt }
);
return (true, null, token, userRow.Username);
}
catch (Exception ex)
{
return (false, $"Password reset request failed: {ex.Message}", null, null);
}
}
public async Task<(bool Valid, string? Error)> ValidatePasswordResetTokenAsync(string token)
{
try
{
var tokenHash = HashToken(token);
var tokenRow = await db.ExecuteReaderAsync(
@"SELECT expires_at, used_at FROM app.password_reset_tokens
WHERE token_hash = @tokenHash",
reader => new
{
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(0),
UsedAt = reader.IsDBNull(1) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(1)
},
new { tokenHash }
);
if (tokenRow == null)
return (false, "Invalid or expired reset link. Please request a new one.");
if (tokenRow.UsedAt.HasValue)
return (false, "This reset link has already been used. Please request a new one.");
if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow)
return (false, "This reset link has expired. Please request a new one.");
return (true, null);
}
catch (Exception ex)
{
return (false, $"Token validation failed: {ex.Message}");
}
}
public async Task<(bool Success, string? Error)> ResetPasswordAsync(string token, string newPassword)
{
try
{
var tokenHash = HashToken(token);
var tokenRow = await db.ExecuteReaderAsync(
@"SELECT id, user_id, expires_at, used_at FROM app.password_reset_tokens
WHERE token_hash = @tokenHash",
reader => new
{
Id = reader.GetInt64(0),
UserId = reader.GetInt64(1),
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(2),
UsedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(3)
},
new { tokenHash }
);
if (tokenRow == null)
return (false, "Invalid or expired reset link. Please request a new one.");
if (tokenRow.UsedAt.HasValue)
return (false, "This reset link has already been used. Please request a new one.");
if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow)
return (false, "This reset link has expired. Please request a new one.");
// Hash new password and update user
var passwordHash = HashPassword(newPassword);
await db.ExecuteNonQueryAsync(
@"UPDATE app.users
SET password_hash = @passwordHash, failed_login_attempts = 0, locked_until = NULL
WHERE id = @userId",
new { userId = tokenRow.UserId, passwordHash }
);
// Mark token as used
await db.ExecuteNonQueryAsync(
"UPDATE app.password_reset_tokens SET used_at = @now WHERE id = @tokenId",
new { tokenId = tokenRow.Id, now = DateTimeOffset.UtcNow }
);
return (true, null);
}
catch (Exception ex)
{
return (false, $"Password reset failed: {ex.Message}");
}
}
}
+258
View File
@@ -0,0 +1,258 @@
using System.Text.RegularExpressions;
using Markdig;
using Media.JoshHeaps.Net.Models;
namespace Media.JoshHeaps.Net.Services;
public partial class BlogService(DbExecutor db, IWebHostEnvironment environment, ILogger<BlogService> logger)
{
private static readonly HashSet<string> AllowedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
private const long MaxImageSize = 10 * 1024 * 1024;
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
public async Task<List<BlogPost>> GetAllPostsAsync()
{
try
{
return await db.ExecuteListReaderAsync(
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
FROM app.blog_posts
ORDER BY published_date DESC",
MapBlogPost);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get all blog posts");
return [];
}
}
public async Task<BlogPost?> GetPostByIdAsync(long id)
{
try
{
return await db.ExecuteReaderAsync(
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
FROM app.blog_posts
WHERE id = @Id",
MapBlogPost,
new { Id = id });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get blog post by id {Id}", id);
return null;
}
}
public async Task<BlogPost?> GetPostBySlugAsync(string slug)
{
try
{
return await db.ExecuteReaderAsync(
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
FROM app.blog_posts
WHERE slug = @Slug",
MapBlogPost,
new { Slug = slug });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get blog post by slug {Slug}", slug);
return null;
}
}
public async Task<List<BlogPost>> GetPostsByTagAsync(string tag)
{
try
{
return await db.ExecuteListReaderAsync(
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
FROM app.blog_posts
WHERE @Tag = ANY(tags)
ORDER BY published_date DESC",
MapBlogPost,
new { Tag = tag });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get blog posts by tag {Tag}", tag);
return [];
}
}
public async Task<BlogPost?> CreatePostAsync(string title, string summary, string markdownContent, List<string> tags, long authorId, DateTime publishedDate)
{
try
{
var slug = GenerateSlug(title);
var htmlContent = Markdown.ToHtml(markdownContent, Pipeline);
var now = DateTime.UtcNow;
return await db.ExecuteReaderAsync(
@"INSERT INTO app.blog_posts (slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at)
VALUES (@Slug, @Title, @Summary, @MarkdownContent, @HtmlContent, @Tags, @AuthorId, @PublishedDate, @CreatedAt, @UpdatedAt)
RETURNING id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at",
MapBlogPost,
new
{
Slug = slug,
Title = title,
Summary = summary,
MarkdownContent = markdownContent,
HtmlContent = htmlContent,
Tags = tags.ToArray(),
AuthorId = authorId,
PublishedDate = publishedDate,
CreatedAt = now,
UpdatedAt = now
});
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create blog post '{Title}'", title);
return null;
}
}
public async Task<BlogPost?> UpdatePostAsync(long id, string title, string summary, string markdownContent, List<string> tags, DateTime publishedDate)
{
try
{
var slug = GenerateSlug(title);
var htmlContent = Markdown.ToHtml(markdownContent, Pipeline);
return await db.ExecuteReaderAsync(
@"UPDATE app.blog_posts
SET slug = @Slug, title = @Title, summary = @Summary, markdown_content = @MarkdownContent,
html_content = @HtmlContent, tags = @Tags, published_date = @PublishedDate, updated_at = @UpdatedAt
WHERE id = @Id
RETURNING id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at",
MapBlogPost,
new
{
Id = id,
Slug = slug,
Title = title,
Summary = summary,
MarkdownContent = markdownContent,
HtmlContent = htmlContent,
Tags = tags.ToArray(),
PublishedDate = publishedDate,
UpdatedAt = DateTime.UtcNow
});
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update blog post {Id}", id);
return null;
}
}
public async Task<bool> DeletePostAsync(long id)
{
try
{
var rows = await db.ExecuteNonQueryAsync(
"DELETE FROM app.blog_posts WHERE id = @Id",
new { Id = id });
return rows > 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete blog post {Id}", id);
return false;
}
}
public async Task<(string? url, string? error)> SaveImageAsync(IFormFile file)
{
if (!AllowedMimeTypes.Contains(file.ContentType))
return (null, "Only JPEG, PNG, GIF, and WEBP images are allowed");
if (file.Length > MaxImageSize)
return (null, "Image must be under 10MB");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(ext)) ext = ".jpg";
var fileName = $"{Guid.NewGuid()}{ext}";
var folder = Path.Combine(environment.ContentRootPath, "App_Data", "blog");
Directory.CreateDirectory(folder);
var filePath = Path.Combine(folder, fileName);
try
{
await using var stream = new FileStream(filePath, FileMode.Create);
await file.CopyToAsync(stream);
return ($"/api/blog/images/{fileName}", null);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to save blog image");
if (File.Exists(filePath)) File.Delete(filePath);
return (null, "Failed to save image");
}
}
public (string? filePath, string? mimeType) GetImagePath(string fileName)
{
if (fileName.Contains("..") || fileName.Contains('/') || fileName.Contains('\\'))
return (null, null);
var filePath = Path.Combine(environment.ContentRootPath, "App_Data", "blog", fileName);
if (!File.Exists(filePath))
return (null, null);
var ext = Path.GetExtension(fileName).ToLowerInvariant();
var mimeType = ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "application/octet-stream"
};
return (filePath, mimeType);
}
private static BlogPost MapBlogPost(Npgsql.NpgsqlDataReader reader)
{
return new BlogPost
{
Id = reader.GetInt64(0),
Slug = reader.GetString(1),
Title = reader.GetString(2),
Summary = reader.GetString(3),
MarkdownContent = reader.GetString(4),
HtmlContent = reader.GetString(5),
Tags = reader.GetFieldValue<string[]>(6).ToList(),
AuthorId = reader.GetInt64(7),
PublishedDate = reader.GetDateTime(8),
CreatedAt = reader.GetDateTime(9),
UpdatedAt = reader.GetDateTime(10)
};
}
private static string GenerateSlug(string title)
{
var slug = title.ToLowerInvariant();
slug = SlugInvalidChars().Replace(slug, "");
slug = SlugWhitespace().Replace(slug, "-");
slug = SlugMultipleDashes().Replace(slug, "-");
return slug.Trim('-');
}
[GeneratedRegex(@"[^a-z0-9\s-]")]
private static partial Regex SlugInvalidChars();
[GeneratedRegex(@"\s+")]
private static partial Regex SlugWhitespace();
[GeneratedRegex(@"-{2,}")]
private static partial Regex SlugMultipleDashes();
}
@@ -0,0 +1,293 @@
using System.Text.Json.Nodes;
using Media.JoshHeaps.Net.Models;
using Npgsql;
namespace Media.JoshHeaps.Net.Services;
/// <summary>
/// Data access and write rules for breadboard projects. The circuit document is opaque
/// here: it travels as JSON text, is checked by <see cref="BreadboardValidator"/> before
/// it ever reaches the database, and is only parsed on the way out so the API can emit it
/// as a JSON object. Ownership is enforced in SQL — every statement is scoped by user_id,
/// so a project belonging to someone else is indistinguishable from one that never existed.
/// </summary>
public class BreadboardService(DbExecutor db, BreadboardValidator validator, ILogger<BreadboardService> logger)
{
public const int MaxNameLength = 200;
public const int MaxDescriptionLength = 2000;
public const int MaxProjectsPerUser = 200;
private const string EmptyCircuitJson = """{"version":1,"boards":[],"components":[],"wires":[]}""";
public async Task<List<BreadboardProjectSummary>> GetProjectsAsync(long userId)
{
try
{
var query = @"
SELECT id, name, description, created_at, updated_at
FROM app.breadboard_projects
WHERE user_id = @userId
ORDER BY updated_at DESC";
return await db.ExecuteListReaderAsync(query, MapSummary, new { userId });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list breadboard projects for user {UserId}", userId);
return [];
}
}
/// <summary>
/// Ownership check plus display fields, without dragging the circuit document along.
/// Pages that only need to know "is this mine, and what is it called" use this so the
/// document is fetched exactly once, by the editor module over the API.
/// </summary>
public async Task<BreadboardProjectSummary?> GetProjectSummaryAsync(long projectId, long userId)
{
var query = @"
SELECT id, name, description, created_at, updated_at
FROM app.breadboard_projects
WHERE id = @projectId AND user_id = @userId";
return await db.ExecuteReaderAsync(query, MapSummary, new { projectId, userId });
}
/// <summary>
/// Returns null only when the project does not exist or is not this user's. Database
/// failures deliberately propagate — a caller must never turn an outage into a 404.
/// </summary>
public async Task<BreadboardProject?> GetProjectAsync(long projectId, long userId)
{
var query = @"
SELECT id, name, description, circuit::text, created_at, updated_at
FROM app.breadboard_projects
WHERE id = @projectId AND user_id = @userId";
return await db.ExecuteReaderAsync(query, MapProject, new { projectId, userId });
}
public async Task<BreadboardResult> CreateProjectAsync(long userId, string? name, string? description)
{
var errors = new List<string>();
var trimmedName = ValidateName(name, errors);
var trimmedDescription = ValidateDescription(description, errors);
if (errors.Count > 0)
{
return BreadboardResult.Invalid(errors);
}
// Even the starter document goes through the validator — there is no trusted path by
// which a circuit reaches the database unchecked. It is server-authored though, so a
// rejection means the seed and the validator have drifted: that is our bug, not the
// caller's, and it must not surface as a 400 blaming their input.
var circuitCheck = validator.Validate(EmptyCircuitJson);
if (!circuitCheck.IsValid)
{
logger.LogError(
"Seed breadboard circuit document was rejected by the validator: {Errors}",
string.Join(", ", circuitCheck.Errors));
return BreadboardResult.Failed("Failed to create project");
}
try
{
// An authenticated user can otherwise grow the table without bound, 2 MB at a time.
var projectCount = await db.ExecuteAsync<long>(
"SELECT COUNT(*) FROM app.breadboard_projects WHERE user_id = @userId",
new { userId });
if (projectCount >= MaxProjectsPerUser)
{
return BreadboardResult.Invalid([$"Project limit reached ({MaxProjectsPerUser} per account)"]);
}
var query = @"
INSERT INTO app.breadboard_projects (user_id, name, description, circuit, created_at, updated_at)
VALUES (@userId, @name, @description, @circuit::jsonb, NOW(), NOW())
RETURNING id, name, description, circuit::text, created_at, updated_at";
var project = await db.ExecuteReaderAsync(query, MapProject, new
{
userId,
name = trimmedName,
description = trimmedDescription,
circuit = EmptyCircuitJson
});
return project is null
? BreadboardResult.Failed("Failed to create project")
: BreadboardResult.Succeeded(project);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create breadboard project for user {UserId}", userId);
return BreadboardResult.Failed("Failed to create project");
}
}
/// <summary>
/// Partial update: a null argument means "leave unchanged". An explicitly blank
/// description clears the column.
/// </summary>
public async Task<BreadboardResult> UpdateProjectAsync(
long projectId,
long userId,
string? name,
string? description,
string? circuitJson)
{
var errors = new List<string>();
var setName = name is not null;
var trimmedName = setName ? ValidateName(name, errors) : null;
var setDescription = description is not null;
var trimmedDescription = setDescription ? ValidateDescription(description, errors) : null;
var setCircuit = circuitJson is not null;
if (setCircuit)
{
var circuitCheck = validator.Validate(circuitJson!);
if (!circuitCheck.IsValid)
{
errors.AddRange(circuitCheck.Errors);
}
}
if (errors.Count > 0)
{
return BreadboardResult.Invalid(errors);
}
try
{
var query = @"
UPDATE app.breadboard_projects
SET name = CASE WHEN @setName THEN @name ELSE name END,
-- NULLIF keeps @description a non-null text parameter, so the server never has
-- to infer a type for an untyped NULL, and clearing still works.
description = CASE WHEN @setDescription THEN NULLIF(@description, '') ELSE description END,
circuit = CASE WHEN @setCircuit THEN @circuit::jsonb ELSE circuit END,
-- A no-op save must not reorder the project list, which sorts on updated_at.
updated_at = CASE WHEN @setName OR @setDescription OR @setCircuit THEN NOW() ELSE updated_at END
WHERE id = @projectId AND user_id = @userId";
var rows = await db.ExecuteNonQueryAsync(query, new
{
projectId,
userId,
setName,
// Guarded by @setName — the CASE is what keeps this placeholder off the column.
name = trimmedName ?? string.Empty,
setDescription,
description = trimmedDescription ?? string.Empty,
setCircuit,
// Guarded by @setCircuit, but the ::jsonb cast still parses it, so it must be
// valid JSON even on the branch the CASE discards.
circuit = circuitJson ?? EmptyCircuitJson
});
return rows == 0 ? BreadboardResult.Missing() : BreadboardResult.Succeeded();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update breadboard project {ProjectId} for user {UserId}", projectId, userId);
return BreadboardResult.Failed("Failed to update project");
}
}
public async Task<BreadboardResult> DeleteProjectAsync(long projectId, long userId)
{
try
{
var query = "DELETE FROM app.breadboard_projects WHERE id = @projectId AND user_id = @userId";
var rows = await db.ExecuteNonQueryAsync(query, new { projectId, userId });
return rows == 0 ? BreadboardResult.Missing() : BreadboardResult.Succeeded();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete breadboard project {ProjectId} for user {UserId}", projectId, userId);
return BreadboardResult.Failed("Failed to delete project");
}
}
private static BreadboardProjectSummary MapSummary(NpgsqlDataReader reader) => new(
reader.GetInt64(0),
reader.GetString(1),
reader.IsDBNull(2) ? null : reader.GetString(2),
reader.GetDateTime(3),
reader.GetDateTime(4));
private BreadboardProject MapProject(NpgsqlDataReader reader) => new(
reader.GetInt64(0),
reader.GetString(1),
reader.IsDBNull(2) ? null : reader.GetString(2),
ParseCircuit(reader.GetString(3), reader.GetInt64(0)),
reader.GetDateTime(4),
reader.GetDateTime(5));
private JsonNode? ParseCircuit(string circuitJson, long projectId)
{
try
{
return JsonNode.Parse(circuitJson);
}
catch (Exception ex)
{
logger.LogError(ex, "Stored circuit for breadboard project {ProjectId} is not parseable JSON", projectId);
return null;
}
}
private static string ValidateName(string? name, List<string> errors)
{
var trimmed = name?.Trim() ?? string.Empty;
if (trimmed.Length == 0)
{
errors.Add("Name is required");
}
else if (trimmed.Length > MaxNameLength)
{
errors.Add($"Name must be {MaxNameLength} characters or fewer");
}
else if (trimmed.Any(char.IsControl))
{
// PostgreSQL rejects NUL in text outright; catching it here makes it a 400 rather
// than a generic 500, and the rest of the control range has no business in a name.
errors.Add("Name must not contain control characters");
}
return trimmed;
}
private static string? ValidateDescription(string? description, List<string> errors)
{
var trimmed = description?.Trim();
if (string.IsNullOrEmpty(trimmed))
{
return null;
}
if (trimmed.Length > MaxDescriptionLength)
{
errors.Add($"Description must be {MaxDescriptionLength} characters or fewer");
}
else if (trimmed.Any(c => char.IsControl(c) && c is not ('\n' or '\r' or '\t')))
{
// Line breaks and tabs are legitimate in free text — CR is in the list because a
// <textarea> submits CRLF. A NUL is not legitimate: PostgreSQL cannot store it in a
// text column at all, so blocking it here makes it a 400 instead of a 500.
// This allow-list is a team-lead ruling, not a local preference; Name is
// deliberately stricter because it is a single-line label.
errors.Add("Description must not contain control characters");
}
return trimmed;
}
}
@@ -0,0 +1,973 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Media.JoshHeaps.Net.Services;
/// <summary>
/// Outcome of validating a breadboard circuit document. Errors are terse
/// machine-readable "path:reason" tokens that are safe to return to the client:
/// they never echo unbounded user input, file paths, or exception detail.
/// </summary>
public sealed record BreadboardValidationResult(bool IsValid, IReadOnlyList<string> Errors)
{
public static BreadboardValidationResult Ok() => new(true, Array.Empty<string>());
public static BreadboardValidationResult Fail(IReadOnlyList<string> errors) => new(false, errors);
}
/// <summary>
/// Server-side structural validation of the circuit JSONB document (schema v1)
/// before persistence. Never throws for expected-invalid input — malformed JSON,
/// wrong types, missing fields and out-of-range values all come back as errors.
/// Stateless and thread-safe.
/// </summary>
public class BreadboardValidator(ILogger<BreadboardValidator> logger)
{
/// <summary>Only schema version 1 exists in milestone 1.</summary>
public const int SupportedVersion = 1;
/// <summary>Hard cap on the raw UTF-8 size of the circuit document (2 MB, per the team contract).</summary>
public const int MaxCircuitBytes = 2 * 1024 * 1024;
/// <summary>A realistic desk holds a handful of boards; 50 is generous and bounds net extraction cost.</summary>
public const int MaxBoards = 50;
/// <summary>Team amendment A11: pinned to the JS shared/ constants. Higher counts could exceed the 2 MB byte cap.</summary>
public const int MaxComponents = 3000;
/// <summary>Team amendment A11: pinned to the JS shared/ constants, and bounds union-find work in the engine.</summary>
public const int MaxWires = 10000;
/// <summary>Per the schema contract: any string up to 40 chars.</summary>
public const int MaxUidLength = 40;
/// <summary>The deepest legal path in schema v1 is 6 (circuit > components > component > props > to > value), so 8 is ample.</summary>
public const int MaxJsonDepth = 8;
/// <summary>No schema string is meaningfully longer than this; keeps a multi-megabyte "color" from being compared or echoed.</summary>
public const int MaxStringLength = 64;
/// <summary>Cap on returned errors so a hostile document cannot produce a huge response.</summary>
public const int MaxErrors = 50;
/// <summary>Full-size 830-point board: 63 columns in the main grid.</summary>
public const int MainColumnMin = 1;
public const int MainColumnMax = 63;
/// <summary>Each power rail exposes 50 tie points.</summary>
public const int RailIndexMin = 1;
public const int RailIndexMax = 50;
/// <summary>Board canvas positions are world-space pixels; anything beyond this is nonsense.</summary>
public const double MaxBoardCoordinate = 1_000_000d;
/// <summary>Resistance must be a positive, finite, physically plausible value.</summary>
public const double MaxResistorOhms = 1e9;
// Column footprints, mirrored from the frontend's shared/component-pins.js so a document
// the client accepts is a document the server accepts.
private const int ChipColumnSpan = 7; // 14-pin DIP
private const int DipSwitchColumnSpan = 8; // 16-pin DIP
private const int PushButtonColumnSpan = 3;
private const int TransistorColumnSpan = 3; // TO-92, three legs one column apart
private const int DipSwitchPositions = 8;
private static readonly Regex UidPattern = new($@"^[A-Za-z0-9_.:-]{{1,{MaxUidLength}}}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex HexColorPattern = new(@"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex SafeNamePattern = new(@"^[A-Za-z0-9_]{1,32}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly HashSet<string> TopLevelKeys = new(StringComparer.Ordinal) { "version", "boards", "components", "wires" };
private static readonly HashSet<string> BoardKeys = new(StringComparer.Ordinal) { "uid", "x", "y" };
private static readonly HashSet<string> ComponentKeys = new(StringComparer.Ordinal) { "uid", "type", "anchor", "orient", "props" };
private static readonly HashSet<string> WireKeys = new(StringComparer.Ordinal) { "uid", "from", "to", "color" };
private static readonly HashSet<string> MainHoleKeys = new(StringComparer.Ordinal) { "board", "kind", "col", "row" };
private static readonly HashSet<string> RailHoleKeys = new(StringComparer.Ordinal) { "board", "kind", "rail", "index" };
private static readonly HashSet<string> MainRows = new(StringComparer.Ordinal) { "a", "b", "c", "d", "e", "f", "g", "h", "i", "j" };
/// <summary>Pin 1 of a gap-straddling package sits in one of the two rows flanking the centre channel.</summary>
private static readonly HashSet<string> GapAnchorRows = new(StringComparer.Ordinal) { "e", "f" };
private static readonly HashSet<string> RailNames = new(StringComparer.Ordinal) { "topPlus", "topMinus", "bottomPlus", "bottomMinus" };
private static readonly HashSet<string> Orientations = new(StringComparer.Ordinal) { "up", "down", "left", "right" };
/// <summary>A DIP package cannot straddle the gap vertically, so it has exactly two orientations 180 degrees apart.</summary>
private static readonly HashSet<string> PackageOrientations = new(StringComparer.Ordinal) { "left", "right" };
private static readonly HashSet<string> SupplySides = new(StringComparer.Ordinal) { "top", "bottom" };
private static readonly HashSet<string> NamedColors = new(StringComparer.Ordinal)
{
"red", "green", "blue", "yellow", "orange", "white", "amber", "purple", "black", "gray"
};
private static readonly HashSet<string> LedColors = new(StringComparer.Ordinal)
{
"red", "green", "blue", "yellow", "orange", "white", "amber", "purple"
};
private static readonly HashSet<string> DipChipTypes = new(StringComparer.Ordinal)
{
"74HC00", "74HC02", "74HC04", "74HC08", "74HC32", "74HC86", "74HC30"
};
private static readonly HashSet<string> ComponentTypes = new(StringComparer.Ordinal)
{
"led", "resistor", "diode", "npn", "pnp", "nmos", "pmos",
"pushButton", "dipSwitch8", "powerSupply5V",
"74HC00", "74HC02", "74HC04", "74HC08", "74HC32", "74HC86", "74HC30"
};
private static readonly HashSet<string> LedPropKeys = new(StringComparer.Ordinal) { "color" };
private static readonly HashSet<string> ResistorPropKeys = new(StringComparer.Ordinal) { "ohms", "to" };
private static readonly HashSet<string> DipSwitchPropKeys = new(StringComparer.Ordinal) { "on" };
private static readonly HashSet<string> SupplyPropKeys = new(StringComparer.Ordinal) { "board", "side" };
private static readonly HashSet<string> NoPropKeys = new(StringComparer.Ordinal);
/// <summary>
/// Validates the raw circuit JSON exactly as it will be persisted. This is the
/// only entry point, so the byte-size cap can never be bypassed.
/// </summary>
public BreadboardValidationResult Validate(string? circuitJson)
{
try
{
if (string.IsNullOrWhiteSpace(circuitJson))
{
return BreadboardValidationResult.Fail(["circuit:missing"]);
}
// Measured on UTF-8 bytes, before any parsing work is done.
if (Encoding.UTF8.GetByteCount(circuitJson) > MaxCircuitBytes)
{
return BreadboardValidationResult.Fail(["circuit:exceeds_max_size"]);
}
JsonDocument document;
try
{
document = JsonDocument.Parse(circuitJson, new JsonDocumentOptions
{
MaxDepth = MaxJsonDepth,
CommentHandling = JsonCommentHandling.Disallow,
AllowTrailingCommas = false
});
}
catch (JsonException)
{
return BreadboardValidationResult.Fail(["circuit:malformed_json"]);
}
using (document)
{
var errors = new ErrorList();
ValidateDocument(document.RootElement, errors);
return errors.HasErrors
? BreadboardValidationResult.Fail(errors.Build())
: BreadboardValidationResult.Ok();
}
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected failure validating breadboard circuit document");
return BreadboardValidationResult.Fail(["circuit:validation_failed"]);
}
}
private static void ValidateDocument(JsonElement circuit, ErrorList errors)
{
if (circuit.ValueKind != JsonValueKind.Object)
{
errors.Add("circuit", "not_an_object");
return;
}
CheckKeys(circuit, "circuit", TopLevelKeys, errors);
ValidateVersion(circuit, errors);
// uids are unique across the whole document, so a single set covers all three collections.
var allUids = new HashSet<string>(StringComparer.Ordinal);
var boardUids = ValidateBoards(circuit, allUids, errors);
ValidateComponents(circuit, boardUids, allUids, errors);
ValidateWires(circuit, boardUids, allUids, errors);
}
private static void ValidateVersion(JsonElement circuit, ErrorList errors)
{
if (!circuit.TryGetProperty("version", out var version))
{
errors.Add("version", "missing");
return;
}
if (version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out var value) || value != SupportedVersion)
{
errors.Add("version", "unsupported");
}
}
private static HashSet<string> ValidateBoards(JsonElement circuit, HashSet<string> allUids, ErrorList errors)
{
var boardUids = new HashSet<string>(StringComparer.Ordinal);
if (!TryGetArray(circuit, "boards", MaxBoards, errors, out var boards))
{
return boardUids;
}
var index = 0;
foreach (var board in boards.EnumerateArray())
{
var path = $"boards[{index++}]";
if (board.ValueKind != JsonValueKind.Object)
{
errors.Add(path, "not_an_object");
continue;
}
CheckKeys(board, path, BoardKeys, errors);
var uid = ReadUid(board, path, allUids, errors);
if (uid is not null)
{
boardUids.Add(uid);
}
ReadCoordinate(board, "x", path, errors);
ReadCoordinate(board, "y", path, errors);
}
return boardUids;
}
private static void ValidateComponents(JsonElement circuit, HashSet<string> boardUids, HashSet<string> allUids, ErrorList errors)
{
if (!TryGetArray(circuit, "components", MaxComponents, errors, out var components))
{
return;
}
// One supply per rail pair: two supplies on the same pair is a contradiction the
// engine would have to resolve as spurious contention.
var supplySlots = new HashSet<(string Board, string Side)>();
var index = 0;
foreach (var component in components.EnumerateArray())
{
var path = $"components[{index++}]";
if (component.ValueKind != JsonValueKind.Object)
{
errors.Add(path, "not_an_object");
continue;
}
CheckKeys(component, path, ComponentKeys, errors);
ReadUid(component, path, allUids, errors);
var type = ReadEnum(component, "type", path, ComponentTypes, errors, required: true);
if (type is null)
{
continue;
}
ValidateComponentByType(component, path, type, boardUids, supplySlots, errors);
}
}
/// <summary>
/// Every branch must account for `orient`, either by validating it or by rejecting it —
/// a key that is in <see cref="ComponentKeys"/> but unvalidated is an arbitrary
/// attacker-controlled slot in the stored document, which is exactly what the
/// unknown-property allowlist exists to prevent.
/// </summary>
private static void ValidateComponentByType(
JsonElement component,
string path,
string type,
HashSet<string> boardUids,
HashSet<(string Board, string Side)> supplySlots,
ErrorList errors)
{
switch (type)
{
case "led":
ValidateTwoHoleSpan(component, path, boardUids, errors);
ValidateLedProps(component, path, errors);
break;
case "diode":
ValidateTwoHoleSpan(component, path, boardUids, errors);
ValidateEmptyProps(component, path, errors);
break;
case "npn":
case "pnp":
case "nmos":
case "pmos":
ValidateInlinePackage(component, path, TransistorColumnSpan, boardUids, errors);
ValidateEmptyProps(component, path, errors);
break;
case "resistor":
// The second terminal is explicit in props.to, so orientation is derived, not stored.
ReadAnchor(component, path, boardUids, errors, required: true);
RequireAbsent(component, "orient", path, errors);
ValidateResistorProps(component, path, boardUids, errors);
break;
case "pushButton":
ValidateGapPackage(component, path, PushButtonColumnSpan, boardUids, errors);
ValidateEmptyProps(component, path, errors);
break;
case "dipSwitch8":
ValidateGapPackage(component, path, DipSwitchColumnSpan, boardUids, errors);
ValidateDipSwitchProps(component, path, errors);
break;
case "powerSupply5V":
// Anchorless: both pins are derived from props, so an anchor would be meaningless data.
RequireAbsent(component, "anchor", path, errors);
RequireAbsent(component, "orient", path, errors);
ValidateSupplyProps(component, path, boardUids, supplySlots, errors);
break;
default:
// Reachable only if a type is added to ComponentTypes without a case here:
// a registry/dispatch drift then fails closed instead of skipping validation.
if (DipChipTypes.Contains(type))
{
ValidateGapPackage(component, path, ChipColumnSpan, boardUids, errors);
ValidateEmptyProps(component, path, errors);
}
else
{
errors.Add($"{path}.type", "unsupported");
}
break;
}
}
/// <summary>
/// A two-legged part spanning two holes: the anchor is the first terminal (an LED's anode,
/// a diode's anode) and the second sits one hole away in the orient direction. A leg in a
/// power rail is legal — electrically useless if both legs share a rail, but a real
/// breadboard allows it — so only the footprint is checked.
/// </summary>
private static void ValidateTwoHoleSpan(JsonElement component, string path, HashSet<string> boardUids, ErrorList errors)
{
var orient = ReadEnum(component, "orient", path, Orientations, errors, required: true);
var anchor = ReadAnchor(component, path, boardUids, errors, required: true);
if (anchor is null || orient is null)
{
return;
}
if (orient is "up" or "down")
{
// Vertical travel moves one row and may legitimately cross the centre channel. A
// rail has no rows, so the far leg of an up/down part anchored there has nowhere to go.
if (anchor.Kind != "main")
{
errors.Add($"{path}.orient", "not_valid_on_rail");
return;
}
// Rows a..j read top to bottom, so "up" decreases the row index — the same sign
// convention that maps "left"/"right" to -1/+1 on the column axis. Mirrors
// shared/board-geometry.js (team amendment A12 pins it as contract).
var farRow = (anchor.Row[0] - 'a') + (orient == "up" ? -1 : 1);
if (farRow < 0 || farRow >= MainRows.Count)
{
errors.Add($"{path}.anchor", "footprint_off_board");
}
return;
}
var farPosition = anchor.Position + (orient == "left" ? -1 : 1);
var min = anchor.Kind == "main" ? MainColumnMin : RailIndexMin;
var max = anchor.Kind == "main" ? MainColumnMax : RailIndexMax;
if (farPosition < min || farPosition > max)
{
errors.Add($"{path}.anchor", "footprint_off_board");
}
}
/// <summary>
/// A three-legged inline package (TO-92). Its legs run along one row of the main grid, one
/// column apart, so only "left" and "right" leave each leg in a strip of its own — a
/// vertical placement would put two legs in the same five-hole strip, and a power rail is
/// one continuous strip, which is why an anchor there is rejected outright.
/// </summary>
private static void ValidateInlinePackage(
JsonElement component,
string path,
int columnSpan,
HashSet<string> boardUids,
ErrorList errors)
{
var orient = ReadEnum(component, "orient", path, PackageOrientations, errors, required: true);
var anchor = ReadAnchor(component, path, boardUids, errors, required: true);
if (anchor is null || orient is null)
{
return;
}
if (anchor.Kind != "main")
{
errors.Add($"{path}.anchor.kind", "must_be_main");
return;
}
var lastColumn = anchor.Position + ((orient == "left" ? -1 : 1) * (columnSpan - 1));
if (lastColumn < MainColumnMin || lastColumn > MainColumnMax)
{
errors.Add($"{path}.anchor.col", "package_off_board");
}
}
/// <summary>
/// Packages that straddle the centre gap. Pin 1 sits in row "e" or "f" on the main grid
/// and the package runs across <paramref name="columnSpan"/> columns: row "e" reads left
/// to right, row "f" is the same package rotated 180 degrees and reads right to left.
/// Every pin must land on a real hole.
/// </summary>
private static void ValidateGapPackage(
JsonElement component,
string path,
int columnSpan,
HashSet<string> boardUids,
ErrorList errors)
{
var orient = ReadEnum(component, "orient", path, PackageOrientations, errors, required: true);
var anchor = ReadAnchor(component, path, boardUids, errors, required: true);
if (anchor is null)
{
return;
}
if (anchor.Kind != "main")
{
errors.Add($"{path}.anchor.kind", "must_be_main");
return;
}
if (!GapAnchorRows.Contains(anchor.Row))
{
errors.Add($"{path}.anchor.row", "must_straddle_gap");
return;
}
// Orientation and anchor row are redundant by construction, so a document carrying
// both is able to encode a contradiction. Reject that rather than pick a winner.
var step = anchor.Row == "e" ? 1 : -1;
if (orient is not null && orient != (step == 1 ? "right" : "left"))
{
errors.Add($"{path}.orient", "contradicts_anchor_row");
}
var lastColumn = anchor.Position + (step * (columnSpan - 1));
if (lastColumn < MainColumnMin || lastColumn > MainColumnMax)
{
errors.Add($"{path}.anchor.col", "package_off_board");
}
}
/// <summary>
/// Rejects a key that carries no meaning for this component type, so it cannot become an
/// unvalidated slot in the stored document. Presence is what is rejected, not just a
/// meaningful value: an explicit JSON null is a present property, and the editor's schema
/// refuses to load one, so accepting it here would persist a document the client cannot open.
/// </summary>
private static void RequireAbsent(JsonElement component, string name, string path, ErrorList errors)
{
if (component.TryGetProperty(name, out _))
{
errors.Add($"{path}.{name}", "not_applicable");
}
}
/// <summary>Saved switch positions, so a bank survives a reload. Optional; exactly eight booleans when present.</summary>
private static void ValidateDipSwitchProps(JsonElement component, string path, ErrorList errors)
{
if (!ReadProps(component, path, DipSwitchPropKeys, errors, required: false, out var props))
{
return;
}
if (!props.TryGetProperty("on", out var positions) || positions.ValueKind == JsonValueKind.Null)
{
return;
}
var onPath = $"{path}.props.on";
if (positions.ValueKind != JsonValueKind.Array)
{
errors.Add(onPath, "not_an_array");
return;
}
if (positions.GetArrayLength() != DipSwitchPositions)
{
errors.Add(onPath, "wrong_length");
return;
}
var index = 0;
foreach (var position in positions.EnumerateArray())
{
if (position.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
errors.Add($"{onPath}[{index}]", "not_a_boolean");
}
index++;
}
}
private static void ValidateLedProps(JsonElement component, string path, ErrorList errors)
{
if (!ReadProps(component, path, LedPropKeys, errors, required: true, out var props))
{
return;
}
ReadColor(props, "color", $"{path}.props", LedColors, errors, required: true);
}
private static void ValidateResistorProps(JsonElement component, string path, HashSet<string> boardUids, ErrorList errors)
{
if (!ReadProps(component, path, ResistorPropKeys, errors, required: true, out var props))
{
return;
}
var propsPath = $"{path}.props";
if (!props.TryGetProperty("ohms", out var ohms))
{
errors.Add($"{propsPath}.ohms", "missing");
}
else if (ohms.ValueKind != JsonValueKind.Number || !ohms.TryGetDouble(out var value) || !double.IsFinite(value))
{
errors.Add($"{propsPath}.ohms", "not_a_number");
}
else if (value <= 0 || value > MaxResistorOhms)
{
errors.Add($"{propsPath}.ohms", "out_of_range");
}
// A resistor spans two holes and may reach across boards or onto a rail.
if (!props.TryGetProperty("to", out var to))
{
errors.Add($"{propsPath}.to", "missing");
}
else
{
ValidateHoleRef(to, $"{propsPath}.to", boardUids, errors);
}
}
private static void ValidateSupplyProps(
JsonElement component,
string path,
HashSet<string> boardUids,
HashSet<(string Board, string Side)> supplySlots,
ErrorList errors)
{
if (!ReadProps(component, path, SupplyPropKeys, errors, required: true, out var props))
{
return;
}
var propsPath = $"{path}.props";
var board = ValidateBoardReference(props, "board", propsPath, boardUids, errors);
var side = ReadEnum(props, "side", propsPath, SupplySides, errors, required: true);
if (board is not null && side is not null && !supplySlots.Add((board, side)))
{
errors.Add(propsPath, "rail_pair_already_supplied");
}
}
private static void ValidateEmptyProps(JsonElement component, string path, ErrorList errors)
{
ReadProps(component, path, NoPropKeys, errors, required: false, out _);
}
private static void ValidateWires(JsonElement circuit, HashSet<string> boardUids, HashSet<string> allUids, ErrorList errors)
{
if (!TryGetArray(circuit, "wires", MaxWires, errors, out var wires))
{
return;
}
var index = 0;
foreach (var wire in wires.EnumerateArray())
{
var path = $"wires[{index++}]";
if (wire.ValueKind != JsonValueKind.Object)
{
errors.Add(path, "not_an_object");
continue;
}
CheckKeys(wire, path, WireKeys, errors);
ReadUid(wire, path, allUids, errors);
foreach (var end in new[] { "from", "to" })
{
if (!wire.TryGetProperty(end, out var hole))
{
errors.Add($"{path}.{end}", "missing");
}
else
{
ValidateHoleRef(hole, $"{path}.{end}", boardUids, errors);
}
}
ReadColor(wire, "color", path, NamedColors, errors, required: false);
}
}
private static ParsedHole? ReadAnchor(JsonElement component, string path, HashSet<string> boardUids, ErrorList errors, bool required)
{
if (!component.TryGetProperty("anchor", out var anchor) || anchor.ValueKind == JsonValueKind.Null)
{
if (required)
{
errors.Add($"{path}.anchor", "missing");
}
return null;
}
return ValidateHoleRef(anchor, $"{path}.anchor", boardUids, errors);
}
private static ParsedHole? ValidateHoleRef(JsonElement hole, string path, HashSet<string> boardUids, ErrorList errors)
{
if (hole.ValueKind != JsonValueKind.Object)
{
errors.Add(path, "not_an_object");
return null;
}
ValidateBoardReference(hole, "board", path, boardUids, errors);
if (!hole.TryGetProperty("kind", out var kindElement))
{
errors.Add($"{path}.kind", "missing");
return null;
}
if (kindElement.ValueKind != JsonValueKind.String)
{
errors.Add($"{path}.kind", "not_a_string");
return null;
}
switch (kindElement.GetString())
{
case "main":
{
CheckKeys(hole, path, MainHoleKeys, errors);
var col = ReadInt(hole, "col", path, MainColumnMin, MainColumnMax, errors);
var row = ReadEnum(hole, "row", path, MainRows, errors, required: true);
return col is null || row is null ? null : new ParsedHole("main", col.Value, row);
}
case "rail":
{
CheckKeys(hole, path, RailHoleKeys, errors);
var rail = ReadEnum(hole, "rail", path, RailNames, errors, required: true);
var index = ReadInt(hole, "index", path, RailIndexMin, RailIndexMax, errors);
return rail is null || index is null ? null : new ParsedHole("rail", index.Value, rail);
}
default:
errors.Add($"{path}.kind", "unsupported");
return null;
}
}
private static bool TryGetArray(JsonElement circuit, string name, int maxLength, ErrorList errors, out JsonElement array)
{
array = default;
if (!circuit.TryGetProperty(name, out var element) || element.ValueKind == JsonValueKind.Null)
{
return false;
}
if (element.ValueKind != JsonValueKind.Array)
{
errors.Add(name, "not_an_array");
return false;
}
if (element.GetArrayLength() > maxLength)
{
errors.Add(name, "too_many");
return false;
}
array = element;
return true;
}
private static bool ReadProps(JsonElement component, string path, IReadOnlySet<string> allowed, ErrorList errors, bool required, out JsonElement props)
{
if (!component.TryGetProperty("props", out props) || props.ValueKind == JsonValueKind.Null)
{
if (required)
{
errors.Add($"{path}.props", "missing");
}
return false;
}
if (props.ValueKind != JsonValueKind.Object)
{
errors.Add($"{path}.props", "not_an_object");
return false;
}
CheckKeys(props, $"{path}.props", allowed, errors);
return true;
}
private static string? ReadUid(JsonElement element, string path, HashSet<string> allUids, ErrorList errors)
{
var uidPath = $"{path}.uid";
if (!element.TryGetProperty("uid", out var uidElement))
{
errors.Add(uidPath, "missing");
return null;
}
if (uidElement.ValueKind != JsonValueKind.String)
{
errors.Add(uidPath, "not_a_string");
return null;
}
var uid = uidElement.GetString()!;
if (uid.Length > MaxUidLength)
{
errors.Add(uidPath, "too_long");
return null;
}
if (!UidPattern.IsMatch(uid))
{
errors.Add(uidPath, "invalid_format");
return null;
}
if (!allUids.Add(uid))
{
errors.Add(uidPath, "duplicate");
return null;
}
return uid;
}
private static void ReadCoordinate(JsonElement element, string name, string path, ErrorList errors)
{
var coordinatePath = $"{path}.{name}";
if (!element.TryGetProperty(name, out var value))
{
errors.Add(coordinatePath, "missing");
return;
}
if (value.ValueKind != JsonValueKind.Number || !value.TryGetDouble(out var number) || !double.IsFinite(number))
{
errors.Add(coordinatePath, "not_a_number");
return;
}
if (Math.Abs(number) > MaxBoardCoordinate)
{
errors.Add(coordinatePath, "out_of_range");
}
}
private static int? ReadInt(JsonElement element, string name, string path, int min, int max, ErrorList errors)
{
var intPath = $"{path}.{name}";
if (!element.TryGetProperty(name, out var value))
{
errors.Add(intPath, "missing");
return null;
}
if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt32(out var number))
{
errors.Add(intPath, "not_an_integer");
return null;
}
if (number < min || number > max)
{
errors.Add(intPath, "out_of_range");
return null;
}
return number;
}
private static string? ReadEnum(JsonElement element, string name, string path, IReadOnlySet<string> allowed, ErrorList errors, bool required)
{
var enumPath = $"{path}.{name}";
if (!element.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null)
{
if (required)
{
errors.Add(enumPath, "missing");
}
return null;
}
if (value.ValueKind != JsonValueKind.String)
{
errors.Add(enumPath, "not_a_string");
return null;
}
var text = value.GetString()!;
if (text.Length > MaxStringLength || !allowed.Contains(text))
{
errors.Add(enumPath, "unsupported");
return null;
}
return text;
}
private static void ReadColor(JsonElement element, string name, string path, IReadOnlySet<string> namedColors, ErrorList errors, bool required)
{
var colorPath = $"{path}.{name}";
if (!element.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null)
{
if (required)
{
errors.Add(colorPath, "missing");
}
return;
}
if (value.ValueKind != JsonValueKind.String)
{
errors.Add(colorPath, "not_a_string");
return;
}
var color = value.GetString()!;
if (color.Length > MaxStringLength || (!namedColors.Contains(color) && !HexColorPattern.IsMatch(color)))
{
errors.Add(colorPath, "unsupported");
}
}
/// <summary>Reads a board uid reference and confirms it names a board declared in this document.</summary>
private static string? ValidateBoardReference(JsonElement element, string name, string path, HashSet<string> boardUids, ErrorList errors)
{
var boardPath = $"{path}.{name}";
if (!element.TryGetProperty(name, out var board))
{
errors.Add(boardPath, "missing");
return null;
}
if (board.ValueKind != JsonValueKind.String)
{
errors.Add(boardPath, "not_a_string");
return null;
}
var uid = board.GetString()!;
if (uid.Length > MaxUidLength || !boardUids.Contains(uid))
{
errors.Add(boardPath, "unknown_board");
return null;
}
return uid;
}
/// <summary>
/// Rejects properties outside the schema and duplicate JSON keys (System.Text.Json
/// tolerates duplicates, which would otherwise let a document smuggle a second value
/// past whichever occurrence the reader picks).
/// </summary>
private static void CheckKeys(JsonElement element, string path, IReadOnlySet<string> allowed, ErrorList errors)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var property in element.EnumerateObject())
{
if (!allowed.Contains(property.Name))
{
errors.Add($"{path}.{SafeName(property.Name)}", "unknown_property");
}
else if (!seen.Add(property.Name))
{
errors.Add($"{path}.{property.Name}", "duplicate_property");
}
}
}
/// <summary>Unknown property names come from untrusted input, so only echo them when they are plainly safe.</summary>
private static string SafeName(string name) => SafeNamePattern.IsMatch(name) ? name : "?";
/// <summary>
/// A hole reference that passed validation. <paramref name="Position"/> is the column for
/// a main-grid hole and the tie-point index for a rail hole; <paramref name="Row"/> is the
/// row letter or the rail name respectively.
/// </summary>
private sealed record ParsedHole(string Kind, int Position, string Row);
private sealed class ErrorList
{
private readonly List<string> _errors = [];
private bool _truncated;
public bool HasErrors => _errors.Count > 0;
public void Add(string path, string reason)
{
if (_errors.Count >= MaxErrors)
{
_truncated = true;
return;
}
_errors.Add($"{path}:{reason}");
}
public IReadOnlyList<string> Build() => _truncated ? [.. _errors, "errors:truncated"] : _errors;
}
}
+1 -1
View File
@@ -107,7 +107,7 @@ If you didn't create an account, you can safely ignore this email.
try
{
var appUrl = config["AppUrl"] ?? "http://localhost:5000";
var resetUrl = $"{appUrl}/ResetPassword?token={resetToken}";
var resetUrl = $"{appUrl}/LoginHelp?token={resetToken}";
var message = new MimeMessage();
message.From.Add(new MailboxAddress(
@@ -0,0 +1,815 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
namespace Media.JoshHeaps.Net.Services;
public class MedicalAiService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<MedicalAiService> _logger;
private readonly Channel<long> _queue = Channel.CreateUnbounded<long>();
private DateTime? _rateLimitResetTime;
private const string HaikuModel = "claude-haiku-4-5-20251001";
private const string SonnetModel = "claude-sonnet-4-5-20250929";
public MedicalAiService(IServiceScopeFactory scopeFactory, ILogger<MedicalAiService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
_ = Task.Run(ProcessQueueAsync);
}
public void EnqueueProcessing(long documentId)
{
_queue.Writer.TryWrite(documentId);
_logger.LogInformation("Enqueued document {DocumentId} for AI processing", documentId);
}
private async Task ProcessQueueAsync()
{
await foreach (var documentId in _queue.Reader.ReadAllAsync())
{
if (_rateLimitResetTime.HasValue && _rateLimitResetTime.Value > DateTime.UtcNow)
{
var delay = _rateLimitResetTime.Value - DateTime.UtcNow;
_logger.LogInformation("Rate limited, waiting {Delay} until {ResetTime}", delay, _rateLimitResetTime.Value);
await Task.Delay(delay);
_rateLimitResetTime = null;
}
try
{
using var scope = _scopeFactory.CreateScope();
var medicalDocsService = scope.ServiceProvider.GetRequiredService<MedicalDocsService>();
await ProcessDocumentAsync(documentId, medicalDocsService);
}
catch (Exception ex)
{
_logger.LogError(ex, "AI processing failed for document {DocumentId}", documentId);
}
}
}
private async Task ProcessDocumentAsync(long documentId, MedicalDocsService medicalDocsService)
{
_logger.LogInformation("Starting AI processing for document {DocumentId}", documentId);
var doc = await medicalDocsService.GetDocumentByIdAsync(documentId);
if (doc == null)
{
_logger.LogWarning("Document {DocumentId} not found for AI processing", documentId);
return;
}
var aiResponses = new Dictionary<string, object>();
// Step 1: Text extraction (Haiku)
string? extractedText = null;
if (doc.DocumentType == "note")
{
extractedText = doc.Description;
}
else if (doc.DocumentType == "file")
{
var fileData = await medicalDocsService.GetDecryptedDocumentDataAsync(documentId);
if (fileData != null && doc.MimeType != null)
{
extractedText = await ExtractTextAsync(fileData, doc.MimeType, doc.FileName);
if (IsRateLimited) { _queue.Writer.TryWrite(documentId); return; }
if (extractedText != null)
aiResponses["extraction"] = new { model = HaikuModel, text = extractedText };
}
}
if (string.IsNullOrWhiteSpace(extractedText))
{
_logger.LogWarning("No text could be extracted from document {DocumentId}", documentId);
extractedText = doc.Title ?? doc.FileName ?? "";
}
// Step 2: Classification + tagging + doctor name (Haiku)
string? classification = doc.Classification;
List<string> tags = [];
string? doctorName = null;
List<string> conditionNames = [];
try
{
var classResult = await ClassifyAndTagAsync(extractedText);
if (IsRateLimited) { _queue.Writer.TryWrite(documentId); return; }
if (classResult != null)
{
classification = classResult.Classification ?? classification;
tags = classResult.Tags ?? [];
doctorName = classResult.DoctorName;
conditionNames = classResult.ConditionNames ?? [];
aiResponses["classification"] = classResult;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Classification failed for document {DocumentId}", documentId);
}
// Step 3: Associate doctor to document (ALL types)
long? doctorId = null;
if (!string.IsNullOrWhiteSpace(doctorName))
{
try
{
var doctor = await medicalDocsService.FindOrCreateDoctorByNameAsync(doc.PersonId, doctorName.Trim(), this);
doctorId = doctor?.Id;
}
catch (Exception ex)
{
_logger.LogError(ex, "Doctor association failed for document {DocumentId}", documentId);
}
}
// Step 3b: Associate conditions to document (ALL types)
if (conditionNames.Count > 0)
{
try
{
await medicalDocsService.AssignConditionsFromAiAsync(
documentId, doc.PersonId, conditionNames, this);
}
catch (Exception ex)
{
_logger.LogError(ex, "Condition association failed for document {DocumentId}", documentId);
}
}
// Step 4: Branch on classification for targeted extraction
var effectiveClassification = classification ?? "other";
BillingExtractionResult? billingData = null;
PrescriptionExtractionResult? prescriptionData = null;
if (effectiveClassification is "receipt" or "insurance")
{
try
{
billingData = await ExtractBillingDataAsync(extractedText, effectiveClassification);
if (IsRateLimited) { _queue.Writer.TryWrite(documentId); return; }
if (billingData != null)
aiResponses["billing"] = billingData;
}
catch (Exception ex)
{
_logger.LogError(ex, "Billing extraction failed for document {DocumentId}", documentId);
}
}
else if (effectiveClassification == "prescription")
{
try
{
prescriptionData = await ExtractPrescriptionDataAsync(extractedText);
if (IsRateLimited) { _queue.Writer.TryWrite(documentId); return; }
if (prescriptionData != null)
aiResponses["prescription"] = prescriptionData;
}
catch (Exception ex)
{
_logger.LogError(ex, "Prescription extraction failed for document {DocumentId}", documentId);
}
}
// Step 5: Sanitize billing data
if (billingData != null)
SanitizeExtractedBills(billingData);
// Step 6: Persist results
var rawResponse = JsonSerializer.Serialize(aiResponses, JsonOpts);
await medicalDocsService.UpdateAiResultsAsync(documentId, extractedText, classification, rawResponse, doctorId);
if (tags.Count > 0)
await medicalDocsService.AddTagsAsync(documentId, tags);
// Clean up prior AI bills for this document (handles re-processing)
await medicalDocsService.CleanupAiBillsForDocumentAsync(documentId);
// Handle prescription documents
if (prescriptionData?.PrescriptionInfo is { MedicationName: not null } rxInfo)
{
try
{
await medicalDocsService.AddPrescriptionFromAiAsync(
documentId, doc.PersonId, rxInfo, doctorName, this);
}
catch (Exception ex)
{
_logger.LogError(ex, "AI prescription processing failed for document {DocumentId}", documentId);
}
}
// Bill processing (billing documents only)
if (billingData?.Bills is { Count: > 0 })
await medicalDocsService.AddBillsFromAiAsync(documentId, doc.PersonId, billingData.Bills, billingData.Payments, this);
_logger.LogInformation("AI processing complete for document {DocumentId}: classification={Classification}, tags={TagCount}, bills={BillCount}",
documentId, classification, tags.Count, billingData?.Bills?.Count ?? 0);
}
private bool IsRateLimited => _rateLimitResetTime.HasValue && _rateLimitResetTime.Value > DateTime.UtcNow;
private async Task<string?> ExtractTextAsync(byte[] fileData, string mimeType, string? fileName)
{
_logger.LogInformation("Extracting text via CLI, mimeType={MimeType}, size={Size}KB", mimeType, fileData.Length / 1024);
var isImage = mimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
var isPdf = mimeType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase);
if (!isImage && !isPdf)
{
_logger.LogInformation("Unsupported mime type {MimeType} for text extraction", mimeType);
return null;
}
// Write decrypted file to temp path so the CLI can read it
var ext = isPdf ? ".pdf" : Path.GetExtension(fileName ?? ".png");
var tempPath = Path.Combine(Path.GetTempPath(), $"meddoc_{Guid.NewGuid()}{ext}");
try
{
await File.WriteAllBytesAsync(tempPath, fileData);
var systemPrompt = "Extract all text from this medical document. Include all visible text, numbers, dates, names, and amounts. Preserve the structure and formatting as much as possible. If the document is handwritten, do your best to transcribe it. Return only the extracted text, no commentary.";
var userPrompt = $"Please read and extract all text from the file at: {tempPath}";
return await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel, allowReadTool: true);
}
finally
{
try { File.Delete(tempPath); } catch { /* cleanup best-effort */ }
}
}
private async Task<ClassificationResult?> ClassifyAndTagAsync(string text)
{
_logger.LogInformation("Classifying and tagging via Haiku CLI");
var truncatedText = text.Length > 4000 ? text[..4000] : text;
var systemPrompt = @"You are a medical document classifier. Analyze the document text and return a JSON object with:
- ""classification"": one of: receipt, lab_result, prescription, imaging, dr_note, insurance, referral, discharge, recording, other
- ""tags"": array of relevant tag strings (lowercase, e.g. ""blood work"", ""cardiology"", ""annual physical"", ""copay"")
- ""doctorName"": string or null — the individual doctor/physician name mentioned (e.g. ""Dr. John Smith"" → ""John Smith""). If multiple, pick the primary/treating physician.
- ""conditionNames"": array of medical condition names mentioned or clearly implied (e.g. ""Type 2 Diabetes"", ""Hypertension""). Only include conditions you can confidently identify. Use standard medical terminology. Return empty array if none are obvious.
Return ONLY the JSON object, no other text. If a document is a receipt for an individual prescription, label it as a prescription.";
var userPrompt = $"Analyze this medical document text and classify it.\n\nDocument text:\n{truncatedText}";
var response = await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel);
if (response == null) return null;
var json = ExtractJson(response);
try
{
return JsonSerializer.Deserialize<ClassificationResult>(json, JsonOpts);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse classification response: {Response}", response);
return null;
}
}
private async Task<BillingExtractionResult?> ExtractBillingDataAsync(string text, string classification)
{
_logger.LogInformation("Extracting billing data via Sonnet CLI, classification={Classification}", classification);
var truncatedText = text.Length > 6000 ? text[..6000] : text;
var systemPrompt = @"You are a medical billing data extractor. Extract financial data from this document.
Return a JSON object with:
- ""bills"": array of bill/charge objects, each with: ""totalAmount"" (number, the GROSS total of all positive charges before any payments or credits), ""category"" (string: office_visit, lab, pharmacy, imaging, therapy, hospital, specialist, other), ""billDate"" (string, YYYY-MM-DD or null), ""summary"" (string, brief description of the charge), ""providerName"" (string or null — the billing provider/facility name, e.g. ""Intermountain Healthcare"", ""Walgreens"". This is the entity sending the bill, NOT the individual doctor), ""lineItems"" (array of {""description"": string, ""amount"": number} or null — only POSITIVE charge items)
- ""payments"": array of payment objects, each with: ""amount"" (number, always positive), ""paymentType"" (string: patient_payment, insurance_payment, insurance_adjustment, write_off), ""paymentDate"" (string, YYYY-MM-DD or null), ""description"" (string), ""billIndex"" (number or null, 0-based index into the bills array that this payment applies to)
CRITICAL — line items vs payments:
- Line items are ONLY positive charges that break down what was billed (e.g., ""Vitrectomy: $5,731"", ""Supplies: $7,500"").
- Negative amounts, credits, refunds, or items labeled ""payment"" are PAYMENTS, not line items. Extract them in the ""payments"" array with a positive amount.
Example: a line showing ""Payment: -$25.00"" → extract as a patient_payment of $25 (NOT as a line item of -$25).
- totalAmount should be the sum of POSITIVE charges only (before credits/payments are subtracted). Do not subtract payments from totalAmount.
Guidelines for document types:
- EOBs (Explanation of Benefits): create the bill (total charge) plus insurance_payment and/or insurance_adjustment for what insurance covered, and patient_payment for patient responsibility
- Receipts: create the bill (total charge) plus a patient_payment for the amount paid
- Invoices/statements/estimates: create the bill (total positive charges). If any payment or credit lines appear, extract those as payments.
- Each unique charge should be one bill. Do NOT create separate bills for line items that are part of the same visit/service — sum them into one bill.
- If the document lists individual charges (e.g., line items on a statement like ""exam: $150"", ""blood draw: $30""), include them in the ""lineItems"" array for that bill. The sum of line items should approximate totalAmount.
Only include fields you can confidently extract. If no bills are found, return empty arrays. Return ONLY the JSON object, no other text.";
var userPrompt = $"Classification: {classification}\n\nDocument text:\n{truncatedText}";
var response = await CallClaudeCliAsync(systemPrompt, userPrompt, SonnetModel);
if (response == null) return null;
var json = ExtractJson(response);
try
{
return JsonSerializer.Deserialize<BillingExtractionResult>(json, JsonOpts);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse billing extraction response: {Response}", response);
return null;
}
}
private async Task<PrescriptionExtractionResult?> ExtractPrescriptionDataAsync(string text)
{
_logger.LogInformation("Extracting prescription data via Sonnet CLI");
var truncatedText = text.Length > 6000 ? text[..6000] : text;
var systemPrompt = @"You are a medical prescription data extractor. Extract prescription details from this document.
Return a JSON object with:
- ""prescriptionInfo"": object with ""medicationName"" (string), ""dosage"" (string or null), ""frequency"" (string or null), ""rxNumber"" (the Rx/prescription number, string or null), ""pharmacy"" (pharmacy name e.g. ""Walgreens"", string or null), ""copay"" (number or null, amount paid), ""pickupDate"" (YYYY-MM-DD or null), ""personName"" (patient name, string or null), ""doctorName"" (prescribing doctor name, string or null)
Only include fields you can confidently extract. Return ONLY the JSON object, no other text.";
var userPrompt = $"Document text:\n{truncatedText}";
var response = await CallClaudeCliAsync(systemPrompt, userPrompt, SonnetModel);
if (response == null) return null;
var json = ExtractJson(response);
try
{
return JsonSerializer.Deserialize<PrescriptionExtractionResult>(json, JsonOpts);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse prescription extraction response: {Response}", response);
return null;
}
}
private async Task<string?> CallClaudeCliAsync(string systemPrompt, string userPrompt, string? model = null, bool allowReadTool = false)
{
var combinedPrompt = $"{systemPrompt}\n\n{userPrompt}";
var args = new StringBuilder("-p --output-format text");
if (!string.IsNullOrEmpty(model))
args.Append($" --model {model}");
if (allowReadTool)
args.Append(" --allowedTools Read");
var psi = new ProcessStartInfo
{
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
if (OperatingSystem.IsWindows())
{
psi.FileName = "cmd.exe";
psi.Arguments = $"/c claude {args}";
}
else
{
psi.FileName = "claude";
psi.Arguments = args.ToString();
}
using var process = Process.Start(psi);
if (process == null)
{
_logger.LogError("Failed to start claude CLI process");
return null;
}
// Write prompt to stdin, then close to signal EOF
await process.StandardInput.WriteAsync(combinedPrompt);
process.StandardInput.Close();
// Read stdout/stderr concurrently to avoid deadlocks
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120));
try
{
await process.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
process.Kill();
_logger.LogError("claude CLI timed out after 120 seconds");
return null;
}
var stdout = await stdoutTask;
var stderr = await stderrTask;
if (!string.IsNullOrEmpty(stderr))
{
var resetTime = ParseRateLimitReset(stderr);
if (resetTime.HasValue)
{
_rateLimitResetTime = resetTime.Value;
_logger.LogWarning("Rate limit detected, reset at {ResetTime}", resetTime.Value);
return null;
}
// Log non-rate-limit stderr as debug info
_logger.LogDebug("claude CLI stderr: {Stderr}", stderr);
}
if (process.ExitCode != 0)
{
// Check stdout too for rate limit messages (some CLIs write there)
var resetFromStdout = ParseRateLimitReset(stdout);
if (resetFromStdout.HasValue)
{
_rateLimitResetTime = resetFromStdout.Value;
_logger.LogWarning("Rate limit detected in stdout, reset at {ResetTime}", resetFromStdout.Value);
return null;
}
_logger.LogError("claude CLI exited with code {ExitCode}.\nStderr: {Stderr}\nStdout: {Stdout}", process.ExitCode, stderr, stdout);
return null;
}
return string.IsNullOrWhiteSpace(stdout) ? null : stdout.Trim();
}
private DateTime? ParseRateLimitReset(string output)
{
if (string.IsNullOrEmpty(output)) return null;
// Try ISO timestamp pattern (e.g., 2026-02-09T14:30:00)
var isoMatch = Regex.Match(output, @"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})");
if (isoMatch.Success && DateTime.TryParse(isoMatch.Groups[1].Value, out var isoTime))
{
return isoTime.ToUniversalTime();
}
// Try time pattern (e.g., "reset at 2:30 PM", "try again at 14:30")
var timeMatch = Regex.Match(output, @"(?:reset|try again|available)(?:\s+at)?\s+(\d{1,2}:\d{2}\s*(?:[APap][Mm])?)", RegexOptions.IgnoreCase);
if (timeMatch.Success && DateTime.TryParse(timeMatch.Groups[1].Value, out var parsedTime))
{
// Assume today; if the time has passed, assume tomorrow
var today = DateTime.Today.Add(parsedTime.TimeOfDay);
if (today < DateTime.Now)
today = today.AddDays(1);
return today.ToUniversalTime();
}
// Try "in X minutes" pattern
var minutesMatch = Regex.Match(output, @"(?:in|after)\s+(\d+)\s+minute", RegexOptions.IgnoreCase);
if (minutesMatch.Success && int.TryParse(minutesMatch.Groups[1].Value, out var minutes))
{
return DateTime.UtcNow.AddMinutes(minutes);
}
// Fallback: detect rate limit keywords without a parseable time → wait 15 minutes
if (Regex.IsMatch(output, @"rate.?limit|session.?limit|too many requests|usage limit", RegexOptions.IgnoreCase))
{
return DateTime.UtcNow.AddMinutes(15);
}
return null;
}
private static string ExtractJson(string response)
{
var trimmed = response.Trim();
if (trimmed.StartsWith("```"))
{
var firstNewline = trimmed.IndexOf('\n');
if (firstNewline >= 0)
{
trimmed = trimmed[(firstNewline + 1)..];
var lastFence = trimmed.LastIndexOf("```");
if (lastFence >= 0)
trimmed = trimmed[..lastFence];
}
}
return trimmed.Trim();
}
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
internal static void SanitizeExtractedBills(BillingExtractionResult result)
{
if (result.Bills == null) return;
result.Payments ??= [];
var paymentKeywords = new[] { "payment", "credit", "refund", "adjustment", "write-off", "write off", "discount", "applied" };
var insuranceKeywords = new[] { "insurance", "ins ", "ins.", "aetna", "cigna", "united", "blue cross", "bcbs", "humana", "anthem", "medicare", "medicaid" };
foreach (var bill in result.Bills)
{
if (bill.LineItems == null) continue;
var toRemove = new List<LineItemExtraction>();
foreach (var item in bill.LineItems)
{
var desc = item.Description?.ToLowerInvariant() ?? "";
var isNegative = item.Amount < 0;
var hasPaymentKeyword = paymentKeywords.Any(k => desc.Contains(k));
if (!isNegative && !hasPaymentKeyword) continue;
var isInsurance = insuranceKeywords.Any(k => desc.Contains(k));
var paymentType = isInsurance ? "insurance_payment" : "patient_payment";
if (desc.Contains("adjustment") || desc.Contains("write-off") || desc.Contains("write off"))
paymentType = isInsurance ? "insurance_adjustment" : "write_off";
var billIndex = result.Bills.IndexOf(bill);
result.Payments.Add(new PaymentExtraction
{
Amount = Math.Abs(item.Amount),
PaymentType = paymentType,
Description = item.Description,
BillIndex = billIndex >= 0 ? billIndex : null
});
toRemove.Add(item);
}
foreach (var item in toRemove)
bill.LineItems.Remove(item);
}
}
public async Task<bool> DisambiguateBillMatchAsync(List<Models.MedicalBillCharge> existingCharges, List<LineItemExtraction> newItems, string? newSummary)
{
try
{
var existingLines = string.Join("\n", existingCharges.Select(c => $" - {c.Description}: ${c.Amount:F2}"));
var newLines = string.Join("\n", newItems.Select(i => $" - {i.Description}: ${i.Amount:F2}"));
var systemPrompt = @"You are comparing two sets of medical bill charges to determine if they represent the same bill. Return ONLY a JSON object with:
- ""sameBill"": true or false
- ""confidence"": ""high"", ""medium"", or ""low""
Consider: charges from the same bill may have slightly different descriptions or amounts across statements. Monthly statements of the same recurring service are the same bill.";
var userPrompt = $"Existing bill charges:\n{existingLines}\n\nNew document charges:\n{newLines}";
if (!string.IsNullOrEmpty(newSummary))
userPrompt += $"\n\nNew document summary: {newSummary}";
var response = await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel);
if (response == null) return false;
var json = ExtractJson(response);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var sameBill = root.TryGetProperty("sameBill", out var sb) && sb.GetBoolean();
var confidence = root.TryGetProperty("confidence", out var conf) ? conf.GetString() : "low";
return sameBill && confidence != "low";
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Bill disambiguation failed, defaulting to no match");
return false;
}
}
public async Task<string?> FuzzyMatchProviderAsync(string extractedName, List<string> existingProviderNames)
{
try
{
var providerList = string.Join("\n", existingProviderNames.Select(n => $" - {n}"));
var systemPrompt = @"You are matching a billing provider name extracted from a medical document against a list of known providers. Return ONLY a JSON object with:
- ""matchedName"": the exact string from the existing list that matches, or null if no match
- ""confidence"": ""high"", ""medium"", or ""low""
Consider abbreviations, slight misspellings, and variations (e.g., ""Intermountain Health"" matches ""Intermountain Healthcare""). Only return a match with medium or high confidence.";
var userPrompt = $"Extracted provider name: \"{extractedName}\"\n\nExisting providers:\n{providerList}";
var response = await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel);
if (response == null) return null;
var json = ExtractJson(response);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var matchedName = root.TryGetProperty("matchedName", out var mn) && mn.ValueKind == JsonValueKind.String ? mn.GetString() : null;
var confidence = root.TryGetProperty("confidence", out var conf) ? conf.GetString() : "low";
if (matchedName != null && confidence != "low")
return matchedName;
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Fuzzy provider matching failed for \"{ExtractedName}\"", extractedName);
return null;
}
}
public async Task<string?> FuzzyMatchDoctorAsync(string extractedName, List<string> existingDoctorNames)
{
try
{
var doctorList = string.Join("\n", existingDoctorNames.Select(n => $" - {n}"));
var systemPrompt = @"You are matching a doctor name extracted from a medical document against a list of known doctors for a patient. Return ONLY a JSON object with:
- ""matchedName"": the exact string from the existing list that matches, or null if no match
- ""confidence"": ""high"", ""medium"", or ""low""
Consider abbreviations, titles, and variations (e.g., ""Dr. John Smith"" matches ""John Smith"", ""J. Smith MD"" matches ""John Smith"", ""Smith, John"" matches ""John Smith""). Only return a match with medium or high confidence.";
var userPrompt = $"Extracted doctor name: \"{extractedName}\"\n\nExisting doctors:\n{doctorList}";
var response = await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel);
if (response == null) return null;
var json = ExtractJson(response);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var matchedName = root.TryGetProperty("matchedName", out var mn) && mn.ValueKind == JsonValueKind.String ? mn.GetString() : null;
var confidence = root.TryGetProperty("confidence", out var conf) ? conf.GetString() : "low";
if (matchedName != null && confidence != "low")
return matchedName;
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Fuzzy doctor matching failed for \"{ExtractedName}\"", extractedName);
return null;
}
}
public async Task<string?> FuzzyMatchConditionAsync(string extractedName, List<string> existingConditionNames)
{
try
{
var conditionList = string.Join("\n", existingConditionNames.Select(n => $" - {n}"));
var systemPrompt = @"You are matching a medical condition name extracted from a document against a list of known conditions for a patient. Return ONLY a JSON object with:
- ""matchedName"": the exact string from the existing list that matches, or null if no match
- ""confidence"": ""high"", ""medium"", or ""low""
Consider abbreviations, slight variations, and synonyms (e.g., ""Type 2 Diabetes"" matches ""Diabetes Mellitus Type 2"", ""HTN"" matches ""Hypertension""). Only return a match with medium or high confidence.";
var userPrompt = $"Extracted condition name: \"{extractedName}\"\n\nExisting conditions:\n{conditionList}";
var response = await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel);
if (response == null) return null;
var json = ExtractJson(response);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var matchedName = root.TryGetProperty("matchedName", out var mn) && mn.ValueKind == JsonValueKind.String ? mn.GetString() : null;
var confidence = root.TryGetProperty("confidence", out var conf) ? conf.GetString() : "low";
if (matchedName != null && confidence != "low")
return matchedName;
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Fuzzy condition matching failed for \"{ExtractedName}\"", extractedName);
return null;
}
}
public async Task<string?> GenerateVisitPrepSummaryAsync(string doctorName, string? specialty, Models.VisitPrepData data)
{
var sb = new StringBuilder();
sb.AppendLine($"Doctor: {doctorName}");
if (!string.IsNullOrEmpty(specialty))
sb.AppendLine($"Specialty: {specialty}");
sb.AppendLine();
if (data.ActiveConditions.Count > 0)
{
sb.AppendLine("Active Conditions:");
foreach (var c in data.ActiveConditions)
sb.AppendLine($" - {c.Name}{(c.DiagnosedDate.HasValue ? $" (diagnosed {c.DiagnosedDate:yyyy-MM-dd})" : "")}");
sb.AppendLine();
}
if (data.ActivePrescriptions.Count > 0)
{
sb.AppendLine("Current Medications:");
foreach (var rx in data.ActivePrescriptions)
{
var details = new List<string>();
if (!string.IsNullOrEmpty(rx.Dosage)) details.Add(rx.Dosage);
if (!string.IsNullOrEmpty(rx.Frequency)) details.Add(rx.Frequency);
sb.AppendLine($" - {rx.MedicationName}{(details.Count > 0 ? $" ({string.Join(", ", details)})" : "")}");
}
sb.AppendLine();
}
if (data.RecentDocuments.Count > 0)
{
sb.AppendLine("Recent Documents with this Doctor:");
foreach (var doc in data.RecentDocuments)
sb.AppendLine($" - {doc.Title ?? doc.FileName ?? "Untitled"}{(doc.DocumentDate.HasValue ? $" ({doc.DocumentDate:yyyy-MM-dd})" : "")}{(!string.IsNullOrEmpty(doc.Classification) ? $" [{doc.Classification}]" : "")}");
sb.AppendLine();
}
if (data.RecentBills.Count > 0)
{
sb.AppendLine("Recent Bills with this Doctor (last 6 months):");
foreach (var bill in data.RecentBills)
sb.AppendLine($" - ${bill.TotalAmount:F2}{(!string.IsNullOrEmpty(bill.Summary) ? $" - {bill.Summary}" : "")}{(bill.BillDate.HasValue ? $" ({bill.BillDate:yyyy-MM-dd})" : "")}");
sb.AppendLine();
}
var systemPrompt = "You are a medical visit preparation assistant. Produce a concise narrative summary for a patient preparing to visit their doctor. Include: key conditions to discuss, current medications to review, recent visits, and any billing notes. Keep under 500 words.";
var userPrompt = sb.ToString();
return await CallClaudeCliAsync(systemPrompt, userPrompt, SonnetModel);
}
// --- Response DTOs ---
public class ClassificationResult
{
public string? Classification { get; set; }
public List<string>? Tags { get; set; }
public string? DoctorName { get; set; }
public List<string>? ConditionNames { get; set; }
}
public class BillingExtractionResult
{
public List<BillExtraction>? Bills { get; set; }
public List<PaymentExtraction>? Payments { get; set; }
}
public class PrescriptionExtractionResult
{
public PrescriptionInfo? PrescriptionInfo { get; set; }
}
public class BillExtraction
{
public decimal TotalAmount { get; set; }
public string? Category { get; set; }
public string? BillDate { get; set; }
public string? Summary { get; set; }
public string? ProviderName { get; set; }
public List<LineItemExtraction>? LineItems { get; set; }
}
public class LineItemExtraction
{
public string? Description { get; set; }
public decimal Amount { get; set; }
}
public class PaymentExtraction
{
public decimal Amount { get; set; }
public string? PaymentType { get; set; }
public string? PaymentDate { get; set; }
public string? Description { get; set; }
public int? BillIndex { get; set; }
}
public class PrescriptionInfo
{
public string? MedicationName { get; set; }
public string? Dosage { get; set; }
public string? Frequency { get; set; }
public string? RxNumber { get; set; }
public string? Pharmacy { get; set; }
public decimal? Copay { get; set; }
public string? PickupDate { get; set; }
public string? PersonName { get; set; }
public string? DoctorName { get; set; }
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
namespace Media.JoshHeaps.Net.Services;
public sealed class SsoClientConfig
{
public string ClientId { get; set; } = string.Empty;
public string ClientSecretHash { get; set; } = string.Empty;
public List<string> RedirectUris { get; set; } = [];
public string Name { get; set; } = string.Empty;
public bool AllowsRedirectUri(string uri) =>
RedirectUris.Any(r => string.Equals(r, uri, StringComparison.Ordinal));
}
public static class SsoClientRegistry
{
public static SsoClientConfig? Find(IConfiguration config, string clientId)
{
var clients = config.GetSection("Sso:Clients").Get<List<SsoClientConfig>>() ?? [];
return clients.FirstOrDefault(c => string.Equals(c.ClientId, clientId, StringComparison.Ordinal));
}
}
@@ -0,0 +1,43 @@
using System.Text.Json;
using Media.JoshHeaps.Net.Models;
namespace Media.JoshHeaps.Net.Services;
public class ThemeService(DbExecutor db)
{
public async Task<UserThemeOverrides?> GetUserThemeAsync(long userId)
{
return await db.ExecuteReaderAsync<UserThemeOverrides?>(
@"SELECT id, user_id, base_theme, color_overrides::text, created_at, updated_at
FROM app.user_theme_overrides
WHERE user_id = @userId",
reader =>
{
if (!reader.Read()) return null;
var overridesJson = reader.GetString(3);
return new UserThemeOverrides
{
Id = reader.GetInt64(0),
UserId = reader.GetInt64(1),
BaseTheme = reader.GetString(2),
ColorOverrides = JsonSerializer.Deserialize<Dictionary<string, string>>(overridesJson) ?? new(),
CreatedAt = reader.GetDateTime(4),
UpdatedAt = reader.GetDateTime(5)
};
},
new { userId });
}
public async Task SaveUserThemeAsync(long userId, string baseTheme, Dictionary<string, string> colorOverrides)
{
var overridesJson = JsonSerializer.Serialize(colorOverrides);
await db.ExecuteNonQueryAsync(
@"INSERT INTO app.user_theme_overrides (user_id, base_theme, color_overrides, created_at, updated_at)
VALUES (@userId, @baseTheme, @overridesJson::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (user_id) DO UPDATE
SET base_theme = @baseTheme,
color_overrides = @overridesJson::jsonb,
updated_at = CURRENT_TIMESTAMP",
new { userId, baseTheme, overridesJson });
}
}
+15
View File
@@ -20,6 +20,21 @@
"FromName": "Media App",
"EnableSsl": "true"
},
"BlogCache": {
"InvalidateUrl": "https://joshheaps.net/api/blog/invalidate",
"InvalidateKey": "CHANGE_ME"
},
"Sso": {
"CodeLifetimeSeconds": 60,
"Clients": [
{
"ClientId": "ai",
"ClientSecretHash": "SET_VIA_USER_SECRETS",
"RedirectUris": [ "https://ai.joshheaps.net/auth/callback" ],
"Name": "AI JoshHeaps"
}
]
},
"FileUpload": {
"MaxFileSizeMB": 10,
"AllowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"],
+23
View File
@@ -152,6 +152,29 @@
display: block;
}
.form-options {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.form-options .checkbox-wrapper {
margin-bottom: 0;
}
.forgot-password-link {
color: var(--text-secondary);
font-size: 13px;
text-decoration: none;
transition: color 0.2s ease;
}
.forgot-password-link:hover {
color: var(--accent-primary);
text-decoration: underline;
}
.checkbox-wrapper {
display: flex;
align-items: center;
@@ -0,0 +1,281 @@
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
}
.welcome-section {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
color: var(--text-primary);
padding: 32px 40px;
border-radius: 8px;
margin-bottom: 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.welcome-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-button {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s ease;
flex-shrink: 0;
}
.back-button:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
transform: translateX(-2px);
}
.back-button svg {
width: 20px;
height: 20px;
}
.welcome-section h1 {
margin: 0;
font-size: 28px;
font-weight: 600;
letter-spacing: -0.5px;
}
.quick-actions {
display: flex;
gap: 12px;
}
.btn {
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s ease;
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
.btn-primary {
background: var(--accent-primary);
color: #fff;
border-color: var(--accent-primary);
}
.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border-primary);
}
.btn-secondary:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.btn-danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.btn-danger:hover {
background: var(--danger-hover);
border-color: var(--danger-hover);
}
.admin-section {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
}
.section-header {
margin-bottom: 16px;
}
.section-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.table-container {
overflow-x: auto;
}
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-secondary);
}
.admin-table th {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
background: var(--bg-tertiary);
}
.admin-table td {
font-size: 14px;
color: var(--text-primary);
}
.admin-table tbody tr:hover {
background: var(--bg-hover);
}
.admin-table code {
font-size: 12px;
padding: 2px 6px;
background: var(--bg-tertiary);
border-radius: 4px;
color: var(--text-secondary);
}
.empty-message {
text-align: center;
color: var(--text-secondary);
font-style: italic;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tag-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 12px;
font-size: 12px;
color: var(--text-secondary);
}
.action-cell {
display: flex;
gap: 6px;
}
/* Editor form */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.form-input {
width: 100%;
padding: 10px 12px;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 6px;
color: var(--text-primary);
font-size: 14px;
outline: none;
transition: border-color 0.2s ease;
box-sizing: border-box;
}
.form-input:focus {
border-color: var(--accent-primary);
}
.content-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.upload-status {
font-size: 13px;
color: var(--text-secondary);
}
.upload-error {
color: var(--danger);
}
.upload-success {
color: var(--success, #22c55e);
}
.form-textarea {
min-height: 400px;
resize: vertical;
font-family: monospace;
line-height: 1.5;
}
.editor-actions {
display: flex;
gap: 8px;
margin-top: 20px;
}
@media (max-width: 768px) {
.welcome-section {
flex-direction: column;
gap: 16px;
padding: 20px;
align-items: flex-start;
}
.admin-table th,
.admin-table td {
padding: 8px 10px;
}
.action-cell {
flex-direction: column;
}
}
@@ -0,0 +1,250 @@
/* Breadboard Simulator - Project List Page Styles */
/* Every colour here comes from the site.css custom properties, so light mode is handled
by the [data-theme="light"] block there and this file needs no theme-specific rules.
site.css already applies `* { margin: 0; padding: 0; box-sizing: border-box }`. */
.bbp-page {
padding: 32px 0;
}
/* Header */
.bbp-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 32px;
}
.bbp-header-left {
display: flex;
align-items: center;
gap: 16px;
}
.bbp-header h1 {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-primary);
}
.bbp-back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex: 0 0 auto;
border: 1px solid var(--border-primary);
border-radius: 8px;
color: var(--text-secondary);
transition: all 0.2s ease;
}
.bbp-back:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.bbp-back svg {
width: 20px;
height: 20px;
}
/* Buttons */
.bbp-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
font-size: 0.9rem;
font-weight: 500;
font-family: inherit;
line-height: 1.4;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.bbp-btn:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.bbp-btn-primary {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--bg-primary);
}
.bbp-btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.bbp-btn-danger {
color: var(--danger);
}
.bbp-btn-danger:hover {
background: var(--danger);
border-color: var(--danger);
color: var(--bg-primary);
}
/* Error banner. Hidden by default; the script adds .is-visible to show it. Both halves are
defined right here, so visibility can never depend on a class defined somewhere else (or,
as it turned out, nowhere at all). Keep `display: none` below as the hidden baseline; if you
need a different VISIBLE display mode, change it on .bbp-error.is-visible instead. */
.bbp-error {
display: none;
padding: 12px 16px;
margin-bottom: 24px;
border: 1px solid var(--danger);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--danger);
white-space: pre-line;
}
.bbp-error.is-visible {
display: block;
}
/* Create form */
.bbp-card {
padding: 20px;
margin-bottom: 32px;
border: 1px solid var(--border-primary);
border-radius: 12px;
background: var(--bg-secondary);
}
.bbp-card-title {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 16px;
}
.bbp-create-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
}
.bbp-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 200px;
}
.bbp-field-grow {
flex: 1 1 320px;
}
.bbp-field label {
font-size: 0.85rem;
color: var(--text-secondary);
}
.bbp-input {
padding: 8px 12px;
font-size: 0.95rem;
font-family: inherit;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
transition: border-color 0.2s ease;
}
.bbp-input:focus {
outline: none;
border-color: var(--accent-primary);
}
/* Project list */
.bbp-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.bbp-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-tertiary);
transition: all 0.2s ease;
}
.bbp-item:hover {
border-color: var(--accent-primary);
background: var(--bg-hover);
}
.bbp-item-main {
min-width: 0;
flex: 1;
}
.bbp-item-name {
font-weight: 600;
color: var(--accent-primary);
}
.bbp-item-name:hover {
color: var(--accent-hover);
text-decoration: underline;
}
.bbp-item-description,
.bbp-item-meta {
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: 4px;
}
.bbp-item-actions {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.bbp-empty {
padding: 32px;
text-align: center;
border: 1px dashed var(--border-primary);
border-radius: 12px;
color: var(--text-secondary);
}
@media (max-width: 640px) {
.bbp-item {
flex-direction: column;
align-items: stretch;
}
.bbp-item-actions {
justify-content: flex-end;
}
}
@@ -0,0 +1,610 @@
/* Breadboard simulator.
*
* Theming follows site.css: the DARK palette is the default on :root, and
* [data-theme="light"] overrides it. Do not add dark-only blocks.
*
* The --bb-* custom properties below are read by the canvas renderer through
* getComputedStyle - canvas cannot inherit CSS, so every colour the renderer draws is
* defined here and nowhere else. Changing the board's look is a stylesheet edit.
*/
:root {
/* Canvas surround */
--bb-canvas-bg: #14161a;
--bb-grid: #1e2126;
/* Board body - a dark-room breadboard reads slightly desaturated */
--bb-board-face: #d9d6cb;
--bb-board-edge: #a8a496;
--bb-board-bevel: #efece3;
--bb-channel: #c3bfb2;
--bb-channel-edge: #a29e90;
--bb-hole: #26282b;
--bb-hole-rim: #b3afa2;
--bb-silk: #7d7869;
--bb-silk-strong: #56524a;
--bb-rail-plus: #c8483f;
--bb-rail-minus: #3f63c8;
/* Interaction */
--bb-hover: #3fb950;
--bb-selection: #58a6ff;
--bb-ghost: #58a6ff;
--bb-invalid: #f85149;
--bb-wire-shadow: rgba(0, 0, 0, 0.45);
/* Parts */
--bb-chip-body: #24242a;
--bb-chip-label: #d9d9de;
--bb-chip-pin: #c2c2c8;
--bb-resistor-body: #d6c298;
--bb-resistor-lead: #a9a9ad;
--bb-diode-body: #9aa7b4;
--bb-diode-band: #1c1c20;
--bb-transistor-body: #1f1f24;
--bb-button-body: #34343a;
--bb-button-cap: #c9553f;
--bb-button-cap-down: #8d3a2b;
--bb-dip-body: #2b4785;
--bb-dip-switch-on: #f4f4f6;
--bb-dip-switch-off: #83838a;
--bb-supply-body: #1f2933;
--bb-supply-text: #e6edf3;
--bb-burned: #4a4a4a;
/* Logic levels */
--bb-level-low: #3b6ea5;
--bb-level-high: #e0483d;
--bb-level-hiz: #7d7d85;
--bb-level-weak-low: #4f7fa8;
--bb-level-weak-high: #d98a4a;
--bb-level-contention: #ffcc00;
}
[data-theme="light"] {
--bb-canvas-bg: #eceff3;
--bb-grid: #dfe3e9;
--bb-board-face: #f2f0e9;
--bb-board-edge: #c9c5b8;
--bb-board-bevel: #ffffff;
--bb-channel: #ddd9cf;
--bb-channel-edge: #bdb9ac;
--bb-hole: #3a3a3a;
--bb-hole-rim: #cfcbbe;
--bb-silk: #97917f;
--bb-silk-strong: #625d52;
--bb-rail-plus: #d24b4b;
--bb-rail-minus: #4b6fd2;
--bb-wire-shadow: rgba(0, 0, 0, 0.22);
--bb-chip-body: #2b2b30;
--bb-transistor-body: #2b2b30;
--bb-supply-body: #26303a;
}
/* --- Page shell -------------------------------------------------------------
* The canvas needs a box with a definite height to measure itself against, which
* nothing above it provides - hence the explicit 100vh.
*
* No breakout is needed and none is wanted. _Layout.cshtml links only site.css: neither
* Bootstrap's CSS nor the CSS-isolation bundle (Media.JoshHeaps.Net.styles.css) is ever
* loaded, so `.container` applies no max-width or padding, and the empty <footer>
* reserves no space. Verified, not assumed. Consequently:
* - `width: 100%` rather than `100vw`, because 100vw includes the vertical scrollbar
* and would produce a horizontal one for no benefit,
* - full `100vh` rather than subtracting a footer that does not occupy any height.
*/
.bb-fullbleed {
width: 100%;
height: 100vh;
min-height: 480px;
overflow: hidden;
}
#breadboard-editor {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bb-canvas-bg);
color: var(--text-primary);
font-size: 0.875rem;
}
.bb-editor-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.bb-workspace {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
/* --- Toolbar --------------------------------------------------------------- */
.bb-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.25rem;
padding: 0.5rem 0.875rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
flex: 0 0 auto;
}
.bb-toolbar-group {
display: flex;
align-items: center;
gap: 0.4rem;
}
.bb-group-view {
margin-left: auto;
}
.bb-title {
font-size: 1rem;
font-weight: 600;
margin: 0 0.5rem 0 0;
max-width: 22ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bb-save-state {
font-size: 0.8rem;
color: var(--text-secondary);
min-width: 9ch;
}
.bb-save-dirty { color: var(--accent-primary); }
.bb-save-error { color: var(--danger); }
.bb-save-clean { color: var(--success); }
.bb-sim-state {
font-size: 0.8rem;
color: var(--text-secondary);
min-width: 12ch;
}
.bb-sim-running { color: var(--success); }
.bb-sim-halted { color: var(--danger); }
.bb-speed {
width: 8rem;
accent-color: var(--accent-primary);
}
.bb-speed-label,
.bb-zoom-label {
font-size: 0.78rem;
color: var(--text-secondary);
min-width: 8ch;
text-align: right;
font-variant-numeric: tabular-nums;
}
.bb-zoom-label { min-width: 5ch; text-align: center; }
/* --- Buttons --------------------------------------------------------------- */
.bb-btn {
font: inherit;
font-size: 0.8rem;
padding: 0.3rem 0.7rem;
border-radius: 6px;
border: 1px solid var(--border-primary);
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.bb-btn:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.bb-btn:disabled {
opacity: 0.45;
cursor: default;
}
.bb-btn:focus-visible,
.bb-tool:focus-visible,
.bb-swatch:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.bb-btn-primary {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: #fff;
}
.bb-btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
color: #fff;
}
.bb-btn-run { color: var(--success); }
.bb-btn-danger { color: var(--danger); border-color: var(--border-primary); }
.bb-btn-danger:hover:not(:disabled) { border-color: var(--danger); background: var(--bg-hover); }
.bb-btn-ghost { background: transparent; border-color: transparent; }
.bb-btn-small { font-size: 0.72rem; padding: 0.15rem 0.45rem; }
.bb-btn-icon {
width: 1.9rem;
padding: 0.25rem 0;
text-align: center;
font-size: 0.95rem;
line-height: 1;
}
/* --- Panel hosts ----------------------------------------------------------- */
.bb-toolbar-host {
flex: 0 0 auto;
}
.bb-palette-host,
.bb-props-host {
flex: 0 0 auto;
min-height: 0;
overflow: hidden;
display: flex;
}
/* Scroll containers for the two panel bodies. min-height:0 is required: without it a
flex child refuses to shrink below its content and the panel grows instead of
scrolling. */
.bb-props-body,
.bb-boards-body {
display: flex;
flex-direction: column;
min-height: 0;
}
/* --- Palette --------------------------------------------------------------- */
.bb-palette,
.bb-props {
width: 13.5rem;
padding: 0.75rem;
overflow-y: auto;
background: var(--bg-secondary);
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.bb-palette { border-right: 1px solid var(--border-primary); }
.bb-props { border-left: 1px solid var(--border-primary); width: 15rem; }
.bb-palette-heading,
.bb-props-heading {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
margin: 0 0 0.35rem;
font-weight: 600;
}
.bb-palette-section { display: flex; flex-direction: column; }
.bb-palette-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.3rem;
}
.bb-tool {
font: inherit;
font-size: 0.75rem;
padding: 0.4rem 0.3rem;
border-radius: 6px;
border: 1px solid var(--border-primary);
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bb-tool:hover { background: var(--bg-hover); }
.bb-tool.is-active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: #fff;
}
.bb-tool-component { font-family: ui-monospace, "SF Mono", Consolas, monospace; }
.bb-swatches {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
.bb-swatch {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
border: 2px solid transparent;
box-shadow: 0 0 0 1px var(--border-primary);
cursor: pointer;
padding: 0;
}
.bb-swatch.is-active {
border-color: var(--text-primary);
transform: scale(1.15);
}
.bb-palette-hint {
font-size: 0.7rem;
line-height: 1.5;
color: var(--text-secondary);
margin: auto 0 0;
}
/* --- Canvas ---------------------------------------------------------------- */
.bb-canvas-container {
position: relative;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
background:
radial-gradient(circle at 1px 1px, var(--bb-grid) 1px, transparent 0) 0 0 / 24px 24px,
var(--bb-canvas-bg);
}
.bb-layer {
position: absolute;
inset: 0;
display: block;
}
.bb-layer-overlay:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: -2px;
}
/* --- Properties ------------------------------------------------------------ */
.bb-props-title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.bb-props-uid {
font-size: 0.68rem;
color: var(--text-secondary);
font-family: ui-monospace, "SF Mono", Consolas, monospace;
}
.bb-props-description,
.bb-props-meta,
.bb-props-empty {
font-size: 0.72rem;
line-height: 1.5;
color: var(--text-secondary);
margin: 0.35rem 0;
}
.bb-props-warning {
font-size: 0.72rem;
line-height: 1.5;
color: var(--danger);
margin: 0.35rem 0;
}
.bb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0.6rem 0;
}
.bb-field-label {
font-size: 0.7rem;
color: var(--text-secondary);
}
.bb-field-stack { display: flex; flex-direction: column; gap: 0.35rem; }
.bb-input {
font: inherit;
font-size: 0.8rem;
padding: 0.3rem 0.45rem;
border-radius: 6px;
border: 1px solid var(--border-primary);
background: var(--bg-primary);
color: var(--text-primary);
width: 100%;
}
.bb-input:focus-visible {
outline: none;
border-color: var(--accent-primary);
}
.bb-presets { display: flex; flex-wrap: wrap; gap: 0.2rem; }
.bb-chip-btn {
font: inherit;
font-size: 0.66rem;
padding: 0.12rem 0.35rem;
border-radius: 999px;
border: 1px solid var(--border-primary);
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: pointer;
}
.bb-chip-btn:hover { color: var(--text-primary); border-color: var(--accent-primary); }
.bb-switch-row { display: flex; flex-wrap: wrap; gap: 0.2rem; }
.bb-switch-toggle {
font: inherit;
font-size: 0.68rem;
width: 1.6rem;
height: 1.6rem;
border-radius: 5px;
border: 1px solid var(--border-primary);
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: pointer;
}
.bb-switch-toggle.is-on {
background: var(--success);
border-color: var(--success);
color: #fff;
}
.bb-props-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.75rem;
}
.bb-board-row {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--border-secondary);
font-size: 0.74rem;
}
.bb-board-name { font-family: ui-monospace, "SF Mono", Consolas, monospace; }
.bb-board-count { color: var(--text-secondary); margin-right: auto; }
/* --- Status strip ---------------------------------------------------------- */
/* Floats over the bottom of the canvas, NOT stacked below it. Sharing the column
* meant every message that appeared or timed out resized the canvas, which reallocated
* the backing stores and forced a full repaint. */
.bb-status-host {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
/* The strip is often empty; the canvas under it must stay clickable. */
pointer-events: none;
}
.bb-status {
display: flex;
flex-direction: column;
max-height: 11rem;
overflow-y: auto;
background: var(--bg-secondary);
pointer-events: auto;
}
/* Border on the groups rather than the panel: both collapse when empty, so an idle
* editor shows no stray rule across the canvas. */
.bb-status-messages,
.bb-status-warnings { border-top: 1px solid var(--border-primary); }
.bb-status-messages:empty,
.bb-status-warnings:not(.is-visible) { display: none; }
.bb-message {
padding: 0.35rem 0.875rem;
font-size: 0.76rem;
border-left: 3px solid transparent;
animation: bb-fade-in 0.15s ease;
}
.bb-message-info { color: var(--text-secondary); border-left-color: var(--border-primary); }
.bb-message-warn { color: var(--text-primary); border-left-color: #e8a33d; }
.bb-message-error { color: var(--danger); border-left-color: var(--danger); }
@keyframes bb-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.bb-message { animation: none; }
.bb-swatch.is-active { transform: none; }
.bb-btn { transition: none; }
}
.bb-status-warnings-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.875rem;
border-bottom: 1px solid var(--border-secondary);
}
.bb-status-warnings-title {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-right: auto;
}
.bb-warning {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.3rem 0.875rem;
font-size: 0.76rem;
border-left: 3px solid transparent;
}
.bb-warning-warn { border-left-color: #e8a33d; }
.bb-warning-error { border-left-color: var(--danger); }
.bb-warning-title { font-weight: 600; }
.bb-warning-count { color: var(--text-secondary); font-size: 0.7rem; }
.bb-warning-detail,
.bb-warning-uids {
color: var(--text-secondary);
font-size: 0.72rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bb-warning-uids { font-family: ui-monospace, "SF Mono", Consolas, monospace; }
/* --- Narrow screens -------------------------------------------------------- */
@media (max-width: 900px) {
.bb-palette, .bb-props { width: 10.5rem; padding: 0.5rem; }
.bb-palette-grid { grid-template-columns: 1fr; }
.bb-group-speed { display: none; }
}
@media (max-width: 640px) {
.bb-props-host { display: none; }
.bb-toolbar { gap: 0.6rem; }
.bb-title { max-width: 12ch; }
}
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
.profile-container {
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
}
.profile-section {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 24px;
margin-bottom: 20px;
}
.profile-section h2 {
margin: 0 0 20px 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
border-bottom: 1px solid var(--border-primary);
padding-bottom: 12px;
}
.profile-field {
margin-bottom: 16px;
}
.profile-field label {
display: block;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 4px;
font-size: 14px;
}
.profile-field-value {
color: var(--text-primary);
font-size: 15px;
}
.theme-toggle-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
}
.theme-toggle-label {
display: flex;
flex-direction: column;
}
.theme-toggle-label strong {
color: var(--text-primary);
font-size: 15px;
margin-bottom: 4px;
}
.theme-toggle-label span {
color: var(--text-secondary);
font-size: 13px;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary);
border: 1px solid var(--border-primary);
transition: 0.3s;
border-radius: 34px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: var(--text-secondary);
transition: 0.3s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: var(--accent-primary);
border-color: var(--accent-primary);
}
input:checked + .toggle-slider:before {
transform: translateX(24px);
background-color: var(--bg-primary);
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent-primary);
text-decoration: none;
font-size: 14px;
margin-bottom: 20px;
transition: color 0.2s ease;
}
.back-link:hover {
color: var(--accent-hover);
}
.customize-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 6px;
color: var(--text-primary);
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
transition: background 0.15s;
}
.customize-btn:hover {
background: var(--bg-hover);
}
@@ -0,0 +1,214 @@
#theme-customizer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
#theme-customizer-modal {
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 8px;
width: 90vw;
max-width: 1200px;
height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tc-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid var(--border-primary);
flex-shrink: 0;
}
.tc-header h3 {
margin: 0;
font-size: 18px;
color: var(--text-primary);
}
.tc-close-btn {
background: none;
border: none;
font-size: 24px;
color: var(--text-secondary);
cursor: pointer;
line-height: 1;
}
.tc-close-btn:hover {
color: var(--text-primary);
}
.tc-controls {
padding: 12px 20px;
border-bottom: 1px solid var(--border-primary);
flex-shrink: 0;
}
.tc-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.tc-row:last-child {
margin-bottom: 0;
}
.tc-row label {
min-width: 110px;
font-size: 14px;
color: var(--text-secondary);
font-weight: 500;
}
.tc-row select,
.tc-row input[type="text"] {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 4px;
color: var(--text-primary);
padding: 6px 10px;
font-size: 14px;
}
.tc-row select {
flex: 1;
max-width: 280px;
}
.tc-picker-row input[type="color"] {
width: 40px;
height: 34px;
border: 1px solid var(--border-primary);
border-radius: 4px;
cursor: pointer;
padding: 2px;
background: var(--bg-secondary);
}
.tc-picker-row input[type="text"] {
width: 90px;
font-family: monospace;
}
.tc-swatches {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin: 8px 0 4px;
}
.tc-swatch {
width: 28px;
height: 28px;
border-radius: 4px;
border: 2px solid var(--border-secondary);
cursor: pointer;
transition: border-color 0.15s;
}
.tc-swatch:hover {
border-color: var(--text-secondary);
}
.tc-swatch-overridden {
border-color: var(--accent-primary);
border-width: 2px;
}
.tc-swatch-selected {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.tc-btn-row {
display: flex;
gap: 10px;
margin-top: 8px;
}
.tc-btn {
padding: 8px 16px;
border-radius: 6px;
border: 1px solid var(--border-primary);
font-size: 14px;
cursor: pointer;
background: var(--bg-secondary);
color: var(--text-primary);
transition: background 0.15s;
}
.tc-btn:hover {
background: var(--bg-hover);
}
.tc-btn-save {
background: var(--accent-primary);
color: #fff;
border-color: var(--accent-primary);
}
.tc-btn-save:hover {
background: var(--accent-hover);
}
.tc-btn-reset {
margin-left: auto;
color: var(--danger);
border-color: var(--danger);
}
.tc-btn-reset:hover {
background: var(--danger);
color: #fff;
}
.tc-btn-small {
padding: 4px 10px;
font-size: 12px;
}
.tc-preview {
flex: 1;
min-height: 0;
padding: 0 20px 20px;
display: flex;
flex-direction: column;
}
.tc-preview iframe {
width: 100%;
flex: 1;
min-height: 0;
border: 1px solid var(--border-primary);
border-radius: 4px;
}
@media (max-width: 600px) {
#theme-customizer-modal {
width: 98vw;
height: 98vh;
}
.tc-row {
flex-wrap: wrap;
}
.tc-row label {
min-width: auto;
}
}
+136
View File
@@ -336,9 +336,145 @@ function initRegisterForm() {
});
}
// Password reset form validation
function initPasswordResetForm() {
const form = document.getElementById('resetPasswordForm');
if (!form) return;
const passwordInput = document.getElementById('newPassword');
const confirmPasswordInput = document.getElementById('confirmPassword');
if (passwordInput) {
passwordInput.addEventListener('input', function() {
checkPasswordStrength(this.value);
if (this.value && validatePassword(this.value)) {
clearError(this);
}
if (confirmPasswordInput && confirmPasswordInput.value) {
if (confirmPasswordInput.value === this.value) {
clearError(confirmPasswordInput);
} else {
showError(confirmPasswordInput, 'Passwords do not match');
}
}
});
passwordInput.addEventListener('blur', function() {
if (!this.value) {
showError(this, 'Password is required');
} else if (!validatePassword(this.value)) {
showError(this, 'Password must be at least 8 characters');
} else {
clearError(this);
}
});
}
if (confirmPasswordInput) {
confirmPasswordInput.addEventListener('input', function() {
if (passwordInput && this.value === passwordInput.value) {
clearError(this);
}
});
confirmPasswordInput.addEventListener('blur', function() {
if (!this.value) {
showError(this, 'Please confirm your password');
} else if (passwordInput && this.value !== passwordInput.value) {
showError(this, 'Passwords do not match');
} else {
clearError(this);
}
});
}
form.addEventListener('submit', function(e) {
e.preventDefault();
let isValid = true;
if (!passwordInput.value) {
showError(passwordInput, 'Password is required');
isValid = false;
} else if (!validatePassword(passwordInput.value)) {
showError(passwordInput, 'Password must be at least 8 characters');
isValid = false;
} else {
clearError(passwordInput);
}
if (!confirmPasswordInput.value) {
showError(confirmPasswordInput, 'Please confirm your password');
isValid = false;
} else if (confirmPasswordInput.value !== passwordInput.value) {
showError(confirmPasswordInput, 'Passwords do not match');
isValid = false;
} else {
clearError(confirmPasswordInput);
}
if (isValid) {
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner"></span> Resetting...';
form.submit();
}
});
}
// Request reset form validation
function initRequestResetForm() {
const form = document.getElementById('requestResetForm');
if (!form) return;
const emailInput = document.getElementById('email');
if (emailInput) {
emailInput.addEventListener('blur', function() {
if (!this.value.trim()) {
showError(this, 'Email is required');
} else if (!validateEmail(this.value)) {
showError(this, 'Please enter a valid email address');
} else {
clearError(this);
}
});
emailInput.addEventListener('input', function() {
if (this.value.trim() && validateEmail(this.value)) {
clearError(this);
}
});
}
form.addEventListener('submit', function(e) {
e.preventDefault();
if (!emailInput.value.trim()) {
showError(emailInput, 'Email is required');
return;
}
if (!validateEmail(emailInput.value)) {
showError(emailInput, 'Please enter a valid email address');
return;
}
clearError(emailInput);
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner"></span> Sending...';
form.submit();
});
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
initPasswordToggles();
initLoginForm();
initRegisterForm();
initPasswordResetForm();
initRequestResetForm();
});
@@ -0,0 +1,195 @@
(function () {
let posts = [];
document.addEventListener('DOMContentLoaded', init);
async function init() {
await loadPosts();
document.getElementById('newPostBtn').addEventListener('click', showNewPostEditor);
document.getElementById('savePostBtn').addEventListener('click', savePost);
document.getElementById('cancelEditBtn').addEventListener('click', hideEditor);
const imageInput = document.getElementById('imageFileInput');
document.getElementById('uploadImageBtn').addEventListener('click', () => imageInput.click());
imageInput.addEventListener('change', uploadImage);
}
async function uploadImage() {
const input = document.getElementById('imageFileInput');
const status = document.getElementById('uploadStatus');
if (!input.files.length) return;
const file = input.files[0];
const formData = new FormData();
formData.append('file', file);
status.textContent = 'Uploading...';
status.className = 'upload-status';
try {
const res = await fetch('/api/blog/images', { method: 'POST', body: formData });
if (!res.ok) {
const err = await res.json();
status.textContent = err.error || 'Upload failed';
status.className = 'upload-status upload-error';
return;
}
const { url } = await res.json();
const textarea = document.getElementById('postContent');
const markdown = `![${file.name}](${url})`;
const pos = textarea.selectionStart;
const before = textarea.value.substring(0, pos);
const after = textarea.value.substring(pos);
const needsNewline = before.length > 0 && !before.endsWith('\n') ? '\n' : '';
textarea.value = before + needsNewline + markdown + '\n' + after;
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = pos + needsNewline.length + markdown.length + 1;
status.textContent = 'Uploaded!';
status.className = 'upload-status upload-success';
setTimeout(() => { status.textContent = ''; }, 2000);
} catch {
status.textContent = 'Upload failed';
status.className = 'upload-status upload-error';
} finally {
input.value = '';
}
}
async function loadPosts() {
const res = await fetch('/api/blog/posts');
if (!res.ok) return;
posts = await res.json();
renderPostsTable();
}
function renderPostsTable() {
const tbody = document.getElementById('postsTableBody');
if (posts.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-message">No posts yet. Create your first post!</td></tr>';
return;
}
tbody.innerHTML = posts.map(post => {
const tags = post.tags.map(t => `<span class="tag-pill">${escapeHtml(t)}</span>`).join('');
const date = new Date(post.publishedDate).toLocaleDateString();
return `<tr>
<td>${escapeHtml(post.title)}</td>
<td><code>${escapeHtml(post.slug)}</code></td>
<td><div class="tag-list">${tags}</div></td>
<td>${date}</td>
<td class="action-cell">
<button class="btn btn-secondary btn-sm" onclick="blogEditPost(${post.id})">Edit</button>
<button class="btn btn-danger btn-sm" onclick="blogDeletePost(${post.id})">Delete</button>
</td>
</tr>`;
}).join('');
}
function showNewPostEditor() {
document.getElementById('editorTitle').textContent = 'New Post';
document.getElementById('editPostId').value = '';
document.getElementById('postTitle').value = '';
document.getElementById('postSummary').value = '';
document.getElementById('postTags').value = '';
document.getElementById('postContent').value = '';
document.getElementById('postDate').value = new Date().toISOString().split('T')[0];
document.getElementById('postsList').style.display = 'none';
document.getElementById('postEditor').style.display = '';
}
function hideEditor() {
document.getElementById('postEditor').style.display = 'none';
document.getElementById('postsList').style.display = '';
}
async function savePost() {
const id = document.getElementById('editPostId').value;
const title = document.getElementById('postTitle').value.trim();
const summary = document.getElementById('postSummary').value.trim();
const tagsStr = document.getElementById('postTags').value.trim();
const markdownContent = document.getElementById('postContent').value;
const dateStr = document.getElementById('postDate').value;
if (!title) { alert('Title is required'); return; }
if (!markdownContent) { alert('Content is required'); return; }
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(t => t) : [];
const publishedDate = dateStr ? new Date(dateStr + 'T00:00:00Z').toISOString() : null;
const body = { title, summary, markdownContent, tags, publishedDate };
const url = id ? `/api/blog/posts/${id}` : '/api/blog/posts';
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json();
alert(err.error || 'Failed to save post');
return;
}
hideEditor();
await loadPosts();
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
window.blogEditPost = async function (id) {
const res = await fetch(`/api/blog/posts`);
if (!res.ok) return;
const allPosts = await res.json();
// Need markdown content, so fetch the admin-specific data
// The public API doesn't return markdownContent, so we fetch all and find by id
// Actually, we need to get it from the create/update response pattern
// For editing, we'll fetch via a dedicated admin endpoint or use stored data
// Since the public GET returns htmlContent but not markdownContent,
// let's make a specific request. But our API only has public routes returning htmlContent.
// We need the admin to get markdownContent too.
// Workaround: find in local posts array (which has htmlContent), but we need markdown.
// The cleanest fix is to have the GET endpoints also return markdownContent for admins,
// but for now let's add an admin-specific detail endpoint.
// Actually, looking at the API, the GET /posts/{slug} returns the public DTO.
// Let me use a different approach: store markdownContent in memory from the list.
// For now, we need to get the post with markdownContent.
// Let's fetch by id from the admin-aware endpoint.
const detailRes = await fetch(`/api/blog/posts/admin/${id}`);
if (!detailRes.ok) {
alert('Failed to load post for editing');
return;
}
const post = await detailRes.json();
document.getElementById('editorTitle').textContent = 'Edit Post';
document.getElementById('editPostId').value = post.id;
document.getElementById('postTitle').value = post.title;
document.getElementById('postSummary').value = post.summary || '';
document.getElementById('postTags').value = (post.tags || []).join(', ');
document.getElementById('postContent').value = post.markdownContent || '';
document.getElementById('postDate').value = new Date(post.publishedDate).toISOString().split('T')[0];
document.getElementById('postsList').style.display = 'none';
document.getElementById('postEditor').style.display = '';
};
window.blogDeletePost = async function (id) {
if (!confirm('Are you sure you want to delete this post?')) return;
const res = await fetch(`/api/blog/posts/${id}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json();
alert(err.error || 'Failed to delete post');
return;
}
await loadPosts();
};
})();
@@ -0,0 +1,130 @@
// Server calls for breadboard projects.
//
// Every error path produces a human-readable message. The API returns a uniform
// { errors: [...] } body on 400/401/404/500, but a request that fails model binding
// before the controller runs yields ASP.NET's ProblemDetails instead, where `errors`
// is an OBJECT keyed by field - hence the Array.isArray guard.
import {
serializeCircuit,
circuitByteSize,
MAX_CIRCUIT_BYTES
} from '../shared/circuit-schema.js';
/** Thrown for any non-2xx response. `messages` is always a non-empty string array. */
export class ApiError extends Error {
constructor(status, messages) {
super(messages[0]);
this.name = 'ApiError';
this.status = status;
this.messages = messages;
}
}
async function readErrors(response) {
if (response.status === 413) {
return ['The circuit is too large to save. Remove some wires or components and try again.'];
}
let payload = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (payload && Array.isArray(payload.errors) && payload.errors.length > 0) {
return payload.errors.map(String);
}
// ProblemDetails: errors is an object of field -> string[]
if (payload && payload.errors && typeof payload.errors === 'object') {
const flattened = [];
for (const value of Object.values(payload.errors)) {
if (Array.isArray(value)) flattened.push(...value.map(String));
}
if (flattened.length > 0) return flattened;
}
if (payload && typeof payload.title === 'string') return [payload.title];
if (response.status === 401) return ['Your session has expired. Please sign in again.'];
if (response.status === 404) return ['This project no longer exists.'];
return [`The server returned an unexpected error (${response.status}).`];
}
async function request(url, options = {}) {
let response;
try {
response = await fetch(url, Object.assign({ credentials: 'same-origin' }, options));
} catch {
throw new ApiError(0, ['Could not reach the server. Check your connection and try again.']);
}
if (!response.ok) throw new ApiError(response.status, await readErrors(response));
if (response.status === 204) return null;
try {
return await response.json();
} catch {
return null;
}
}
const jsonHeaders = { 'Content-Type': 'application/json' };
export function createApi(baseUrl) {
const base = (baseUrl || '/api/breadboard').replace(/\/+$/, '');
return {
listProjects() {
return request(`${base}/projects`);
},
createProject(name, description) {
return request(`${base}/projects`, {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify(description ? { name, description } : { name })
});
},
getProject(id) {
return request(`${base}/projects/${encodeURIComponent(id)}`);
},
/**
* Save the circuit only. Omitting name/description leaves them untouched
* server-side, which is why this sends nothing else.
*
* The payload is rebuilt by serializeCircuit so no editor state can leak into
* the document, and its byte size is checked first: the count caps do NOT
* imply the 2 MB byte cap, so without this the user would meet it as a bare
* rejection with no explanation.
*/
saveCircuit(id, circuit) {
const payload = serializeCircuit(circuit);
const bytes = circuitByteSize(payload);
if (bytes > MAX_CIRCUIT_BYTES) {
const over = Math.ceil((bytes - MAX_CIRCUIT_BYTES) / 1024);
return Promise.reject(new ApiError(0, [
`This circuit is ${(bytes / 1048576).toFixed(2)} MB, which is ${over} KB over the 2 MB limit.`,
`It has ${payload.components.length} components and ${payload.wires.length} wires — removing some will bring it under.`
]));
}
return request(`${base}/projects/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: jsonHeaders,
body: JSON.stringify({ circuit: payload })
}).then(() => payload);
},
renameProject(id, name, description) {
const body = {};
if (name !== undefined) body.name = name;
if (description !== undefined) body.description = description;
return request(`${base}/projects/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: jsonHeaders,
body: JSON.stringify(body)
});
},
deleteProject(id) {
return request(`${base}/projects/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
};
}
@@ -0,0 +1,243 @@
// Static breadboard artwork, rasterized once and blitted.
//
// A board's face, holes, rails and silkscreen never change, so redrawing 830 holes per
// frame per board would be pure waste. This rasterizes ONE board bitmap and blits it
// for every board on the canvas - every board is visually identical, so the cache is a
// single image no matter how many boards the circuit has.
//
// CACHING POLICY (stated explicitly, since silence here is a bug waiting to happen):
// the bitmap is rasterized at a QUANTIZED scale, not at the exact zoom. Buckets are
// powers of two and we pick the smallest bucket at or above the current zoom, so a
// wheel gesture re-rasterizes at most a handful of times instead of on every tick. The
// cache is keyed by (scale bucket, palette version), so a theme change invalidates it.
//
// UP TO bucket 4 the blit only ever scales DOWN, which stays sharp. ABOVE it the art is
// magnified - viewport MAX_ZOOM is 6, so zoom 4-6 blits at up to 1.5x and the board
// looks mildly soft there. That is DELIBERATE, to bound memory: the bucket-4 bitmap is
// already 5120x1600 px, about 33 MB, and a bucket 8 would be ~131 MB. Slightly soft
// artwork at extreme zoom is the better trade. Note the 33 MB is allocated as soon as a
// user zooms past 2, and it is one bitmap total - every board blits from the same one.
import {
PITCH,
BOARD_COLUMNS,
BOARD_WIDTH,
BOARD_HEIGHT,
RAIL_HOLES,
MAIN_ROWS,
RAIL_NAMES,
CHANNEL_TOP,
CHANNEL_BOTTOM,
columnX,
rowY,
railHoleX,
railY
} from '../shared/board-geometry.js';
const SCALE_BUCKETS = Object.freeze([0.25, 0.5, 1, 2, 4]);
/**
* Smallest bucket at or above `zoom`. Below the top bucket the blit scales down; above
* it there is nothing sharper to pick, so the art is magnified. See the memory note at
* the top of this file for why the ladder stops at 4.
*/
export function scaleBucketFor(zoom) {
for (const bucket of SCALE_BUCKETS) {
if (zoom <= bucket) return bucket;
}
return SCALE_BUCKETS[SCALE_BUCKETS.length - 1];
}
function createSurface(width, height) {
if (typeof OffscreenCanvas === 'function') return new OffscreenCanvas(width, height);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/**
* Draw one board at 1:1 board-space scale. The caller has already applied the bucket
* scale to the context, so everything here is in board-space units.
*/
function paintBoard(ctx, colors) {
// Face
ctx.fillStyle = colors.boardFace;
roundRect(ctx, 0, 0, BOARD_WIDTH, BOARD_HEIGHT, PITCH * 0.4);
ctx.fill();
// A soft bevel along the top edge reads as moulded plastic without costing much.
const bevel = ctx.createLinearGradient(0, 0, 0, BOARD_HEIGHT);
bevel.addColorStop(0, colors.boardBevel);
bevel.addColorStop(0.06, colors.boardFace);
bevel.addColorStop(0.94, colors.boardFace);
bevel.addColorStop(1, colors.boardEdge);
ctx.fillStyle = bevel;
ctx.globalAlpha = 0.55;
roundRect(ctx, 0, 0, BOARD_WIDTH, BOARD_HEIGHT, PITCH * 0.4);
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = colors.boardEdge;
ctx.lineWidth = 1;
roundRect(ctx, 0.5, 0.5, BOARD_WIDTH - 1, BOARD_HEIGHT - 1, PITCH * 0.4);
ctx.stroke();
paintChannel(ctx, colors);
paintRailMarkings(ctx, colors);
paintHoles(ctx, colors);
paintSilkscreen(ctx, colors);
}
function paintChannel(ctx, colors) {
const height = CHANNEL_BOTTOM - CHANNEL_TOP;
const gradient = ctx.createLinearGradient(0, CHANNEL_TOP, 0, CHANNEL_BOTTOM);
gradient.addColorStop(0, colors.channelEdge);
gradient.addColorStop(0.35, colors.channel);
gradient.addColorStop(1, colors.channelEdge);
ctx.fillStyle = gradient;
ctx.fillRect(PITCH * 0.5, CHANNEL_TOP, BOARD_WIDTH - PITCH, height);
ctx.strokeStyle = colors.channelEdge;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PITCH * 0.5, CHANNEL_TOP + 0.5);
ctx.lineTo(BOARD_WIDTH - PITCH * 0.5, CHANNEL_TOP + 0.5);
ctx.moveTo(PITCH * 0.5, CHANNEL_BOTTOM - 0.5);
ctx.lineTo(BOARD_WIDTH - PITCH * 0.5, CHANNEL_BOTTOM - 0.5);
ctx.stroke();
}
/** The red/blue guide lines that run alongside each power rail. */
function paintRailMarkings(ctx, colors) {
const x0 = railHoleX(1) - PITCH * 0.7;
const x1 = railHoleX(RAIL_HOLES) + PITCH * 0.7;
for (const rail of RAIL_NAMES) {
const isPlus = rail.endsWith('Plus');
const y = railY(rail);
// The stripe sits on the outer side of its rail, as on a real board.
const outward = (rail === 'topPlus' || rail === 'bottomMinus') ? -1 : 1;
const lineY = y + outward * PITCH * 0.62;
ctx.strokeStyle = isPlus ? colors.railPlus : colors.railMinus;
ctx.globalAlpha = 0.75;
ctx.lineWidth = Math.max(1, PITCH * 0.08);
ctx.beginPath();
ctx.moveTo(x0, lineY);
ctx.lineTo(x1, lineY);
ctx.stroke();
ctx.globalAlpha = 1;
// + and - symbols at both ends
ctx.fillStyle = isPlus ? colors.railPlus : colors.railMinus;
for (const x of [x0 - PITCH * 0.55, x1 + PITCH * 0.55]) {
const arm = PITCH * 0.22;
ctx.lineWidth = Math.max(1, PITCH * 0.09);
ctx.strokeStyle = ctx.fillStyle;
ctx.beginPath();
ctx.moveTo(x - arm, lineY);
ctx.lineTo(x + arm, lineY);
if (isPlus) {
ctx.moveTo(x, lineY - arm);
ctx.lineTo(x, lineY + arm);
}
ctx.stroke();
}
}
}
function paintHoles(ctx, colors) {
const radius = PITCH * 0.17;
const inset = PITCH * 0.28;
const drawHole = (x, y) => {
// Square socket recess, then the round hole - reads as a real breadboard.
ctx.fillStyle = colors.holeRim;
ctx.fillRect(x - inset, y - inset, inset * 2, inset * 2);
ctx.fillStyle = colors.hole;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
};
for (let col = 1; col <= BOARD_COLUMNS; col++) {
const x = columnX(col);
for (const row of MAIN_ROWS) drawHole(x, rowY(row));
}
for (const rail of RAIL_NAMES) {
const y = railY(rail);
for (let i = 1; i <= RAIL_HOLES; i++) drawHole(railHoleX(i), y);
}
}
function paintSilkscreen(ctx, colors) {
ctx.fillStyle = colors.silk;
ctx.font = `${Math.round(PITCH * 0.42)}px system-ui, -apple-system, "Segoe UI", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Row letters, just outside the main grid at both ends.
for (const row of MAIN_ROWS) {
const y = rowY(row);
ctx.fillText(row, columnX(1) - PITCH * 0.75, y);
ctx.fillText(row, columnX(BOARD_COLUMNS) + PITCH * 0.75, y);
}
// Column numbers every 5 columns, above row a and below row j.
ctx.fillStyle = colors.silkStrong;
ctx.font = `${Math.round(PITCH * 0.38)}px system-ui, -apple-system, "Segoe UI", sans-serif`;
for (let col = 1; col <= BOARD_COLUMNS; col++) {
if (col !== 1 && col % 5 !== 0) continue;
const x = columnX(col);
ctx.fillText(String(col), x, rowY('a') - PITCH * 0.72);
ctx.fillText(String(col), x, rowY('j') + PITCH * 0.72);
}
}
/**
* Cache of rasterized board bitmaps, keyed by scale bucket and palette version.
* One entry serves every board in the circuit.
*/
export function createBoardArtCache() {
let cached = null; // { bucket, paletteVersion, surface }
return {
/**
* A board bitmap suitable for the given zoom, rasterizing only when the bucket
* or the theme has changed.
* @returns {{surface: (OffscreenCanvas|HTMLCanvasElement), bucket: number}}
*/
get(zoom, colors, paletteVersion) {
const bucket = scaleBucketFor(zoom);
if (cached !== null && cached.bucket === bucket && cached.paletteVersion === paletteVersion) {
return cached;
}
const surface = createSurface(
Math.ceil(BOARD_WIDTH * bucket),
Math.ceil(BOARD_HEIGHT * bucket)
);
const ctx = surface.getContext('2d');
ctx.save();
ctx.scale(bucket, bucket);
paintBoard(ctx, colors);
ctx.restore();
cached = { bucket, paletteVersion, surface };
return cached;
},
/** Drop the cached bitmap; the next get() re-rasterizes. */
invalidate() {
cached = null;
}
};
}
@@ -0,0 +1,536 @@
// Drawing routines for each component type.
//
// Everything here draws in WORLD space - the caller has already applied the
// device-pixel-ratio base transform and the pan/zoom world transform. One world unit
// is one board-space pixel, so PITCH is the hole spacing in the units used here.
//
// Colours come from the palette (CSS custom properties), never from literals, except
// LED lens colours which are a physical property of the part.
import {
PITCH,
holeWorldPos,
railY,
railHoleX,
RAIL_HOLES
} from '../shared/board-geometry.js';
import { getComponentDef, isChipType, ledColorHex } from '../shared/component-registry.js';
import { componentPinHoles } from '../shared/component-pins.js';
import { formatOhms } from './dom.js';
/**
* World positions of a component's pins.
* @returns {Array<{x:number,y:number}|null>} index-aligned with the pin list
*/
export function pinPositions(component, boards) {
return componentPinHoles(component).map(p => (p.hole === null ? null : holeWorldPos(p.hole, boards)));
}
/** Centre of a component's placed pins, or null when nothing is placed. */
export function componentCenter(component, boards) {
const points = pinPositions(component, boards).filter(p => p !== null);
if (points.length === 0) return null;
const sum = points.reduce((acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }), { x: 0, y: 0 });
return { x: sum.x / points.length, y: sum.y / points.length };
}
/**
* Axis-aligned world bounds of a component, padded to its drawn body.
* @returns {{x:number,y:number,w:number,h:number}|null}
*/
export function componentBounds(component, boards) {
const points = pinPositions(component, boards).filter(p => p !== null);
if (points.length === 0) return null;
const pad = PITCH * 0.5;
const xs = points.map(p => p.x);
const ys = points.map(p => p.y);
const x = Math.min(...xs) - pad;
const y = Math.min(...ys) - pad;
return { x, y, w: Math.max(...xs) + pad - x, h: Math.max(...ys) + pad - y };
}
function roundRect(ctx, x, y, w, h, r) {
const radius = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + w, y, x + w, y + h, radius);
ctx.arcTo(x + w, y + h, x, y + h, radius);
ctx.arcTo(x, y + h, x, y, radius);
ctx.arcTo(x, y, x + w, y, radius);
ctx.closePath();
}
function drawLead(ctx, from, to, colors) {
ctx.strokeStyle = colors.resistorLead;
ctx.lineWidth = PITCH * 0.09;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
}
// --- Per-type painters. Each receives already-resolved pin positions. ---
function drawLed(ctx, component, pins, colors, state) {
const [anode, cathode] = pins;
if (!anode || !cathode) return;
const lens = ledColorHex(component.props && component.props.color);
const mid = { x: (anode.x + cathode.x) / 2, y: (anode.y + cathode.y) / 2 };
const radius = PITCH * 0.36;
const burned = state.burned === true;
const brightness = burned ? 0 : Math.max(0, Math.min(1, state.brightness || 0));
drawLead(ctx, anode, cathode, colors);
// Emission halo, drawn under the lens so the lens stays readable.
if (brightness > 0.01) {
const glowRadius = radius * (2.2 + brightness * 2.4);
const glow = ctx.createRadialGradient(mid.x, mid.y, radius * 0.4, mid.x, mid.y, glowRadius);
glow.addColorStop(0, lens);
glow.addColorStop(1, 'transparent');
ctx.globalAlpha = 0.15 + brightness * 0.55;
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(mid.x, mid.y, glowRadius, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.fillStyle = burned ? colors.burned : lens;
ctx.globalAlpha = burned ? 1 : 0.55 + brightness * 0.45;
ctx.beginPath();
ctx.arc(mid.x, mid.y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
// Flat on the cathode side, as on a real LED.
const angle = Math.atan2(cathode.y - anode.y, cathode.x - anode.x);
ctx.strokeStyle = burned ? colors.burned : colors.silkStrong;
ctx.lineWidth = PITCH * 0.07;
ctx.beginPath();
ctx.arc(mid.x, mid.y, radius, angle - Math.PI / 3, angle + Math.PI / 3);
ctx.stroke();
if (burned) {
ctx.strokeStyle = colors.invalid;
ctx.lineWidth = PITCH * 0.1;
ctx.beginPath();
ctx.moveTo(mid.x - radius * 0.7, mid.y - radius * 0.7);
ctx.lineTo(mid.x + radius * 0.7, mid.y + radius * 0.7);
ctx.moveTo(mid.x + radius * 0.7, mid.y - radius * 0.7);
ctx.lineTo(mid.x - radius * 0.7, mid.y + radius * 0.7);
ctx.stroke();
}
}
function drawResistor(ctx, component, pins, colors, state, zoom) {
const [a, b] = pins;
if (!a || !b) return;
drawLead(ctx, a, b, colors);
const angle = Math.atan2(b.y - a.y, b.x - a.x);
const length = Math.hypot(b.x - a.x, b.y - a.y);
const bodyLength = Math.max(PITCH * 0.8, Math.min(length * 0.62, PITCH * 2.4));
const bodyHeight = PITCH * 0.44;
ctx.save();
ctx.translate((a.x + b.x) / 2, (a.y + b.y) / 2);
ctx.rotate(angle);
ctx.fillStyle = colors.resistorBody;
roundRect(ctx, -bodyLength / 2, -bodyHeight / 2, bodyLength, bodyHeight, bodyHeight * 0.45);
ctx.fill();
// Value bands. Decorative rather than an encoded E12 colour code, but they read
// instantly as a resistor at any zoom.
ctx.fillStyle = colors.silkStrong;
const bandWidth = bodyLength * 0.075;
for (let i = 0; i < 3; i++) {
const x = -bodyLength * 0.3 + i * bodyLength * 0.2;
ctx.fillRect(x, -bodyHeight / 2, bandWidth, bodyHeight);
}
// The value itself, only when there are enough device pixels to read it.
if (zoom > 1.1) {
ctx.fillStyle = colors.silkStrong;
ctx.font = `${PITCH * 0.36}px system-ui, -apple-system, "Segoe UI", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
const upsideDown = Math.abs(angle) > Math.PI / 2;
ctx.rotate(upsideDown ? Math.PI : 0);
ctx.fillText(formatOhms(component.props && component.props.ohms), 0, -bodyHeight * 0.75);
}
ctx.restore();
}
function drawDiode(ctx, component, pins, colors) {
const [anode, cathode] = pins;
if (!anode || !cathode) return;
drawLead(ctx, anode, cathode, colors);
const angle = Math.atan2(cathode.y - anode.y, cathode.x - anode.x);
const length = Math.hypot(cathode.x - anode.x, cathode.y - anode.y);
const bodyLength = Math.max(PITCH * 0.5, Math.min(length * 0.6, PITCH * 1.8));
const bodyHeight = PITCH * 0.34;
ctx.save();
ctx.translate((anode.x + cathode.x) / 2, (anode.y + cathode.y) / 2);
ctx.rotate(angle);
ctx.fillStyle = colors.diodeBody;
roundRect(ctx, -bodyLength / 2, -bodyHeight / 2, bodyLength, bodyHeight, bodyHeight * 0.3);
ctx.fill();
// The band marks the cathode, which after the rotation is always the +x end.
ctx.fillStyle = colors.diodeBand;
ctx.fillRect(bodyLength * 0.24, -bodyHeight / 2, bodyLength * 0.16, bodyHeight);
ctx.restore();
}
/**
* TO-92 package seen from above: a flat face with a domed back, sitting over its own
* three holes. Everything is drawn within half a pitch of the pin row so the body stays
* inside componentBounds, which is what hit-testing and dirty-rect culling use.
*/
function drawTransistor(ctx, component, pins, colors, zoom) {
const placed = pins.filter(p => p !== null);
if (placed.length < 3) return;
const xs = placed.map(p => p.x);
const ys = placed.map(p => p.y);
const x = Math.min(...xs);
const y = Math.min(...ys);
const w = Math.max(...xs) - x;
const cy = y + (Math.max(...ys) - y) / 2;
const pad = PITCH * 0.34;
const left = x - pad;
const right = x + w + pad;
const flatY = cy + PITCH * 0.42;
const backY = cy - PITCH * 0.52;
ctx.strokeStyle = colors.chipPin;
ctx.lineWidth = PITCH * 0.12;
ctx.lineCap = 'butt';
for (const pin of placed) {
ctx.beginPath();
ctx.moveTo(pin.x, flatY - PITCH * 0.1);
ctx.lineTo(pin.x, flatY + PITCH * 0.16);
ctx.stroke();
}
ctx.fillStyle = colors.transistorBody;
ctx.beginPath();
ctx.moveTo(left, flatY);
ctx.lineTo(right, flatY);
ctx.lineTo(right, backY + PITCH * 0.22);
ctx.quadraticCurveTo((left + right) / 2, backY - PITCH * 0.28, left, backY + PITCH * 0.22);
ctx.closePath();
ctx.fill();
const def = getComponentDef(component.type);
ctx.fillStyle = colors.chipLabel;
ctx.textAlign = 'center';
if (zoom > 1.4) {
// Leg letters, taken from the registry's pin names so they can never disagree
// with the netlist. They follow the pins, so a rotated part reads correctly.
ctx.font = `${PITCH * 0.26}px system-ui, -apple-system, "Segoe UI", sans-serif`;
ctx.textBaseline = 'bottom';
ctx.globalAlpha = 0.7;
for (let i = 0; i < 3; i++) {
if (pins[i]) ctx.fillText(def.pins[i].name[0].toUpperCase(), pins[i].x, flatY - PITCH * 0.08);
}
ctx.globalAlpha = 1;
}
if (zoom > 0.9) {
ctx.font = `${PITCH * 0.3}px system-ui, -apple-system, "Segoe UI", sans-serif`;
ctx.textBaseline = 'middle';
ctx.fillText(def.label, (left + right) / 2, backY + PITCH * 0.3);
}
}
function drawPushButton(ctx, component, pins, colors, state) {
const placed = pins.filter(p => p !== null);
if (placed.length < 4) return;
const xs = placed.map(p => p.x);
const ys = placed.map(p => p.y);
const x = Math.min(...xs);
const y = Math.min(...ys);
const w = Math.max(...xs) - x;
const h = Math.max(...ys) - y;
const pad = PITCH * 0.34;
for (const pin of placed) {
drawLead(ctx, pin, { x: x + w / 2, y: pin.y }, colors);
}
ctx.fillStyle = colors.buttonBody;
roundRect(ctx, x - pad, y - pad, w + pad * 2, h + pad * 2, PITCH * 0.18);
ctx.fill();
const pressed = state.pressed === true;
const cx = x + w / 2;
const cy = y + h / 2;
const capRadius = Math.min(w, h) * 0.42 + PITCH * 0.1;
ctx.fillStyle = pressed ? colors.buttonCapDown : colors.buttonCap;
ctx.beginPath();
ctx.arc(cx, cy, pressed ? capRadius * 0.88 : capRadius, 0, Math.PI * 2);
ctx.fill();
if (!pressed) {
ctx.strokeStyle = colors.buttonCapDown;
ctx.lineWidth = PITCH * 0.06;
ctx.beginPath();
ctx.arc(cx, cy, capRadius, 0, Math.PI * 2);
ctx.stroke();
}
}
function drawDipSwitch(ctx, component, pins, colors, state, zoom) {
const placed = pins.filter(p => p !== null);
if (placed.length < 16) return;
const xs = placed.map(p => p.x);
const ys = placed.map(p => p.y);
const x = Math.min(...xs);
const y = Math.min(...ys);
const w = Math.max(...xs) - x;
const h = Math.max(...ys) - y;
const pad = PITCH * 0.3;
ctx.fillStyle = colors.dipBody;
roundRect(ctx, x - pad, y - pad, w + pad * 2, h + pad * 2, PITCH * 0.12);
ctx.fill();
const on = (component.props && Array.isArray(component.props.on)) ? component.props.on : [];
const slotWidth = w / 7; // 8 switches across 7 column gaps
const slotHeight = h * 0.52;
for (let k = 0; k < 8; k++) {
// Switch k lives in the column of pins k+1 and 16-k.
const pin = pins[k];
if (!pin) continue;
const cx = pin.x;
const cy = y + h / 2;
const sw = slotWidth * 0.44;
ctx.fillStyle = colors.dipSwitchOff;
ctx.globalAlpha = 0.35;
roundRect(ctx, cx - sw / 2, cy - slotHeight / 2, sw, slotHeight, PITCH * 0.05);
ctx.fill();
ctx.globalAlpha = 1;
const isOn = on[k] === true;
ctx.fillStyle = isOn ? colors.dipSwitchOn : colors.dipSwitchOff;
const leverHeight = slotHeight * 0.42;
const leverY = isOn ? cy - slotHeight / 2 : cy + slotHeight / 2 - leverHeight;
roundRect(ctx, cx - sw / 2, leverY, sw, leverHeight, PITCH * 0.04);
ctx.fill();
}
if (zoom > 1.3) {
ctx.fillStyle = colors.dipSwitchOn;
ctx.globalAlpha = 0.75;
ctx.font = `${PITCH * 0.26}px system-ui, -apple-system, "Segoe UI", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (let k = 0; k < 8; k++) {
if (pins[k]) ctx.fillText(String(k + 1), pins[k].x, y - pad + PITCH * 0.04);
}
ctx.globalAlpha = 1;
}
}
function drawChip(ctx, component, pins, colors, state, zoom) {
const placed = pins.filter(p => p !== null);
if (placed.length < 14) return;
const xs = placed.map(p => p.x);
const ys = placed.map(p => p.y);
const x = Math.min(...xs);
const y = Math.min(...ys);
const w = Math.max(...xs) - x;
const h = Math.max(...ys) - y;
const padX = PITCH * 0.32;
const padY = PITCH * 0.55;
// Legs
ctx.strokeStyle = colors.chipPin;
ctx.lineWidth = PITCH * 0.13;
ctx.lineCap = 'butt';
for (const pin of placed) {
const towardBody = pin.y < y + h / 2 ? 1 : -1;
ctx.beginPath();
ctx.moveTo(pin.x, pin.y);
ctx.lineTo(pin.x, pin.y + towardBody * padY * 0.8);
ctx.stroke();
}
ctx.fillStyle = colors.chipBody;
roundRect(ctx, x - padX, y - padY * 0.15, w + padX * 2, h + padY * 0.3, PITCH * 0.1);
ctx.fill();
// Pin-1 notch, at the end where pin 1 actually is.
const pin1 = pins[0];
const notchAtLeft = pin1 && pin1.x <= x + w / 2;
const notchX = notchAtLeft ? x - padX : x + w + padX;
ctx.fillStyle = colors.chipLabel;
ctx.globalAlpha = 0.35;
ctx.beginPath();
ctx.arc(notchX, y + h / 2, PITCH * 0.22, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
// Pin-1 dot
if (pin1) {
ctx.fillStyle = colors.chipLabel;
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.arc(pin1.x, y + h / 2 - (pin1.y < y + h / 2 ? -1 : 1) * h * 0.28, PITCH * 0.1, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
if (zoom > 0.75) {
ctx.save();
ctx.translate(x + w / 2, y + h / 2);
// Row-'f' anchored chips are the package rotated 180 degrees; keep the text
// upright regardless so the part number stays readable.
ctx.fillStyle = colors.chipLabel;
ctx.font = `${PITCH * 0.42}px "SF Mono", ui-monospace, Consolas, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(component.type, 0, 0);
ctx.restore();
}
}
function drawPowerSupply(ctx, component, pins, colors, state, zoom, boards) {
const props = component.props || {};
const board = boards instanceof Map ? boards.get(props.board) : null;
if (!board) return;
const side = props.side === 'bottom' ? 'bottom' : 'top';
const plusY = railY(side === 'top' ? 'topPlus' : 'bottomPlus');
const minusY = railY(side === 'top' ? 'topMinus' : 'bottomMinus');
const midY = board.y + (plusY + minusY) / 2;
// Clipped to the right-hand end of the rails so it never covers holes.
const x = board.x + railHoleX(RAIL_HOLES) + PITCH * 1.4;
const w = PITCH * 2.6;
const h = PITCH * 1.7;
ctx.fillStyle = colors.supplyBody;
roundRect(ctx, x, midY - h / 2, w, h, PITCH * 0.2);
ctx.fill();
ctx.strokeStyle = colors.railPlus;
ctx.lineWidth = PITCH * 0.07;
ctx.stroke();
// Leads back to each rail
for (const [y, color] of [[board.y + plusY, colors.railPlus], [board.y + minusY, colors.railMinus]]) {
ctx.strokeStyle = color;
ctx.lineWidth = PITCH * 0.11;
ctx.beginPath();
ctx.moveTo(x, midY);
ctx.lineTo(board.x + railHoleX(RAIL_HOLES), y);
ctx.stroke();
}
if (zoom > 0.6) {
ctx.fillStyle = colors.supplyText;
ctx.font = `${PITCH * 0.5}px system-ui, -apple-system, "Segoe UI", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('5V', x + w / 2, midY);
}
}
/**
* Draw one component.
* @param {CanvasRenderingContext2D} ctx world-transformed context
* @param {object} component
* @param {Map<string,object>} boards
* @param {object} colors palette
* @param {object} state runtime state: { brightness, burned, pressed }
* @param {number} zoom current zoom, used to drop text detail when it would be unreadable
*/
export function drawComponent(ctx, component, boards, colors, state, zoom) {
const def = getComponentDef(component.type);
if (def === null) return;
const pins = pinPositions(component, boards);
if (isChipType(component.type)) {
drawChip(ctx, component, pins, colors, state, zoom);
return;
}
switch (component.type) {
case 'led': drawLed(ctx, component, pins, colors, state); break;
case 'resistor': drawResistor(ctx, component, pins, colors, state, zoom); break;
case 'diode': drawDiode(ctx, component, pins, colors); break;
case 'npn': case 'pnp': case 'nmos': case 'pmos':
drawTransistor(ctx, component, pins, colors, zoom);
break;
case 'pushButton': drawPushButton(ctx, component, pins, colors, state); break;
case 'dipSwitch8': drawDipSwitch(ctx, component, pins, colors, state, zoom); break;
case 'powerSupply5V': drawPowerSupply(ctx, component, pins, colors, state, zoom, boards); break;
default: break;
}
}
/**
* Draw a wire as a shallow arc, so overlapping wires stay distinguishable.
* @param {object} [options] levelColor paints a halo showing the net's logic level
*/
export function drawWire(ctx, from, to, color, options = {}) {
const dx = to.x - from.x;
const dy = to.y - from.y;
const length = Math.hypot(dx, dy);
// Perpendicular sag proportional to length, capped so long wires do not balloon.
const sag = Math.min(length * 0.14, PITCH * 2.2);
const mid = { x: (from.x + to.x) / 2 - (dy / (length || 1)) * sag, y: (from.y + to.y) / 2 + (dx / (length || 1)) * sag };
const stroke = () => {
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.quadraticCurveTo(mid.x, mid.y, to.x, to.y);
ctx.stroke();
};
ctx.lineCap = 'round';
if (options.levelColor) {
ctx.strokeStyle = options.levelColor;
ctx.globalAlpha = 0.5;
ctx.lineWidth = PITCH * 0.42;
stroke();
ctx.globalAlpha = 1;
}
ctx.strokeStyle = options.shadow || 'rgba(0,0,0,0.25)';
ctx.lineWidth = PITCH * 0.24;
ctx.save();
ctx.translate(0, PITCH * 0.06);
stroke();
ctx.restore();
ctx.strokeStyle = color;
ctx.lineWidth = PITCH * 0.2;
stroke();
// End collars, so a wire visibly plugs into its hole.
ctx.fillStyle = color;
for (const point of [from, to]) {
ctx.beginPath();
ctx.arc(point.x, point.y, PITCH * 0.15, 0, Math.PI * 2);
ctx.fill();
}
}
@@ -0,0 +1,116 @@
// Small DOM helpers for the breadboard editor.
//
// Everything user-provided reaches the page through textContent, never innerHTML -
// project names and server error strings are both untrusted as far as this file is
// concerned.
/**
* Create an element.
* @param {string} tag
* @param {object} [options] className, id, title, type, text, attrs, dataset, children
* @returns {HTMLElement}
*/
export function el(tag, options = {}) {
const node = document.createElement(tag);
if (options.className) node.className = options.className;
if (options.id) node.id = options.id;
if (options.title) node.title = options.title;
if (options.type) node.type = options.type;
if (options.text !== undefined) node.textContent = String(options.text);
if (options.attrs) {
for (const [key, value] of Object.entries(options.attrs)) {
if (value !== null && value !== undefined) node.setAttribute(key, String(value));
}
}
if (options.dataset) {
for (const [key, value] of Object.entries(options.dataset)) node.dataset[key] = String(value);
}
for (const child of options.children || []) {
if (child) node.appendChild(child);
}
return node;
}
/** Create a button that never submits a form. */
export function button(text, options = {}) {
return el('button', Object.assign({ type: 'button', text }, options));
}
/** Replace an element's contents with plain text. Safe for untrusted strings. */
export function setText(node, text) {
node.textContent = text === null || text === undefined ? '' : String(text);
}
/** Remove every child of an element. */
export function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
/**
* Collects event listeners so they can all be removed in one call. Every listener in
* the editor goes through this - untracked listeners leak when the page is torn down.
*/
export function createListenerBag() {
const entries = [];
return {
/** @returns {Function} a function that removes just this listener */
on(target, type, handler, options) {
target.addEventListener(type, handler, options);
const entry = { target, type, handler, options };
entries.push(entry);
return () => {
target.removeEventListener(type, handler, options);
const i = entries.indexOf(entry);
if (i !== -1) entries.splice(i, 1);
};
},
removeAll() {
for (const e of entries) e.target.removeEventListener(e.type, e.handler, e.options);
entries.length = 0;
}
};
}
/**
* Trailing-edge debounce. `cancel()` drops a pending call, `flush()` runs it now.
*/
export function debounce(fn, delay) {
let timer = null;
let pendingArgs = null;
const wrapped = (...args) => {
pendingArgs = args;
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const a = pendingArgs;
pendingArgs = null;
fn(...a);
}, delay);
};
wrapped.cancel = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
pendingArgs = null;
};
wrapped.flush = () => {
if (timer === null) return;
clearTimeout(timer);
timer = null;
const a = pendingArgs;
pendingArgs = null;
fn(...a);
};
return wrapped;
}
/** Format a resistance for display: 220 -> "220 Ω", 4700 -> "4.7 kΩ". */
export function formatOhms(ohms) {
if (!Number.isFinite(ohms)) return '—';
if (ohms >= 1000000) return `${trimZeros(ohms / 1000000)}`;
if (ohms >= 1000) return `${trimZeros(ohms / 1000)}`;
return `${trimZeros(ohms)} Ω`;
}
function trimZeros(value) {
return String(Number(value.toFixed(2)));
}
@@ -0,0 +1,380 @@
// Editor document state: the circuit, the selection, and what has changed.
//
// DIRTY TRACKING (amendment A9). The circuit is stored in PostgreSQL as jsonb, which
// normalizes object key order and whitespace, so the bytes we send are never the bytes
// that come back. Comparing serialized strings across a save therefore reports a
// document as dirty the moment it is reloaded - and with an autosave that is a loop
// that never settles. So:
// - the baseline is a deep clone of exactly what we last SENT (never a re-GET),
// - comparison is structural, over a canonical form with sorted keys,
// - and no re-GET happens after a save, since PUT returns 204 with no body.
//
// UID ALLOCATION. One long-lived allocator lives here, seeded from the loaded
// document. Allocation is editor session state, not a property of the document, so
// nothing in this module calls shared/'s nextUid.
import {
createUidAllocator,
createWire,
addBoard as schemaAddBoard,
removeBoard as schemaRemoveBoard,
boardsByUid,
allUids,
validateCircuit,
MAX_COMPONENTS,
MAX_WIRES,
MAX_BOARDS,
DEFAULT_WIRE_COLOR,
orientFor
} from '../shared/circuit-schema.js';
import { getComponentDef, dipRowForOrient, defaultPropsFor } from '../shared/component-registry.js';
import { isFullyPlaced, componentSelfShorts } from '../shared/component-pins.js';
import { holeKey, sameHole } from '../shared/board-geometry.js';
/** Uid carried by the placement preview; never added to the circuit. */
const GHOST_UID = '__ghost__';
/** Stable stringification: object keys sorted, so key order never affects equality. */
function canonical(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
const keys = Object.keys(value).sort();
return `{${keys.map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
}
export function createEditorState(initialCircuit) {
let circuit = initialCircuit;
let baseline = canonical(initialCircuit);
let allocator = createUidAllocator(allUids(initialCircuit));
let boards = boardsByUid(circuit);
const selection = new Set();
const listeners = new Set();
/** Components the user is physically holding down right now. Never persisted. */
const pressed = new Set();
function notify(change) {
for (const listener of listeners) listener(change);
}
function structureChanged() {
boards = boardsByUid(circuit);
notify({ kind: 'circuit' });
}
return {
get circuit() { return circuit; },
get boards() { return boards; },
get selection() { return selection; },
get pressed() { return pressed; },
get dirty() { return canonical(circuit) !== baseline; },
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
/**
* Mark the document saved. Pass the EXACT payload that was sent - the baseline
* must be what we sent, never a re-read of the server's copy.
*/
markSaved(sentPayload) {
baseline = canonical(sentPayload);
notify({ kind: 'saved' });
},
/** Replace the whole document, e.g. after a load. */
replace(nextCircuit) {
circuit = nextCircuit;
baseline = canonical(nextCircuit);
allocator = createUidAllocator(allUids(nextCircuit));
selection.clear();
pressed.clear();
structureChanged();
},
// --- Selection ---
select(uid, additive = false) {
if (!additive) selection.clear();
if (uid !== null && uid !== undefined) selection.add(uid);
notify({ kind: 'selection' });
},
toggleSelect(uid) {
if (selection.has(uid)) selection.delete(uid);
else selection.add(uid);
notify({ kind: 'selection' });
},
clearSelection() {
if (selection.size === 0) return;
selection.clear();
notify({ kind: 'selection' });
},
findSelectedComponent() {
if (selection.size !== 1) return null;
const uid = [...selection][0];
return circuit.components.find(c => c.uid === uid) || null;
},
// --- Components ---
/**
* Build a component of the given type at an anchor, without adding it. Used for
* the placement ghost so the preview is the real thing.
*/
buildComponent(type, anchor, extraProps) {
const def = getComponentDef(type);
if (def === null) return null;
// Deliberately does NOT allocate a uid. The placement ghost is rebuilt on
// every pointer move, and uid allocation is O(document) - doing it here
// would scan the whole circuit once per mousemove.
const component = {
uid: GHOST_UID,
type,
props: defaultPropsFor(type)
};
if (extraProps) Object.assign(component.props, extraProps);
if (!def.anchorless) component.anchor = anchor;
if (def.orientable) component.orient = orientFor(def, anchor, def.defaultOrient);
return component;
},
/**
* Add a component. Returns { ok, component, reason, warnings }.
* `warnings` is advisory - a self-shorted part is legal but useless.
*/
addComponent(type, anchor, extraProps) {
if (circuit.components.length >= MAX_COMPONENTS) {
return { ok: false, reason: `A circuit can hold at most ${MAX_COMPONENTS} components.` };
}
const def = getComponentDef(type);
if (def === null) return { ok: false, reason: `Unknown component type "${type}".` };
const component = {
uid: allocator.next('c'),
type,
props: defaultPropsFor(type) // deep-copies array defaults
};
if (extraProps) Object.assign(component.props, extraProps);
if (!def.anchorless) component.anchor = anchor;
if (def.orientable) component.orient = orientFor(def, anchor, def.defaultOrient);
if (!isFullyPlaced(component)) {
return { ok: false, reason: `A ${def.label} does not fit there — part of it would hang off the board.` };
}
circuit.components.push(component);
const check = validateCircuit(circuit);
if (!check.ok) {
circuit.components.pop();
return { ok: false, reason: check.errors[0] };
}
const warnings = [];
if (componentSelfShorts(component).length > 0) {
warnings.push(`This ${def.label} has both ends in the same connected strip, so it will have no effect.`);
}
structureChanged();
return { ok: true, component, warnings };
},
/** Move a component to a new anchor. Returns { ok, reason }. */
moveComponent(uid, anchor) {
const component = circuit.components.find(c => c.uid === uid);
if (!component) return { ok: false, reason: 'That component no longer exists.' };
const def = getComponentDef(component.type);
if (def.anchorless) return { ok: false, reason: `A ${def.label} is moved by changing its rail, not by dragging.` };
const previousAnchor = component.anchor;
const previousOrient = component.orient;
component.anchor = anchor;
if (def.orientable) component.orient = orientFor(def, anchor, component.orient);
if (!isFullyPlaced(component) || !validateCircuit(circuit).ok) {
component.anchor = previousAnchor;
component.orient = previousOrient;
return { ok: false, reason: `A ${def.label} does not fit there.` };
}
structureChanged();
return { ok: true };
},
/**
* Rotate a component. For DIP-style packages this flips the anchor across the
* center channel, which IS the rotation; for an LED it cycles the orient.
*/
rotateComponent(uid) {
const component = circuit.components.find(c => c.uid === uid);
if (!component) return { ok: false, reason: 'That component no longer exists.' };
const def = getComponentDef(component.type);
if (!def.orientable) return { ok: false, reason: `A ${def.label} cannot be rotated.` };
const previousAnchor = component.anchor;
const previousOrient = component.orient;
if (def.dipStyle) {
const nextOrient = component.orient === 'right' ? 'left' : 'right';
component.anchor = Object.assign({}, component.anchor, { row: dipRowForOrient(nextOrient) });
component.orient = nextOrient;
} else {
const values = def.orientValues;
const index = values.indexOf(component.orient);
component.orient = values[(index + 1) % values.length];
}
if (!isFullyPlaced(component) || !validateCircuit(circuit).ok) {
component.anchor = previousAnchor;
component.orient = previousOrient;
return { ok: false, reason: `A ${def.label} does not fit in that orientation here.` };
}
structureChanged();
return { ok: true };
},
/** Update a component's props. Reverts and explains if the result is invalid. */
setComponentProps(uid, changes) {
const component = circuit.components.find(c => c.uid === uid);
if (!component) return { ok: false, reason: 'That component no longer exists.' };
const previous = Object.assign({}, component.props);
Object.assign(component.props, changes);
const check = validateCircuit(circuit);
if (!check.ok || !isFullyPlaced(component)) {
component.props = previous;
return { ok: false, reason: check.ok ? 'That change would move a pin off the board.' : check.errors[0] };
}
structureChanged();
return { ok: true };
},
/** Toggle one switch of a DIP package. */
toggleSwitch(uid, switchNumber) {
const component = circuit.components.find(c => c.uid === uid);
if (!component || component.type !== 'dipSwitch8') return null;
const on = Array.isArray(component.props.on) ? component.props.on.slice() : new Array(8).fill(false);
const index = switchNumber - 1;
if (index < 0 || index >= on.length) return null;
on[index] = !on[index];
component.props.on = on;
structureChanged();
return on[index];
},
setPressed(uid, isPressed) {
if (isPressed) pressed.add(uid);
else pressed.delete(uid);
notify({ kind: 'runtime' });
},
// --- Wires ---
/** Add a wire. Returns { ok, wire, reason }. */
addWire(from, to, color) {
if (circuit.wires.length >= MAX_WIRES) {
return { ok: false, reason: `A circuit can hold at most ${MAX_WIRES} wires.` };
}
if (sameHole(from, to)) {
return { ok: false, reason: 'A wire needs two different holes.' };
}
const duplicate = circuit.wires.some(w =>
(sameHole(w.from, from) && sameHole(w.to, to)) || (sameHole(w.from, to) && sameHole(w.to, from)));
if (duplicate) return { ok: false, reason: 'Those holes are already joined by a wire.' };
const wire = {
uid: allocator.next('w'),
from,
to,
color: color || DEFAULT_WIRE_COLOR
};
circuit.wires.push(wire);
const check = validateCircuit(circuit);
if (!check.ok) {
circuit.wires.pop();
return { ok: false, reason: check.errors[0] };
}
structureChanged();
return { ok: true, wire };
},
setWireColor(uid, color) {
const wire = circuit.wires.find(w => w.uid === uid);
if (!wire) return false;
wire.color = color;
structureChanged();
return true;
},
// --- Deletion ---
/** Delete everything selected. Returns the number of items removed. */
deleteSelected() {
if (selection.size === 0) return 0;
const before = circuit.components.length + circuit.wires.length;
circuit.components = circuit.components.filter(c => !selection.has(c.uid));
circuit.wires = circuit.wires.filter(w => !selection.has(w.uid));
const removed = before - (circuit.components.length + circuit.wires.length);
if (removed > 0) {
selection.clear();
structureChanged();
}
return removed;
},
// --- Boards ---
addBoard() {
if (circuit.boards.length >= MAX_BOARDS) {
return { ok: false, reason: `A circuit can hold at most ${MAX_BOARDS} boards.` };
}
const board = schemaAddBoard(circuit);
if (board === null) return { ok: false, reason: 'Could not add another board.' };
allocator.claim(board.uid, 'b');
structureChanged();
return { ok: true, board };
},
/** Remove a board and everything on it. Returns { ok, reason, removed }. */
removeBoard(uid) {
if (circuit.boards.length <= 1) {
return { ok: false, reason: 'A circuit needs at least one board.' };
}
const before = circuit.components.length + circuit.wires.length;
if (!schemaRemoveBoard(circuit, uid)) {
return { ok: false, reason: 'That board no longer exists.' };
}
const removed = before - (circuit.components.length + circuit.wires.length);
selection.clear();
structureChanged();
return { ok: true, removed };
},
moveBoard(uid, x, y) {
const board = circuit.boards.find(b => b.uid === uid);
if (!board) return false;
board.x = x;
board.y = y;
structureChanged();
return true;
},
/** Component or wire whose uid matches, or null. */
findByUid(uid) {
return circuit.components.find(c => c.uid === uid)
|| circuit.wires.find(w => w.uid === uid)
|| null;
},
/** Wire whose either end is at the given hole, or null. */
wireAtHole(hole) {
const key = holeKey(hole);
return circuit.wires.find(w => holeKey(w.from) === key || holeKey(w.to) === key) || null;
}
};
}
export { canonical };
@@ -0,0 +1,474 @@
// Breadboard editor entry point.
//
// Boots into <div id="breadboard-editor" data-project-id data-project-name
// data-api-base>, which the Razor page renders empty - all chrome is built here.
//
// Everything user-supplied (project name, server messages, engine detail strings)
// reaches the DOM through textContent via the dom.js helpers. Nothing uses innerHTML.
import { el, createListenerBag, debounce } from './dom.js';
import { createPalette as createThemePalette } from './theme-colors.js';
import { createViewport } from './viewport.js';
import { createRenderer } from './renderer.js';
import { createEditorState } from './editor-state.js';
import { createTools } from './tools.js';
import { createToolbar } from './toolbar.js';
import { createPalette } from './palette.js';
import { createProperties } from './properties.js';
import { createStatus } from './status.js';
import { createApi, ApiError } from './api.js';
import { createSimClient } from './sim-client.js';
import { describeServerErrors } from './server-errors.js';
import {
normalizeCircuitWithReport,
createCircuit,
circuitBounds
} from '../shared/circuit-schema.js';
import { boardBounds } from '../shared/board-geometry.js';
/** How long after the last edit the simulation is re-loaded. */
const SIM_RELOAD_DEBOUNCE_MS = 350;
/** How long after the last edit an autosave fires. */
const AUTOSAVE_DEBOUNCE_MS = 4000;
const VIEW_STORAGE_PREFIX = 'bb-view-';
function readView(projectId) {
try {
const raw = localStorage.getItem(VIEW_STORAGE_PREFIX + projectId);
return raw ? JSON.parse(raw) : null;
} catch {
return null; // private mode, quota, corrupt entry - the view is optional
}
}
function writeView(projectId, view) {
try {
localStorage.setItem(VIEW_STORAGE_PREFIX + projectId, JSON.stringify(view));
} catch {
// Losing the remembered viewport is not worth surfacing.
}
}
export function boot(root) {
const projectId = root.dataset.projectId;
const apiBase = root.dataset.apiBase || '/api/breadboard';
// Shell
const toolbarHost = el('div', { className: 'bb-toolbar-host' });
const canvasContainer = el('div', { className: 'bb-canvas-container' });
const paletteHost = el('div', { className: 'bb-palette-host' });
const propsHost = el('div', { className: 'bb-props-host' });
const statusHost = el('div', { className: 'bb-status-host', id: 'bb-warnings' });
const workspace = el('div', {
className: 'bb-workspace',
children: [paletteHost, canvasContainer, propsHost]
});
const shell = el('div', { className: 'bb-editor-shell', children: [toolbarHost, workspace] });
root.appendChild(shell);
// The status strip floats over the bottom of the canvas rather than sitting below
// it in the column. In the column, every message that appeared or timed out resized
// the canvas container, which reallocated all four backing stores and forced a full
// repaint - twice per message.
canvasContainer.appendChild(statusHost);
const status = createStatus(statusHost);
if (!projectId) {
status.error('This page did not receive a project id, so there is nothing to edit.');
return { destroy() { status.destroy(); root.removeChild(shell); } };
}
const bag = createListenerBag();
const api = createApi(apiBase);
const themePalette = createThemePalette(root);
const viewport = createViewport();
const renderer = createRenderer(canvasContainer, viewport, themePalette, {
onPaintError(layer, message) {
// A blank canvas with no explanation is the worst possible failure mode.
status.error(`The ${layer} layer failed to draw: ${message}`);
}
});
const state = createEditorState(createCircuit());
let destroyed = false;
let saving = false;
/** Pending properties-panel rebuild. See scheduleProperties. */
let propertiesFrame = null;
let simLoadedOnce = false;
/** Components named by a simulation warning, highlighted on the canvas. */
const warnedUids = new Set();
/**
* Record the components a warning blames, so the overlay can point at them.
*
* Single rule for BOTH the load-time warnings array and the streaming warning
* message: warningsSuppressed is a coalescing summary rather than a fault on any
* component, so it must never paint a highlight. Enforcing it in one place stops
* the two paths from disagreeing.
*/
function addWarnedUids(warning) {
if (!warning || warning.kind === 'warningsSuppressed') return;
for (const uid of warning.uids || []) warnedUids.add(uid);
}
// --- Simulation ---
const sim = createSimClient({
loaded({ netCount, warnings, failed }) {
simLoadedOnce = true;
if (!failed) warnedUids.clear();
for (const warning of warnings) addWarnedUids(warning);
// A failed load still posts `loaded` so nothing waits forever. Keep the
// error that came with it instead of clearing the list and announcing
// success, and do not report the failure a second time.
if (!failed) status.clearWarnings();
for (const warning of warnings) status.addWarning(warning);
refreshSimState();
pushSimScene('dynamic', 'overlay');
if (!failed && netCount > 0) {
status.info(`Simulation ready — ${netCount} net${netCount === 1 ? '' : 's'}.`);
}
},
frame() {
pushSimScene();
refreshSimState();
},
warning(warning) {
status.addWarning(warning);
addWarnedUids(warning);
// Repaint here rather than waiting for an unrelated message: shortCircuit
// and oscillation HALT the engine, so there may be no further frame at all -
// and those are exactly the warnings whose components most need pointing at.
pushSimScene('dynamic', 'overlay');
// A halt is worth an explicit message; the engine refuses to run into it.
if (warning.kind === 'shortCircuit') {
status.error('Simulation halted: the supply rails are shorted together.');
} else if (warning.kind === 'oscillation') {
status.warn('Simulation halted: the circuit oscillates without settling.');
}
refreshSimState();
},
unavailable(message) {
status.warn(`Simulation is unavailable: ${message}`);
refreshSimState();
}
});
/**
* Hand the current simulation state to the renderer.
*
* Defaults to the dynamic layer alone. The overlay only shows simulation state
* through `warnedUids`, which changes on a warning - not on every frame - so
* callers that touch warnedUids pass 'dynamic', 'overlay' explicitly.
*/
function pushSimScene(...layers) {
renderer.setScene({
netLevels: sim.state.netLevels,
netOfStrip: sim.state.netOfStrip,
ledBrightness: sim.state.ledBrightness,
burned: sim.state.burned,
warnedUids,
simActive: sim.state.loaded
}, ...(layers.length > 0 ? layers : ['dynamic']));
}
/**
* Rebuild the properties panel at most once per frame. render() throws away and
* rebuilds the whole panel, and a component drag fires a circuit change on every
* pointermove, so calling it directly meant a full DOM teardown per mouse move.
*/
function scheduleProperties() {
if (propertiesFrame !== null) return;
propertiesFrame = requestAnimationFrame(() => {
propertiesFrame = null;
if (!destroyed) properties.render(state);
});
}
function refreshSimState() {
toolbar.setSimState({
available: sim.available,
running: sim.state.running,
settled: sim.state.settled,
halted: sim.state.halted,
loaded: simLoadedOnce
});
}
const reloadSim = debounce(() => {
if (destroyed || !sim.available) return;
warnedUids.clear();
sim.load(state.circuit);
}, SIM_RELOAD_DEBOUNCE_MS);
// --- Persistence ---
function updateSaveState() {
if (saving) {
toolbar.setSaveState('saving', 'Saving…');
} else if (state.dirty) {
toolbar.setSaveState('dirty', 'Unsaved changes');
} else {
toolbar.setSaveState('clean', 'Saved');
}
}
async function save() {
if (destroyed || saving || !state.dirty) return;
saving = true;
updateSaveState();
try {
// The reporting form, so anything the document cannot carry is surfaced
// rather than silently dropped on the way out.
// Neutral lead-in: some problems are renames, which ARE saved, just under
// a different id. "Not saved" would be false for those.
const { problems } = normalizeCircuitWithReport(state.circuit);
for (const problem of problems) status.warn(`On save: ${problem.reason}`);
const sent = await api.saveCircuit(projectId, state.circuit);
state.markSaved(sent);
status.info('Saved.');
} catch (error) {
if (error instanceof ApiError) {
status.errors(describeServerErrors(error.messages));
} else {
status.error('Saving failed unexpectedly.');
}
} finally {
saving = false;
updateSaveState();
}
}
const autosave = debounce(() => {
if (state.dirty && !saving) save();
}, AUTOSAVE_DEBOUNCE_MS);
// --- Chrome ---
const toolbar = createToolbar(toolbarHost, {
projectName: root.dataset.projectName || 'Breadboard',
onSave: save,
onRun: () => { sim.run(); refreshSimState(); },
onPause: () => { sim.pause(); refreshSimState(); },
onStep: () => sim.step(1),
onReset: () => {
// Clear BEFORE issuing the command: sending it can itself fail (an
// unclonable payload throws in postMessage), and clearing afterwards would
// wipe the very error the reset produced.
warnedUids.clear();
status.clearWarnings();
sim.reset();
status.info('Simulation reset.');
pushSimScene('dynamic', 'overlay');
},
onSpeed: (eventsPerSecond) => sim.setSpeed(eventsPerSecond),
onAddBoard: () => {
const result = state.addBoard();
if (!result.ok) status.warn(result.reason);
else status.info(`Added board ${result.board.uid}.`);
},
onZoom: (factor) => {
viewport.zoomAtCenter(renderer.width, renderer.height, factor);
afterViewportChange();
},
onZoomFit: () => fitAll()
});
const palette = createPalette(paletteHost, {
onTool: (tool) => {
tools.setTool(tool);
palette.setActiveTool(tool);
},
onWireColor: (color) => tools.setWireColor(color)
});
const properties = createProperties(propsHost, {
onChangeProps: (uid, changes) => {
const result = state.setComponentProps(uid, changes);
if (!result.ok) status.warn(result.reason);
},
onToggleSwitch: (uid, switchNumber) => {
const on = state.toggleSwitch(uid, switchNumber);
if (on !== null) sim.setSwitch(uid, switchNumber, on);
},
onRotate: (uid) => {
const result = state.rotateComponent(uid);
if (!result.ok) status.warn(result.reason);
},
onDelete: (uid) => {
state.select(uid);
const removed = state.deleteSelected();
if (removed > 0) status.info('Deleted.');
},
onDeleteSelection: () => {
const removed = state.deleteSelected();
if (removed > 0) status.info(`Deleted ${removed} items.`);
},
onFocusBoard: (uid) => {
const board = state.circuit.boards.find(b => b.uid === uid);
if (!board) return;
viewport.fit(boardBounds(board), renderer.width, renderer.height);
afterViewportChange();
},
onRemoveBoard: (uid) => {
const result = state.removeBoard(uid);
if (!result.ok) status.warn(result.reason);
else status.info(`Removed board ${uid}${result.removed > 0 ? ` and ${result.removed} item(s) on it` : ''}.`);
}
});
const tools = createTools({
canvas: renderer.canvas,
viewport,
state,
renderer,
sim,
status,
initialWireColor: palette.wireColor,
setTool: (tool) => {
tools.setTool(tool);
palette.setActiveTool(tool);
},
onSceneChange: () => {
toolbar.setZoom(viewport.zoom);
persistView();
},
onSelectionChange: () => scheduleProperties()
});
palette.setActiveTool({ kind: 'select', type: null });
// --- Wiring ---
const persistView = debounce(() => writeView(projectId, viewport.toJSON()), 400);
function afterViewportChange() {
renderer.viewportChanged();
toolbar.setZoom(viewport.zoom);
persistView();
}
function fitAll() {
viewport.fit(circuitBounds(state.circuit), renderer.width, renderer.height);
afterViewportChange();
}
function onContainerResize() {
tools.invalidateRect();
renderer.resize();
}
bag.on(window, 'resize', onContainerResize);
const resizeObserver = typeof ResizeObserver === 'function'
? new ResizeObserver(onContainerResize)
: null;
if (resizeObserver) resizeObserver.observe(canvasContainer);
bag.on(window, 'keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && (event.key === 's' || event.key === 'S')) {
event.preventDefault();
save();
}
});
bag.on(window, 'beforeunload', (event) => {
if (!state.dirty) return;
event.preventDefault();
event.returnValue = '';
});
state.subscribe((change) => {
if (change.kind === 'circuit') {
tools.refresh('board', 'static', 'dynamic', 'overlay');
scheduleProperties();
reloadSim();
autosave();
} else if (change.kind === 'runtime') {
tools.refresh('dynamic', 'overlay');
}
updateSaveState();
});
// --- Load ---
async function load() {
try {
const project = await api.getProject(projectId);
if (destroyed) return;
toolbar.setProjectName(project.name);
if (project.name) document.title = `${project.name} — Breadboard`;
const { circuit, problems } = normalizeCircuitWithReport(project.circuit);
state.replace(circuit);
// Nothing the document could not carry is allowed to vanish quietly.
for (const problem of problems) status.warn(problem.reason);
if (problems.length > 0) {
status.warn(`${problems.length} item${problems.length === 1 ? '' : 's'} in the saved circuit could not be loaded exactly as stored.`);
}
renderer.resize();
const savedView = readView(projectId);
if (!viewport.restore(savedView)) fitAll();
else afterViewportChange();
tools.refresh('board', 'static', 'dynamic', 'overlay');
scheduleProperties();
updateSaveState();
if (sim.start()) sim.load(circuit);
sim.setSpeed(toolbar.initialSpeed);
refreshSimState();
} catch (error) {
if (error instanceof ApiError) status.errors(describeServerErrors(error.messages));
else status.error('The project could not be loaded.');
toolbar.setSaveState('error', 'Not loaded');
}
}
load();
return {
destroy() {
destroyed = true;
reloadSim.cancel();
autosave.cancel();
persistView.cancel();
if (propertiesFrame !== null) cancelAnimationFrame(propertiesFrame);
bag.removeAll();
if (resizeObserver) resizeObserver.disconnect();
tools.destroy();
properties.destroy();
palette.destroy();
toolbar.destroy();
renderer.destroy();
themePalette.destroy();
sim.destroy();
status.destroy();
if (shell.parentNode) shell.parentNode.removeChild(shell);
}
};
}
// Auto-boot. Module scripts are deferred, so the DOM is normally parsed by now, but
// guard anyway rather than assuming either way.
function start() {
const root = document.getElementById('breadboard-editor');
if (root && !root.dataset.booted) {
root.dataset.booted = 'true';
boot(root);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
}
@@ -0,0 +1,127 @@
// Component palette and tool selection.
//
// Driven entirely by the shared component registry, so a new component type appears
// here without touching this file.
import { el, button, createListenerBag } from './dom.js';
import { COMPONENT_TYPES, getComponentDef } from '../shared/component-registry.js';
import { WIRE_COLORS, DEFAULT_WIRE_COLOR } from '../shared/circuit-schema.js';
const CATEGORY_ORDER = Object.freeze(['passive', 'semiconductor', 'output', 'input', 'power', 'chip']);
const CATEGORY_LABELS = Object.freeze(Object.assign(Object.create(null), {
passive: 'Passive',
semiconductor: 'Semiconductor',
output: 'Output',
input: 'Input',
power: 'Power',
chip: 'Logic'
}));
export function createPalette(root, handlers) {
const bag = createListenerBag();
const toolButtons = new Map();
function toolButton(key, label, title) {
const node = button(label, { className: 'bb-tool', title });
node.dataset.tool = key;
toolButtons.set(key, node);
return node;
}
const selectButton = toolButton('select', 'Select', 'Select, move and rotate parts (Esc)');
const wireButton = toolButton('wire', 'Wire', 'Drag from hole to hole to run a wire');
const colorSwatches = el('div', { className: 'bb-swatches' });
let activeColor = DEFAULT_WIRE_COLOR;
const swatchNodes = new Map();
for (const color of WIRE_COLORS) {
const swatch = button('', {
className: 'bb-swatch',
title: `Wire colour ${color}`,
attrs: { 'aria-label': `Wire colour ${color}` }
});
swatch.style.background = color;
swatchNodes.set(color, swatch);
bag.on(swatch, 'click', () => {
setColor(color);
handlers.onWireColor(color);
});
colorSwatches.appendChild(swatch);
}
function setColor(color) {
activeColor = color;
for (const [value, node] of swatchNodes) node.classList.toggle('is-active', value === color);
}
setColor(DEFAULT_WIRE_COLOR);
const sections = [
el('div', {
className: 'bb-palette-section',
children: [
el('h2', { className: 'bb-palette-heading', text: 'Tools' }),
el('div', { className: 'bb-palette-grid', children: [selectButton, wireButton] }),
colorSwatches
]
})
];
// Component buttons, grouped by registry category.
const byCategory = new Map();
for (const type of COMPONENT_TYPES) {
const def = getComponentDef(type);
if (!byCategory.has(def.category)) byCategory.set(def.category, []);
byCategory.get(def.category).push(def);
}
for (const category of CATEGORY_ORDER) {
const defs = byCategory.get(category);
if (!defs || defs.length === 0) continue;
const grid = el('div', { className: 'bb-palette-grid' });
for (const def of defs) {
const node = toolButton(`place:${def.type}`, def.label, def.description || def.label);
node.classList.add('bb-tool-component');
grid.appendChild(node);
}
sections.push(el('div', {
className: 'bb-palette-section',
children: [
el('h2', { className: 'bb-palette-heading', text: CATEGORY_LABELS[category] || category }),
grid
]
}));
}
const hint = el('p', {
className: 'bb-palette-hint',
text: 'Space or middle-drag pans. Scroll to zoom. R rotates, Delete removes.'
});
sections.push(hint);
const panel = el('aside', { className: 'bb-palette', children: sections });
root.appendChild(panel);
for (const [key, node] of toolButtons) {
bag.on(node, 'click', () => {
if (key === 'select') handlers.onTool({ kind: 'select', type: null });
else if (key === 'wire') handlers.onTool({ kind: 'wire', type: null });
else handlers.onTool({ kind: 'place', type: key.slice('place:'.length) });
});
}
return {
get wireColor() { return activeColor; },
setActiveTool(tool) {
const key = tool.kind === 'place' ? `place:${tool.type}` : tool.kind;
for (const [name, node] of toolButtons) node.classList.toggle('is-active', name === key);
},
setWireColor: setColor,
destroy() {
bag.removeAll();
if (panel.parentNode) panel.parentNode.removeChild(panel);
}
};
}
@@ -0,0 +1,248 @@
// Properties side panel: edits the selected component, and manages boards.
//
// Controls are generated from the registry's propSpecs, so a new scalar property on a
// component type gets an editor here for free.
import { el, button, setText, clear, createListenerBag, formatOhms } from './dom.js';
import { getComponentDef, LED_COLORS } from '../shared/component-registry.js';
import { componentSelfShorts, componentPinsWithNames } from '../shared/component-pins.js';
function field(labelText, control) {
return el('label', {
className: 'bb-field',
children: [el('span', { className: 'bb-field-label', text: labelText }), control]
});
}
export function createProperties(root, handlers) {
// Panel-lifetime listeners.
const bag = createListenerBag();
// Listeners for the controls rebuilt on every render. Cleared each time, otherwise
// the bag would keep every detached button and its closure alive for the session.
const renderBag = createListenerBag();
const body = el('div', { className: 'bb-props-body' });
const boardsBody = el('div', { className: 'bb-boards-body' });
const panel = el('aside', {
className: 'bb-props',
children: [
el('h2', { className: 'bb-props-heading', text: 'Properties' }),
body,
el('h2', { className: 'bb-props-heading', text: 'Boards' }),
boardsBody
]
});
root.appendChild(panel);
function buildEnumControl(component, key, spec) {
const select = el('select', { className: 'bb-input' });
// LED colours get their swatch shown alongside the name.
const options = key === 'color'
? LED_COLORS.map(c => ({ value: c.value, label: c.label }))
: spec.values.map(v => ({ value: v, label: v }));
for (const option of options) {
const node = el('option', { text: option.label, attrs: { value: option.value } });
if (component.props[key] === option.value) node.selected = true;
select.appendChild(node);
}
renderBag.on(select, 'change', () => handlers.onChangeProps(component.uid, { [key]: select.value }));
return select;
}
function buildNumberControl(component, key, spec) {
const input = el('input', {
className: 'bb-input',
attrs: {
type: 'number', min: String(spec.min), max: String(spec.max),
step: '1', value: String(component.props[key])
}
});
const commit = () => {
const value = Number(input.value);
if (!Number.isFinite(value)) {
input.value = String(component.props[key]);
return;
}
const clamped = Math.min(spec.max, Math.max(spec.min, value));
input.value = String(clamped);
handlers.onChangeProps(component.uid, { [key]: clamped });
};
renderBag.on(input, 'change', commit);
renderBag.on(input, 'keydown', (event) => {
if (event.key === 'Enter') { event.preventDefault(); commit(); }
});
const wrapper = el('div', { className: 'bb-field-stack', children: [input] });
if (Array.isArray(spec.presets)) {
const presets = el('div', { className: 'bb-presets' });
for (const preset of spec.presets) {
const node = button(formatOhms(preset), { className: 'bb-chip-btn' });
renderBag.on(node, 'click', () => {
input.value = String(preset);
handlers.onChangeProps(component.uid, { [key]: preset });
});
presets.appendChild(node);
}
wrapper.appendChild(presets);
}
return wrapper;
}
function buildBoolArrayControl(component, key, spec) {
const row = el('div', { className: 'bb-switch-row' });
const values = Array.isArray(component.props[key]) ? component.props[key] : [];
for (let i = 0; i < spec.length; i++) {
const node = button(String(i + 1), {
className: `bb-switch-toggle${values[i] ? ' is-on' : ''}`,
title: `Switch ${i + 1}: ${values[i] ? 'on' : 'off'}`
});
renderBag.on(node, 'click', () => handlers.onToggleSwitch(component.uid, i + 1));
row.appendChild(node);
}
return row;
}
function renderComponent(component) {
const def = getComponentDef(component.type);
if (def === null) return;
body.appendChild(el('div', {
className: 'bb-props-title',
children: [
el('strong', { text: def.label }),
el('span', { className: 'bb-props-uid', text: component.uid })
]
}));
if (def.description) {
body.appendChild(el('p', { className: 'bb-props-description', text: def.description }));
}
for (const [key, spec] of Object.entries(def.propSpecs)) {
let control = null;
if (spec.kind === 'enum') control = buildEnumControl(component, key, spec);
else if (spec.kind === 'number') control = buildNumberControl(component, key, spec);
else if (spec.kind === 'boolArray') control = buildBoolArrayControl(component, key, spec);
if (control !== null) {
body.appendChild(field(spec.unit ? `${spec.label} (${spec.unit})` : spec.label, control));
}
}
// Placement summary - where the part actually sits.
const pins = componentPinsWithNames(component);
const placed = pins.filter(p => p.hole !== null);
if (placed.length > 0) {
const first = placed[0].hole;
const where = first.kind === 'main'
? `board ${first.board}, column ${first.col} row ${first.row}`
: `board ${first.board}, ${first.rail} rail`;
body.appendChild(el('p', { className: 'bb-props-meta', text: `${pins.length} pins at ${where}` }));
}
const shorts = componentSelfShorts(component);
if (shorts.length > 0) {
body.appendChild(el('p', {
className: 'bb-props-warning',
text: 'Both ends of this part sit in the same connected strip, so it will have no effect.'
}));
}
const actions = el('div', { className: 'bb-props-actions' });
if (def.orientable) {
const rotate = button('Rotate (R)', { className: 'bb-btn' });
renderBag.on(rotate, 'click', () => handlers.onRotate(component.uid));
actions.appendChild(rotate);
}
const remove = button('Delete', { className: 'bb-btn bb-btn-danger' });
renderBag.on(remove, 'click', () => handlers.onDelete(component.uid));
actions.appendChild(remove);
body.appendChild(actions);
}
function renderWire(wire) {
body.appendChild(el('div', {
className: 'bb-props-title',
children: [el('strong', { text: 'Wire' }), el('span', { className: 'bb-props-uid', text: wire.uid })]
}));
const describe = (hole) => hole.kind === 'main'
? `${hole.board} · ${hole.col}${hole.row}`
: `${hole.board} · ${hole.rail} ${hole.index}`;
body.appendChild(el('p', {
className: 'bb-props-meta',
text: `${describe(wire.from)}${describe(wire.to)}`
}));
const actions = el('div', { className: 'bb-props-actions' });
const remove = button('Delete', { className: 'bb-btn bb-btn-danger' });
renderBag.on(remove, 'click', () => handlers.onDelete(wire.uid));
actions.appendChild(remove);
body.appendChild(actions);
}
return {
/** Re-render for the current selection. */
render(state) {
renderBag.removeAll();
clear(body);
const selection = [...state.selection];
if (selection.length === 0) {
body.appendChild(el('p', {
className: 'bb-props-empty',
text: 'Select a part or wire to edit it.'
}));
} else if (selection.length > 1) {
body.appendChild(el('p', {
className: 'bb-props-empty',
text: `${selection.length} items selected.`
}));
const actions = el('div', { className: 'bb-props-actions' });
const remove = button(`Delete ${selection.length} items`, { className: 'bb-btn bb-btn-danger' });
renderBag.on(remove, 'click', () => handlers.onDeleteSelection());
actions.appendChild(remove);
body.appendChild(actions);
} else {
const uid = selection[0];
const component = state.circuit.components.find(c => c.uid === uid);
if (component) renderComponent(component);
else {
const wire = state.circuit.wires.find(w => w.uid === uid);
if (wire) renderWire(wire);
}
}
this.renderBoards(state);
},
/** Rebuilds the board list. Its listeners belong to renderBag - render() clears
* it first, so calling this directly is only valid from render(). */
renderBoards(state) {
clear(boardsBody);
for (const board of state.circuit.boards) {
const onBoard = state.circuit.components.filter(c =>
(c.anchor && c.anchor.board === board.uid) || (c.props && c.props.board === board.uid)).length;
const row = el('div', { className: 'bb-board-row' });
row.appendChild(el('span', { className: 'bb-board-name', text: board.uid }));
row.appendChild(el('span', { className: 'bb-board-count', text: `${onBoard} part${onBoard === 1 ? '' : 's'}` }));
const focus = button('Show', { className: 'bb-btn bb-btn-small' });
renderBag.on(focus, 'click', () => handlers.onFocusBoard(board.uid));
row.appendChild(focus);
if (state.circuit.boards.length > 1) {
const remove = button('Remove', { className: 'bb-btn bb-btn-small bb-btn-danger' });
renderBag.on(remove, 'click', () => handlers.onRemoveBoard(board.uid));
row.appendChild(remove);
}
boardsBody.appendChild(row);
}
},
destroy() {
renderBag.removeAll();
bag.removeAll();
if (panel.parentNode) panel.parentNode.removeChild(panel);
}
};
}
@@ -0,0 +1,428 @@
// Layered canvas renderer.
//
// ============================ COORDINATE / SCALE POLICY ============================
// devicePixelRatio lives OUTSIDE the world transform, and this is the only file that
// knows about it. Each canvas backing store is sized cssPx * dpr; before drawing we
// set the BASE transform to (dpr, 0, 0, dpr, 0, 0), then apply the pan/zoom world
// transform on top of it. Consequences, relied on everywhere else:
// - viewport.js, board-geometry.js and component-art.js never see device pixels.
// - a "line width of 2" is 2 CSS pixels at zoom 1, on every display.
// - hit-testing uses viewport.screenToWorld on CSS-pixel mouse coordinates, with no
// dpr correction anywhere.
//
// ================================ LAYERS & DIRTYING ================================
// Four stacked canvases, each redrawn only when something it depends on changes.
//
// board static board artwork, blitted from a cached bitmap (board-art.js).
// Dirty on: board add/move/remove, theme, viewport.
// static components whose appearance CANNOT change during simulation - chips,
// resistors, supplies. These carry the expensive text.
// Dirty on: circuit edit, theme, viewport.
// dynamic wires (with net-level halos) and the components that DO change - LEDs,
// buttons, DIP switches.
// Dirty on: circuit edit, theme, viewport, and every simulation frame.
// overlay hover highlight, selection, drag ghost, in-progress wire.
// Dirty on: pointer/selection changes only.
//
// Each component is drawn on exactly one layer, chosen by whether simulation can alter
// it, so a 60 fps frame never re-rasterizes chip labels and a mousemove that changes
// nothing draws nothing.
import {
PITCH,
BOARD_WIDTH,
BOARD_HEIGHT,
holeWorldPos,
boardBounds,
stripKey
} from '../shared/board-geometry.js';
import { createBoardArtCache } from './board-art.js';
import { drawComponent, drawWire, componentBounds } from './component-art.js';
import { rectsIntersect } from './viewport.js';
/** Components whose drawn appearance depends on simulation state. */
const VOLATILE_TYPES = new Set(['led', 'pushButton', 'dipSwitch8']);
const LAYER_NAMES = Object.freeze(['board', 'static', 'dynamic', 'overlay']);
export function createRenderer(container, viewport, palette, options = {}) {
const canvases = {};
const contexts = {};
const boardArt = createBoardArtCache();
for (const name of LAYER_NAMES) {
const canvas = document.createElement('canvas');
canvas.className = `bb-layer bb-layer-${name}`;
// Only the topmost layer takes pointer events; the rest are pure paint.
canvas.style.pointerEvents = name === 'overlay' ? 'auto' : 'none';
container.appendChild(canvas);
canvases[name] = canvas;
contexts[name] = canvas.getContext('2d');
}
canvases.overlay.tabIndex = 0; // focusable, so the canvas can own keyboard
// Latest MEASURED size, in CSS pixels. Read straight after resize() so callers
// that fit the view can trust it.
let cssWidth = 0;
let cssHeight = 0;
let dpr = 1;
// Size the backing stores currently hold. Reallocating them costs tens of MB of
// churn, so it happens once per frame at most, inside paint().
let appliedWidth = 0;
let appliedHeight = 0;
let appliedDpr = 0;
const dirty = { board: true, static: true, dynamic: true, overlay: true };
let frameHandle = null;
// Latest scene to draw. Replaced wholesale by the editor on each change.
let scene = {
circuit: null,
boards: new Map(),
selection: new Set(),
hoverHole: null,
hoverComponent: null,
pendingWire: null, // { from: holeRef, toPoint: {x,y}, color }
ghost: null, // { component, valid }
ledBrightness: new Map(),
burned: new Set(),
pressed: new Set(),
warnedUids: new Set(), // components a simulation warning named
netLevels: null, // Uint8Array
netOfStrip: null, // Map<stripKey, netId>
simActive: false
};
/** Read the container size. Cheap - one layout read, no canvas work. */
function measureContainer() {
const rect = container.getBoundingClientRect();
const nextDpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height));
if (width === cssWidth && height === cssHeight && nextDpr === dpr) return false;
cssWidth = width;
cssHeight = height;
dpr = nextDpr;
return true;
}
/** Resize the backing stores to the measured size. Wipes them, so all layers dirty. */
function applyCanvasSize() {
if (cssWidth === appliedWidth && cssHeight === appliedHeight && dpr === appliedDpr) return;
appliedWidth = cssWidth;
appliedHeight = cssHeight;
appliedDpr = dpr;
for (const name of LAYER_NAMES) {
const canvas = canvases[name];
canvas.width = Math.round(cssWidth * dpr);
canvas.height = Math.round(cssHeight * dpr);
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
}
for (const name of LAYER_NAMES) dirty[name] = true;
}
/** Apply the base dpr transform, then the world transform. */
function beginWorld(ctx) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssWidth, cssHeight);
ctx.save();
ctx.translate(viewport.offsetX, viewport.offsetY);
ctx.scale(viewport.zoom, viewport.zoom);
}
function endWorld(ctx) {
ctx.restore();
}
function visibleRect() {
const rect = viewport.visibleWorldRect(cssWidth, cssHeight);
// A little margin so components straddling the edge are not clipped mid-body.
const margin = PITCH * 4;
return { x: rect.x - margin, y: rect.y - margin, w: rect.w + margin * 2, h: rect.h + margin * 2 };
}
function visibleBoards(view) {
if (!scene.circuit) return [];
return scene.circuit.boards.filter(board => rectsIntersect(boardBounds(board), view));
}
// --- Layer painters ---
function drawBoardLayer() {
const ctx = contexts.board;
beginWorld(ctx);
if (scene.circuit) {
const art = boardArt.get(viewport.zoom, palette.colors, palette.version);
const view = visibleRect();
for (const board of visibleBoards(view)) {
ctx.drawImage(art.surface, board.x, board.y, BOARD_WIDTH, BOARD_HEIGHT);
}
}
endWorld(ctx);
}
/** Net level code for a hole, or -1 when the simulation has nothing to say. */
function levelAtHole(hole) {
if (!scene.netLevels || !scene.netOfStrip || !hole) return -1;
const netId = scene.netOfStrip.get(stripKey(hole));
if (netId === undefined || netId < 0 || netId >= scene.netLevels.length) return -1;
return scene.netLevels[netId];
}
function drawStaticLayer() {
const ctx = contexts.static;
beginWorld(ctx);
if (scene.circuit) {
const view = visibleRect();
for (const component of scene.circuit.components) {
if (VOLATILE_TYPES.has(component.type)) continue;
const bounds = componentBounds(component, scene.boards);
if (bounds === null || !rectsIntersect(bounds, view)) continue;
drawComponent(ctx, component, scene.boards, palette.colors, {}, viewport.zoom);
}
}
endWorld(ctx);
}
function drawDynamicLayer() {
const ctx = contexts.dynamic;
beginWorld(ctx);
if (scene.circuit) {
const view = visibleRect();
for (const wire of scene.circuit.wires) {
const from = holeWorldPos(wire.from, scene.boards);
const to = holeWorldPos(wire.to, scene.boards);
if (!from || !to) continue;
const bounds = {
x: Math.min(from.x, to.x) - PITCH,
y: Math.min(from.y, to.y) - PITCH,
w: Math.abs(to.x - from.x) + PITCH * 2,
h: Math.abs(to.y - from.y) + PITCH * 2
};
if (!rectsIntersect(bounds, view)) continue;
let levelColor = null;
if (scene.simActive) {
const level = levelAtHole(wire.from);
if (level >= 0) levelColor = palette.levelColor(level);
}
drawWire(ctx, from, to, wire.color, {
levelColor,
shadow: palette.colors.wireShadow
});
}
for (const component of scene.circuit.components) {
if (!VOLATILE_TYPES.has(component.type)) continue;
const bounds = componentBounds(component, scene.boards);
if (bounds === null || !rectsIntersect(bounds, view)) continue;
drawComponent(ctx, component, scene.boards, palette.colors, {
brightness: scene.ledBrightness.get(component.uid) || 0,
burned: scene.burned.has(component.uid),
pressed: scene.pressed.has(component.uid)
}, viewport.zoom);
}
}
endWorld(ctx);
}
function drawOverlayLayer() {
const ctx = contexts.overlay;
beginWorld(ctx);
const colors = palette.colors;
// Selection rings around every selected component's pins.
for (const uid of scene.selection) {
const component = scene.circuit && scene.circuit.components.find(c => c.uid === uid);
if (component) {
const bounds = componentBounds(component, scene.boards);
if (bounds) {
ctx.strokeStyle = colors.selection;
ctx.lineWidth = Math.max(1.5 / viewport.zoom, PITCH * 0.07);
ctx.setLineDash([PITCH * 0.3, PITCH * 0.2]);
ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
ctx.setLineDash([]);
}
continue;
}
const wire = scene.circuit && scene.circuit.wires.find(w => w.uid === uid);
if (wire) {
const from = holeWorldPos(wire.from, scene.boards);
const to = holeWorldPos(wire.to, scene.boards);
if (from && to) {
ctx.strokeStyle = colors.selection;
ctx.lineWidth = PITCH * 0.34;
ctx.globalAlpha = 0.45;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
ctx.globalAlpha = 1;
}
}
}
// Components a warning named. Worth drawing rather than only listing: the
// whole difficulty with a self-shorted or burned-out part is that it looks
// correctly placed, so a text row alone leaves the user hunting for it.
if (scene.warnedUids && scene.warnedUids.size > 0 && scene.circuit) {
ctx.strokeStyle = colors.invalid;
ctx.lineWidth = Math.max(2 / viewport.zoom, PITCH * 0.09);
ctx.setLineDash([PITCH * 0.22, PITCH * 0.18]);
for (const uid of scene.warnedUids) {
const component = scene.circuit.components.find(c => c.uid === uid);
if (!component) continue;
const bounds = componentBounds(component, scene.boards);
if (bounds === null) continue;
const pad = PITCH * 0.18;
ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
}
ctx.setLineDash([]);
}
// Ghost of the component about to be placed.
if (scene.ghost && scene.ghost.component) {
ctx.globalAlpha = 0.55;
drawComponent(ctx, scene.ghost.component, scene.boards, colors, {}, viewport.zoom);
ctx.globalAlpha = 1;
if (!scene.ghost.valid) {
const bounds = componentBounds(scene.ghost.component, scene.boards);
if (bounds) {
ctx.strokeStyle = colors.invalid;
ctx.lineWidth = Math.max(1.5 / viewport.zoom, PITCH * 0.08);
ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
}
}
}
// Wire being dragged out.
if (scene.pendingWire) {
const from = holeWorldPos(scene.pendingWire.from, scene.boards);
if (from && scene.pendingWire.toPoint) {
ctx.strokeStyle = scene.pendingWire.color;
ctx.lineWidth = PITCH * 0.18;
ctx.globalAlpha = 0.85;
ctx.setLineDash([PITCH * 0.4, PITCH * 0.25]);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(scene.pendingWire.toPoint.x, scene.pendingWire.toPoint.y);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
}
}
// Hovered hole, plus a soft wash over every other hole on the same strip so the
// electrical grouping is visible while wiring.
if (scene.hoverHole) {
const point = holeWorldPos(scene.hoverHole, scene.boards);
if (point) {
if (scene.hoverStripPoints) {
ctx.fillStyle = colors.hover;
ctx.globalAlpha = 0.18;
for (const p of scene.hoverStripPoints) {
ctx.beginPath();
ctx.arc(p.x, p.y, PITCH * 0.3, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
ctx.strokeStyle = colors.hover;
ctx.lineWidth = Math.max(1.5 / viewport.zoom, PITCH * 0.08);
ctx.beginPath();
ctx.arc(point.x, point.y, PITCH * 0.34, 0, Math.PI * 2);
ctx.stroke();
}
}
endWorld(ctx);
}
const PAINTERS = { board: drawBoardLayer, static: drawStaticLayer, dynamic: drawDynamicLayer, overlay: drawOverlayLayer };
// Layers whose painter has already thrown, so the failure is reported once rather
// than on every frame.
const reportedFailures = new Set();
function paint() {
frameHandle = null;
applyCanvasSize();
for (const name of LAYER_NAMES) {
if (!dirty[name]) continue;
try {
PAINTERS[name]();
// Cleared only on success. Marking clean before painting would leave a
// layer that threw permanently stale, so it could never recover even
// once the cause was gone.
dirty[name] = false;
} catch (error) {
if (!reportedFailures.has(name)) {
reportedFailures.add(name);
const message = error && error.message ? error.message : String(error);
if (typeof options.onPaintError === 'function') {
options.onPaintError(name, message);
}
}
// Stop rather than cascade: the layers below would paint over a gap.
break;
}
}
}
function schedule() {
if (frameHandle === null) frameHandle = requestAnimationFrame(paint);
}
function invalidate(...names) {
for (const name of names) dirty[name] = true;
schedule();
}
function invalidateAll() {
invalidate(...LAYER_NAMES);
}
const unsubscribeTheme = palette.subscribe(() => {
boardArt.invalidate();
invalidateAll();
});
return {
get canvas() { return canvases.overlay; },
get width() { return cssWidth; },
get height() { return cssHeight; },
/** Replace the scene and mark the given layers dirty. */
setScene(next, ...invalidated) {
scene = Object.assign(scene, next);
invalidate(...(invalidated.length > 0 ? invalidated : LAYER_NAMES));
},
resize() {
if (measureContainer()) invalidateAll();
},
invalidate,
invalidateAll,
/** Board artwork must be re-rasterized when the zoom bucket may have changed. */
viewportChanged() {
invalidate('board', 'static', 'dynamic', 'overlay');
},
destroy() {
if (frameHandle !== null) cancelAnimationFrame(frameHandle);
frameHandle = null;
unsubscribeTheme();
boardArt.invalidate();
for (const name of LAYER_NAMES) {
const canvas = canvases[name];
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
}
}
};
}
@@ -0,0 +1,74 @@
// Friendly wording for the server validator's machine-readable error tokens.
//
// The validator emits `path:reason` tokens (e.g. "components[3].props.color:unsupported")
// deliberately, so they stay machine-readable. Turning them into sentences belongs
// here, in the UI layer, and MUST fall back to the raw token for anything unrecognised
// - a new validator reason has to remain visible, not vanish.
const REASONS = Object.freeze(Object.assign(Object.create(null), {
malformed_json: 'could not be read',
exceeds_max_size: 'is larger than the 2 MB limit',
unsupported: 'has a value this build does not support',
unknown_property: 'has a property this build does not recognise',
not_an_object: 'is not shaped like a circuit element',
not_an_array: 'should be a list',
not_a_boolean: 'should be true or false',
wrong_length: 'has the wrong number of entries',
out_of_range: 'is outside the allowed range',
unknown_board: 'refers to a board that does not exist',
duplicate: 'is used more than once',
not_applicable: 'is not allowed on this component',
contradicts_anchor_row: 'does not match which side of the centre channel the part sits on',
footprint_off_board: 'would place part of the component off the edge of the board',
package_off_board: 'is too close to the edge for the package to fit',
not_valid_on_rail: 'is not a valid direction for a part in a power rail',
rail_pair_already_supplied: 'already has a 5V supply on that rail pair',
unsupported_version: 'was saved by a different version of the editor',
truncated: 'and more problems were found than can be listed'
}));
const PATHS = Object.freeze(Object.assign(Object.create(null), {
circuit: 'The circuit',
version: 'The circuit version',
boards: 'The boards list',
components: 'The components list',
wires: 'The wires list'
}));
function describePath(path) {
if (PATHS[path]) return PATHS[path];
// components[3].props.color -> "Component 4's colour"
const match = /^(components|wires|boards)\[(\d+)\](?:\.(.+))?$/.exec(path);
if (match) {
const noun = { components: 'Component', wires: 'Wire', boards: 'Board' }[match[1]];
const ordinal = Number(match[2]) + 1;
const field = match[3] ? ` (${match[3].replace(/\./g, ' ')})` : '';
return `${noun} ${ordinal}${field}`;
}
return path;
}
/**
* Turn one validator token into a sentence, or return it unchanged when it is not in
* the expected shape.
* @param {string} token
* @returns {string}
*/
export function describeServerError(token) {
if (typeof token !== 'string') return String(token);
const split = token.lastIndexOf(':');
if (split <= 0) return token;
const path = token.slice(0, split);
const reason = token.slice(split + 1);
const wording = REASONS[reason];
// Unrecognised reason: show the raw token so nothing is silently swallowed.
if (!wording) return token;
return `${describePath(path)} ${wording}.`;
}
/** Map a list of tokens, preserving order. */
export function describeServerErrors(tokens) {
return tokens.map(describeServerError);
}
@@ -0,0 +1,307 @@
// Client for the simulation worker.
//
// Speaks the engine protocol exactly. This module owns ALL knowledge of the worker
// message shapes; the rest of the editor sees plain callbacks and a small state
// object, so a protocol change lands in one file.
//
// Behaviours of the engine that the UI must not misread, per the engine pair:
// - The worker AUTO-PAUSES when the circuit settles. Frame silence is normal for a
// combinational circuit, not a hang. `settled` says so explicitly.
// - `input` works while paused; the worker settles the consequences and posts a
// frame immediately, so a button press updates the view without pressing run.
// - `run` can REFUSE: on a rail-to-rail short it posts a frame with halted:true and
// does not start. The UI must reflect that the run did not take.
// - A burned LED reports 0 mA, which is also what an off LED reports. Burnout is
// known ONLY from the one-shot `ledBurnout` warning, so it is latched here and
// cleared on load/reset.
const WORKER_URL = '/js/breadboard/engine/worker.js';
/** Current at which an LED is drawn at full brightness (also the overcurrent point). */
const FULL_BRIGHTNESS_MA = 20;
export function createSimClient(handlers = {}) {
let worker = null;
let available = false;
let loadError = null;
const state = {
running: false,
settled: false,
halted: false,
loaded: false,
netCount: 0,
simTimeNs: 0,
netOfStrip: new Map(),
pinNets: new Map(), // uid -> [netId per pin], -1 when unconnected
ledOrder: [],
netLevels: null,
ledCurrentMa: new Map(), // uid -> mA
ledBrightness: new Map(), // uid -> 0..1
burned: new Set(),
// Set by a worker `error`, cleared only when WE send a new load/reset. A failed
// load posts BOTH `error` and `loaded`, so this survives the `loaded` that
// follows and stops the failure being reported twice or wiped from the list.
engineError: null
};
function emit(name, ...args) {
const handler = handlers[name];
if (typeof handler === 'function') handler(...args);
}
function resetDerived() {
state.netOfStrip = new Map();
state.pinNets = new Map();
state.ledOrder = [];
state.netLevels = null;
state.ledCurrentMa = new Map();
state.ledBrightness = new Map();
state.burned = new Set();
state.running = false;
state.settled = false;
state.halted = false;
state.simTimeNs = 0;
}
function handleLoaded(message) {
const carriedError = state.engineError;
resetDerived();
state.engineError = carriedError;
state.loaded = true;
// The engine reports a failed load two ways: a preceding `error` message, and a
// `loadFailed` entry in these warnings with nets:0. Either means this is not a
// valid empty circuit, so check both rather than trusting nets===0.
const warnings = Array.isArray(message.warnings) ? message.warnings : [];
const loadFailed = warnings.some(w => w && w.kind === 'loadFailed');
state.netCount = typeof message.nets === 'number' ? message.nets : 0;
const index = message.netIndex || {};
if (index.strips && typeof index.strips === 'object') {
state.netOfStrip = new Map(Object.entries(index.strips));
}
if (index.components && typeof index.components === 'object') {
for (const [uid, nets] of Object.entries(index.components)) {
if (Array.isArray(nets)) state.pinNets.set(uid, nets);
}
}
state.ledOrder = Array.isArray(index.ledOrder) ? index.ledOrder.slice() : [];
if (loadFailed) state.halted = true;
emit('loaded', {
netCount: state.netCount,
warnings,
// The engine posts `loaded` even when the load failed, so that anything
// awaiting it is released. Tell the UI not to treat this as a clean start.
failed: carriedError !== null || loadFailed
});
}
function handleFrame(message) {
state.netLevels = message.netLevels instanceof Uint8Array ? message.netLevels : null;
state.simTimeNs = typeof message.simTimeNs === 'number' ? message.simTimeNs : state.simTimeNs;
// Additive fields beyond the spec; absent on an older engine, so default safely.
if (typeof message.running === 'boolean') state.running = message.running;
if (typeof message.settled === 'boolean') state.settled = message.settled;
if (typeof message.halted === 'boolean') state.halted = message.halted;
if (state.halted) state.running = false;
const currents = message.ledStates;
if (currents && currents.length >= 0) {
for (let i = 0; i < state.ledOrder.length && i < currents.length; i++) {
const uid = state.ledOrder[i];
const ma = currents[i];
state.ledCurrentMa.set(uid, ma);
// A burned LED also reports 0 mA, so the latched flag - not the
// current - decides whether it is drawn as dead.
state.ledBrightness.set(uid, state.burned.has(uid)
? 0
: Math.max(0, Math.min(1, ma / FULL_BRIGHTNESS_MA)));
}
}
emit('frame', state);
}
function handleWarning(message) {
if (message.kind === 'ledBurnout' && Array.isArray(message.uids)) {
for (const uid of message.uids) {
state.burned.add(uid);
state.ledBrightness.set(uid, 0);
}
}
// Unknown kinds are passed through untouched - the status surface renders them
// generically so a new engine warning needs no change here.
emit('warning', {
kind: typeof message.kind === 'string' ? message.kind : 'unknown',
uids: Array.isArray(message.uids) ? message.uids : [],
netId: message.netId,
detail: typeof message.detail === 'string' ? message.detail : ''
});
}
/**
* The engine threw internally (amendment A13). The worker is still alive but its
* state is not to be trusted, so stop the transport and tell the user - never
* leave the UI waiting on frames that will not come.
*/
function handleError(message) {
state.running = false;
state.halted = true;
const context = typeof message.context === 'string' && message.context.length > 0
? message.context : 'simulation';
const detail = typeof message.message === 'string' ? message.message : '';
state.engineError = { context, detail };
emit('warning', {
kind: 'engineError',
uids: Array.isArray(message.uids) ? message.uids : [],
netId: message.netId,
detail: detail ? `${context}: ${detail}` : context
});
emit('frame', state);
}
/**
* The Worker failed to start or threw at the top level. Separate from the engine's
* own `error` message, which is a structured report from a worker that is running.
*/
function onWorkerError(event) {
loadError = event.message || 'The simulation engine failed to start.';
available = false;
state.running = false;
emit('unavailable', loadError);
}
function onMessage(event) {
const message = event.data;
if (!message || typeof message.type !== 'string') return;
switch (message.type) {
case 'loaded': handleLoaded(message); break;
case 'frame': handleFrame(message); break;
case 'warning': handleWarning(message); break;
case 'error': handleError(message); break;
default:
// Forward-compatible, but NEVER silent about a failure: an unrecognised
// message that carries error-shaped fields is surfaced rather than
// dropped, because a swallowed error looks exactly like a hung worker.
if (/error|fail/i.test(message.type)
|| typeof message.message === 'string'
|| typeof message.context === 'string') {
handleError(message);
}
break;
}
}
function post(message) {
if (worker === null) return;
try {
worker.postMessage(message);
} catch (error) {
// Structured clone failed, so the message NEVER REACHED the worker: no
// loaded, no frame, and no engine-side `error` either, because the engine
// was never told. Without this the careful error plumbing is bypassed and
// the UI waits forever. Route it into the same failure path.
handleError({
context: message && message.type ? message.type : 'postMessage',
message: error && error.message ? error.message : String(error)
});
}
}
return {
state,
get available() { return available; },
get loadError() { return loadError; },
/**
* Start the worker. Failure is reported rather than thrown, so the editor
* still works without simulation.
* @returns {boolean} whether the worker started
*/
start() {
if (worker !== null) return true;
try {
worker = new Worker(WORKER_URL, { type: 'module' });
} catch (error) {
loadError = error && error.message ? error.message : String(error);
available = false;
emit('unavailable', loadError);
return false;
}
worker.addEventListener('message', onMessage);
worker.addEventListener('error', onWorkerError);
available = true;
return true;
},
/** Send a circuit for net extraction. Clears burnout and run state. */
load(circuit) {
resetDerived();
state.engineError = null;
state.loaded = false;
post({ type: 'load', circuit });
},
run() {
state.running = true; // optimistic; a halted frame corrects it
post({ type: 'run' });
},
pause() {
state.running = false;
post({ type: 'pause' });
},
step(count = 1) {
post({ type: 'step', count });
},
setSpeed(eventsPerSecond) {
post({ type: 'setSpeed', eventsPerSecond });
},
/** Button press/release. */
setButton(uid, pressed) {
post({ type: 'input', uid, value: pressed === true });
},
/** DIP toggle. `pin` is the SWITCH number 1..8, not the package pin. */
setSwitch(uid, pin, on) {
post({ type: 'input', uid, value: { pin, on: on === true } });
},
/** Re-load the last circuit, clearing LED burnout. */
reset() {
state.burned.clear();
state.ledBrightness.clear();
state.engineError = null;
state.halted = false;
post({ type: 'reset' });
},
/** Net level code for a strip key, or -1 when unknown. */
levelForStrip(stripKey) {
if (!state.netLevels) return -1;
const netId = state.netOfStrip.get(stripKey);
if (netId === undefined || netId < 0 || netId >= state.netLevels.length) return -1;
return state.netLevels[netId];
},
destroy() {
if (worker !== null) {
// Removed by name rather than relying on terminate() to take the
// listeners with it, so teardown does not depend on that detail.
worker.removeEventListener('message', onMessage);
worker.removeEventListener('error', onWorkerError);
worker.terminate();
worker = null;
}
available = false;
resetDerived();
}
};
}
export { FULL_BRIGHTNESS_MA };
@@ -0,0 +1,166 @@
// Status strip: transient messages and simulation warnings.
//
// Non-intrusive by design - nothing here blocks the canvas or steals focus. Every
// string that reaches the DOM goes through textContent, because these carry server
// error text and engine detail strings.
import { el, button, setText, clear, createListenerBag } from './dom.js';
/** How long an informational message stays before fading. Warnings persist. */
const INFO_TIMEOUT_MS = 3200;
/**
* Friendly text for the warning kinds the engine documents. Unknown kinds are rendered
* generically rather than dropped, so a new engine warning needs no change here
* (amendment A8).
*/
const WARNING_LABELS = Object.freeze(Object.assign(Object.create(null), {
contention: 'Contention — two outputs are driving the same net',
ledOvercurrent: 'LED over 20 mA',
ledBurnout: 'LED burned out',
shortCircuit: 'Short circuit across the supply',
oscillation: 'Oscillation — the circuit never settles',
floatingInput: 'Floating input',
unpoweredChip: 'Chip has no power',
invalidHole: 'Component is not on a valid hole',
duplicateUid: 'Duplicate component id',
unknownComponent: 'Unrecognised component',
unconnectedSupply: 'Supply is not connected to anything',
selfShorted: 'Both ends are on the same net',
floatingControl: 'Nothing is connected to the base or gate',
unlimitedBaseCurrent: 'Transistor base has no series resistor',
engineError: 'The simulation engine hit an internal error',
warningsSuppressed: 'Some warnings were coalesced'
}));
const SEVERITY = Object.freeze(Object.assign(Object.create(null), {
// A coalescing summary, not a circuit fault - it must not read as an error.
warningsSuppressed: 'info',
engineError: 'error',
ledBurnout: 'error',
shortCircuit: 'error',
contention: 'error',
oscillation: 'warn',
ledOvercurrent: 'warn',
unlimitedBaseCurrent: 'warn'
}));
function humanizeKind(kind) {
// "someUnknownKind" -> "Some unknown kind"
const spaced = String(kind).replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
export function createStatus(root) {
const bag = createListenerBag();
const messageList = el('div', { className: 'bb-status-messages' });
const warningList = el('div', { className: 'bb-status-warnings' });
const warningHeader = el('div', { className: 'bb-status-warnings-header' });
const warningTitle = el('span', { className: 'bb-status-warnings-title' });
const clearButton = button('Clear', { className: 'bb-btn bb-btn-ghost bb-btn-small' });
warningHeader.appendChild(warningTitle);
warningHeader.appendChild(clearButton);
const panel = el('div', {
className: 'bb-status',
attrs: { role: 'status', 'aria-live': 'polite' },
children: [messageList, warningList]
});
root.appendChild(panel);
/** kind -> { count, detail, uids, node } so repeats collapse instead of flooding. */
const warnings = new Map();
const timers = new Set();
function renderWarningHeader() {
if (warnings.size === 0) {
if (warningHeader.parentNode) warningList.removeChild(warningHeader);
warningList.classList.remove('is-visible');
return;
}
if (!warningHeader.parentNode) warningList.insertBefore(warningHeader, warningList.firstChild);
warningList.classList.add('is-visible');
setText(warningTitle, `${warnings.size} issue${warnings.size === 1 ? '' : 's'}`);
}
function message(text, kind) {
if (!text) return;
const node = el('div', { className: `bb-message bb-message-${kind}`, text });
messageList.appendChild(node);
// Newest first, and never let the list grow without bound.
while (messageList.childElementCount > 4) messageList.removeChild(messageList.firstChild);
if (kind === 'info') {
const timer = setTimeout(() => {
timers.delete(timer);
if (node.parentNode) node.parentNode.removeChild(node);
}, INFO_TIMEOUT_MS);
timers.add(timer);
}
}
bag.on(clearButton, 'click', () => {
warnings.clear();
clear(warningList);
renderWarningHeader();
});
return {
info(text) { message(text, 'info'); },
warn(text) { message(text, 'warn'); },
error(text) { message(text, 'error'); },
/** Show a list of messages, e.g. a server validation failure. */
errors(list) {
for (const text of list) message(text, 'error');
},
/**
* Record a simulation warning. Unknown kinds render generically - never
* dropped, never a crash.
*/
addWarning(warning) {
const kind = warning.kind || 'unknown';
const existing = warnings.get(kind);
if (existing) {
existing.count++;
setText(existing.countNode, `x${existing.count}`);
if (warning.detail) setText(existing.detailNode, warning.detail);
return;
}
const label = WARNING_LABELS[kind] || humanizeKind(kind);
const severity = SEVERITY[kind] || 'warn';
const titleNode = el('span', { className: 'bb-warning-title', text: label });
const countNode = el('span', { className: 'bb-warning-count', text: '' });
const detailNode = el('span', { className: 'bb-warning-detail', text: warning.detail || '' });
const uidsText = Array.isArray(warning.uids) && warning.uids.length > 0
? warning.uids.join(', ') : '';
const uidNode = el('span', { className: 'bb-warning-uids', text: uidsText });
const node = el('div', {
className: `bb-warning bb-warning-${severity}`,
children: [titleNode, countNode, detailNode, uidNode]
});
warningList.appendChild(node);
warnings.set(kind, { count: 1, node, countNode, detailNode });
renderWarningHeader();
},
/** Drop all simulation warnings, e.g. on a fresh load. */
clearWarnings() {
warnings.clear();
clear(warningList);
renderWarningHeader();
},
destroy() {
for (const timer of timers) clearTimeout(timer);
timers.clear();
bag.removeAll();
if (panel.parentNode) panel.parentNode.removeChild(panel);
}
};
}
@@ -0,0 +1,141 @@
// Canvas palette, sourced from CSS.
//
// Canvas cannot inherit CSS, so every colour the renderer draws is read out of a CSS
// custom property defined in breadboard.css. There are NO hardcoded colours in the
// renderer - change the look by editing the stylesheet, and both themes follow.
//
// site.css puts the dark palette on bare :root and overrides it under
// [data-theme="light"], so this reads whatever is currently in effect rather than
// assuming either. The site's theme customizer can also set custom properties inline
// on <html>, which is why the observer watches `style` as well as `data-theme`.
/** Every custom property the renderer needs, with a fallback if the sheet is missing. */
const TOKENS = Object.freeze({
boardFace: ['--bb-board-face', '#e8e6df'],
boardEdge: ['--bb-board-edge', '#c9c5b8'],
boardBevel: ['--bb-board-bevel', '#f5f3ee'],
channel: ['--bb-channel', '#d8d5cb'],
channelEdge: ['--bb-channel-edge', '#bdb9ac'],
hole: ['--bb-hole', '#3a3a3a'],
holeRim: ['--bb-hole-rim', '#b9b5a8'],
silk: ['--bb-silk', '#8a8578'],
silkStrong: ['--bb-silk-strong', '#5e5a50'],
railPlus: ['--bb-rail-plus', '#d24b4b'],
railMinus: ['--bb-rail-minus', '#4b6fd2'],
canvasBg: ['--bb-canvas-bg', '#1b1d21'],
grid: ['--bb-grid', '#2a2d33'],
hover: ['--bb-hover', '#3fb950'],
selection: ['--bb-selection', '#58a6ff'],
ghost: ['--bb-ghost', '#58a6ff'],
invalid: ['--bb-invalid', '#f85149'],
wireShadow: ['--bb-wire-shadow', 'rgba(0,0,0,0.35)'],
chipBody: ['--bb-chip-body', '#2b2b2f'],
chipLabel: ['--bb-chip-label', '#d8d8d8'],
chipPin: ['--bb-chip-pin', '#c8c8cc'],
resistorBody: ['--bb-resistor-body', '#d8c49a'],
resistorLead: ['--bb-resistor-lead', '#b0b0b0'],
diodeBody: ['--bb-diode-body', '#9aa7b4'],
diodeBand: ['--bb-diode-band', '#1c1c20'],
transistorBody: ['--bb-transistor-body', '#1f1f24'],
buttonBody: ['--bb-button-body', '#3a3a3e'],
buttonCap: ['--bb-button-cap', '#c9553f'],
buttonCapDown: ['--bb-button-cap-down', '#8d3a2b'],
dipBody: ['--bb-dip-body', '#2f4a8c'],
dipSwitchOn: ['--bb-dip-switch-on', '#f2f2f2'],
dipSwitchOff: ['--bb-dip-switch-off', '#8b8b90'],
supplyBody: ['--bb-supply-body', '#26303a'],
supplyText: ['--bb-supply-text', '#e6edf3'],
burned: ['--bb-burned', '#4a4a4a'],
levelLow: ['--bb-level-low', '#3b6ea5'],
levelHigh: ['--bb-level-high', '#e0483d'],
levelHiZ: ['--bb-level-hiz', '#7d7d85'],
levelWeakLow: ['--bb-level-weak-low', '#4f7fa8'],
levelWeakHigh: ['--bb-level-weak-high', '#d98a4a'],
levelContention: ['--bb-level-contention', '#ffcc00']
});
/**
* Net level codes from the engine's `frame` message, mapped to palette keys.
* Index is the Uint8Array value: 0=low 1=high 2=highZ 3=weakLow 4=weakHigh 5=contention
*/
export const LEVEL_KEYS = Object.freeze([
'levelLow', 'levelHigh', 'levelHiZ', 'levelWeakLow', 'levelWeakHigh', 'levelContention'
]);
/** Human-readable names for the same codes, for the status bar. */
export const LEVEL_NAMES = Object.freeze([
'low', 'high', 'high-Z', 'weak low', 'weak high', 'contention'
]);
/**
* Reads the palette from CSS and notifies subscribers when the theme changes.
* `version` increments on every change so cached bitmaps know to re-rasterize.
*/
export function createPalette(rootElement) {
const probe = rootElement || document.documentElement;
let colors = read();
let version = 0;
const subscribers = new Set();
function read() {
const computed = getComputedStyle(probe);
const next = {};
for (const [key, [prop, fallback]] of Object.entries(TOKENS)) {
const value = computed.getPropertyValue(prop).trim();
next[key] = value.length > 0 ? value : fallback;
}
return next;
}
function refresh() {
const next = read();
const changed = Object.keys(next).some(k => next[k] !== colors[k]);
if (!changed) return false;
colors = next;
version++;
for (const fn of subscribers) fn(colors, version);
return true;
}
// <html> carries both the data-theme attribute and any inline custom-property
// overrides written by the site's theme customizer.
const observer = new MutationObserver(refresh);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'style', 'class']
});
return {
/** Current colours. Treat as immutable; re-read via .colors after a change. */
get colors() { return colors; },
/** Bumped whenever the palette changes - use as a cache key. */
get version() { return version; },
/** Colour for a net level code, falling back to high-Z for unknown codes. */
levelColor(code) {
const key = LEVEL_KEYS[code];
return colors[key === undefined ? 'levelHiZ' : key];
},
subscribe(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
},
/** Force a re-read; returns whether anything changed. */
refresh,
destroy() {
observer.disconnect();
subscribers.clear();
}
};
}
/**
* Blend a CSS colour toward transparency for glow effects. Only handles the alpha
* channel, so it works with any colour syntax the browser accepts by delegating to
* globalAlpha at draw time instead of parsing.
*/
export function withAlpha(ctx, alpha, draw) {
const previous = ctx.globalAlpha;
ctx.globalAlpha = previous * alpha;
draw();
ctx.globalAlpha = previous;
}
@@ -0,0 +1,131 @@
// Top toolbar: project identity, save state, simulation transport, zoom.
import { el, button, setText, createListenerBag } from './dom.js';
/** Simulation speed presets, in engine events per second. */
const SPEED_STEPS = Object.freeze([1, 10, 100, 1000, 10000, 100000, 1000000, 10000000]);
const DEFAULT_SPEED_INDEX = 5;
function formatSpeed(eventsPerSecond) {
if (eventsPerSecond >= 1000000) return `${eventsPerSecond / 1000000} M events/s`;
if (eventsPerSecond >= 1000) return `${eventsPerSecond / 1000} k events/s`;
return `${eventsPerSecond} events/s`;
}
export function createToolbar(root, handlers) {
const bag = createListenerBag();
const title = el('h1', { className: 'bb-title', text: handlers.projectName || 'Breadboard' });
const saveState = el('span', { className: 'bb-save-state', text: 'Loading…' });
const saveButton = button('Save', { className: 'bb-btn bb-btn-primary', attrs: { 'aria-keyshortcuts': 'Control+S' } });
const runButton = button('Run', { className: 'bb-btn bb-btn-run', title: 'Start the simulation' });
const pauseButton = button('Pause', { className: 'bb-btn', title: 'Pause the simulation' });
const stepButton = button('Step', { className: 'bb-btn', title: 'Advance one event' });
const resetButton = button('Reset', { className: 'bb-btn', title: 'Reload the circuit and clear burned-out parts' });
const simState = el('span', { className: 'bb-sim-state', text: 'idle' });
const speedInput = el('input', {
className: 'bb-speed',
attrs: {
type: 'range', min: '0', max: String(SPEED_STEPS.length - 1),
step: '1', value: String(DEFAULT_SPEED_INDEX),
'aria-label': 'Simulation speed'
}
});
const speedLabel = el('span', { className: 'bb-speed-label', text: formatSpeed(SPEED_STEPS[DEFAULT_SPEED_INDEX]) });
const zoomOut = button('', { className: 'bb-btn bb-btn-icon', title: 'Zoom out' });
const zoomIn = button('+', { className: 'bb-btn bb-btn-icon', title: 'Zoom in' });
const zoomFit = button('Fit', { className: 'bb-btn', title: 'Fit all boards in view' });
const zoomLabel = el('span', { className: 'bb-zoom-label', text: '100%' });
const addBoardButton = button('Add board', { className: 'bb-btn' });
// Last state written to the DOM. setSimState is called on every simulation frame,
// and writing unchanged text/disabled flags still invalidates layout on a
// wrapping toolbar, so identical calls stop here.
let lastSimSignature = null;
const group = (className, children) => el('div', { className: `bb-toolbar-group ${className}`, children });
const bar = el('div', {
className: 'bb-toolbar',
children: [
group('bb-group-project', [title, saveState, saveButton]),
group('bb-group-sim', [runButton, pauseButton, stepButton, resetButton, simState]),
group('bb-group-speed', [speedLabel, speedInput]),
group('bb-group-view', [addBoardButton, zoomOut, zoomLabel, zoomIn, zoomFit])
]
});
root.appendChild(bar);
bag.on(saveButton, 'click', () => handlers.onSave());
bag.on(runButton, 'click', () => handlers.onRun());
bag.on(pauseButton, 'click', () => handlers.onPause());
bag.on(stepButton, 'click', () => handlers.onStep());
bag.on(resetButton, 'click', () => handlers.onReset());
bag.on(addBoardButton, 'click', () => handlers.onAddBoard());
bag.on(zoomIn, 'click', () => handlers.onZoom(1.25));
bag.on(zoomOut, 'click', () => handlers.onZoom(1 / 1.25));
bag.on(zoomFit, 'click', () => handlers.onZoomFit());
bag.on(speedInput, 'input', () => {
const speed = SPEED_STEPS[Number(speedInput.value)] || SPEED_STEPS[DEFAULT_SPEED_INDEX];
setText(speedLabel, formatSpeed(speed));
handlers.onSpeed(speed);
});
return {
get initialSpeed() { return SPEED_STEPS[DEFAULT_SPEED_INDEX]; },
setProjectName(name) {
setText(title, name || 'Breadboard');
},
/**
* Reflect save state.
* @param {'clean'|'dirty'|'saving'|'error'} kind
*/
setSaveState(kind, text) {
saveState.className = `bb-save-state bb-save-${kind}`;
setText(saveState, text);
saveButton.disabled = kind === 'saving' || kind === 'clean';
},
/**
* Reflect simulation state. `halted` means the engine refused to run - a
* rail-to-rail short - so the run button must visibly not take.
*/
setSimState({ available, running, settled, halted, loaded }) {
const signature = `${!!available}|${!!running}|${!!settled}|${!!halted}|${!!loaded}`;
if (signature === lastSimSignature) return;
lastSimSignature = signature;
runButton.disabled = !available || running || halted;
pauseButton.disabled = !available || !running;
stepButton.disabled = !available || running;
resetButton.disabled = !available;
speedInput.disabled = !available;
let label = 'idle';
if (!available) label = 'engine unavailable';
else if (halted) label = 'halted — fix the fault, then reset';
else if (running) label = 'running';
else if (settled) label = 'settled';
else if (loaded) label = 'paused';
simState.className = `bb-sim-state${halted ? ' bb-sim-halted' : ''}${running ? ' bb-sim-running' : ''}`;
setText(simState, label);
},
setZoom(zoom) {
setText(zoomLabel, `${Math.round(zoom * 100)}%`);
},
destroy() {
bag.removeAll();
if (bar.parentNode) bar.parentNode.removeChild(bar);
}
};
}
export { SPEED_STEPS };
@@ -0,0 +1,536 @@
// Pointer and keyboard interaction.
//
// All mouse coordinates here are SCREEN space (CSS pixels relative to the canvas), and
// are converted to world space through the viewport before touching any geometry.
// devicePixelRatio never appears - see the policy note in renderer.js.
//
// Modes:
// select click to select, drag a component to move it, drag empty space to pan
// wire drag from hole to hole
// place:<type> a ghost follows the cursor; click a hole to commit
// Space or the middle button pans in any mode.
import {
holeAtWorldPoint,
holeWorldPos,
holesInStrip,
sameHole,
UPPER_ROWS,
BOARD_WIDTH,
BOARD_HEIGHT,
PITCH
} from '../shared/board-geometry.js';
import { getComponentDef } from '../shared/component-registry.js';
import { componentPinHoles } from '../shared/component-pins.js';
import { componentBounds } from './component-art.js';
import { createListenerBag } from './dom.js';
/** Pointer travel (screen px) before a press becomes a drag rather than a click. */
const DRAG_THRESHOLD = 4;
// How close the cursor must be to a hole, in WORLD units, to snap onto it.
//
// board-geometry's HOLE_HIT_RADIUS is half a pitch, which leaves the corners of every
// cell dead - roughly a fifth of the board selects nothing. That is a UI feel decision
// rather than a geometry fact, so the editor picks its own, more generous radius here.
// At 0.7 the snap regions of neighbouring holes overlap slightly and nearest-hole wins,
// so there is effectively no dead space and no risk of snapping to a distant hole.
const HOLE_SNAP_RADIUS = PITCH * 0.7;
// WheelEvent.deltaY is only in pixels when deltaMode is 0. Firefox reports deltaMode 1
// (LINES, ~3 per notch) where Chrome reports 0 (PIXELS, ~100 per notch) - a ~32x
// difference that makes an un-normalized zoom unusable outside Chromium. Normalizing at
// the event boundary keeps viewport.js in pixels and unaware of DOM event quirks, the
// same way it is kept unaware of devicePixelRatio.
const WHEEL_LINE_PX = 16;
const WHEEL_PAGE_FALLBACK_PX = 400;
/**
* The row a DIP-style package should anchor to for a hole the user clicked: the near
* side of the centre channel. Uses the shared row grouping rather than comparing row
* letters, so it cannot drift if the row alphabet ever changes.
*/
function channelSideRow(hole) {
return UPPER_ROWS.indexOf(hole.row) !== -1 ? 'e' : 'f';
}
function pointInRect(x, y, rect) {
return rect !== null && x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
}
export function createTools(options) {
const { canvas, viewport, state, renderer, sim, status, onSceneChange, onSelectionChange } = options;
const bag = createListenerBag();
let tool = { kind: 'select', type: null };
let wireColor = options.initialWireColor;
let spaceHeld = false;
// Transient interaction state
let gesture = null; // { kind, ... }
let hoverHole = null;
let hoverComponentUid = null;
// Cached canvas rect. pointermove is bound to `window` and needs the rect twice per
// event, and a high-poll mouse fires far more often than once per frame - reading it
// live forces a layout every time. Resize and scroll invalidate it explicitly; the
// TTL is the backstop for a layout shift that moves the canvas without either, so
// the rect is never more than RECT_TTL_MS stale.
let canvasRect = null;
let canvasRectAt = 0;
const RECT_TTL_MS = 250;
function bounds() {
const now = performance.now();
if (canvasRect === null || now - canvasRectAt > RECT_TTL_MS) {
canvasRect = canvas.getBoundingClientRect();
canvasRectAt = now;
}
return canvasRect;
}
function screenPoint(event) {
const rect = bounds();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
}
/** Whether a pointer event happened over the canvas itself. */
function isOverCanvas(event) {
const rect = bounds();
return event.clientX >= rect.left && event.clientX <= rect.right
&& event.clientY >= rect.top && event.clientY <= rect.bottom;
}
function worldPoint(event) {
const point = screenPoint(event);
return viewport.screenToWorld(point.x, point.y);
}
function holeAt(world) {
// Radius is in world units, so snapping feels the same at every zoom.
return holeAtWorldPoint(world.x, world.y, state.circuit.boards, HOLE_SNAP_RADIUS);
}
function componentAt(world) {
// Only boards under the cursor can hold the component under the cursor, so a
// cheap board test skips resolving pin geometry for everything elsewhere.
const nearbyBoards = new Set(
state.circuit.boards
.filter(b => world.x >= b.x - PITCH && world.x <= b.x + BOARD_WIDTH + PITCH
&& world.y >= b.y - PITCH && world.y <= b.y + BOARD_HEIGHT + PITCH)
.map(b => b.uid)
);
if (nearbyBoards.size === 0) return null;
// Topmost first, so later components win where they overlap.
const components = state.circuit.components;
for (let i = components.length - 1; i >= 0; i--) {
const component = components[i];
const boardUid = component.anchor ? component.anchor.board
: (component.props ? component.props.board : null);
if (boardUid !== null && boardUid !== undefined && !nearbyBoards.has(boardUid)) continue;
if (pointInRect(world.x, world.y, componentBounds(component, state.boards))) {
return component;
}
}
return null;
}
/** Which DIP switch lever (1..8) is under a world point, or 0. */
function switchAt(component, world) {
if (component.type !== 'dipSwitch8') return 0;
const pins = componentPinHoles(component);
let best = 0;
let bestDistance = PITCH * 0.6;
for (let k = 0; k < 8; k++) {
const hole = pins[k] && pins[k].hole;
if (!hole) continue;
const point = holeWorldPos(hole, state.boards);
if (!point) continue;
const distance = Math.abs(point.x - world.x);
if (distance < bestDistance) {
bestDistance = distance;
best = k + 1;
}
}
return best;
}
function ghostFor(hole) {
if (tool.kind !== 'place' || hole === null) return null;
const def = getComponentDef(tool.type);
if (def === null) return null;
let anchor = hole;
// DIP-style packages must straddle the channel, so snap onto the nearest of
// rows e/f rather than refusing every other row.
if (def.dipStyle && hole.kind === 'main') {
anchor = Object.assign({}, hole, { row: channelSideRow(hole) });
}
const component = state.buildComponent(tool.type, anchor, ghostExtraProps(hole));
if (component === null) return null;
const pins = componentPinHoles(component);
const valid = pins.length > 0 && pins.every(p => p.hole !== null);
return { component, valid, anchor };
}
function ghostExtraProps(hole) {
if (tool.type === 'powerSupply5V' && hole) {
return { board: hole.board, side: hole.kind === 'rail' ? (hole.rail.startsWith('top') ? 'top' : 'bottom') : 'top' };
}
if (tool.type === 'resistor' && hole) {
// Second terminal defaults four columns along, which the user then edits.
const to = Object.assign({}, hole);
if (to.kind === 'main') to.col = Math.min(63, to.col + 4);
else to.index = Math.min(50, to.index + 4);
return { to };
}
return undefined;
}
function publishScene(...layers) {
const ghost = tool.kind === 'place' ? ghostFor(hoverHole) : null;
renderer.setScene({
circuit: state.circuit,
boards: state.boards,
selection: state.selection,
hoverHole,
hoverStripPoints: hoverHole
? holesInStrip(hoverHole).map(h => holeWorldPos(h, state.boards)).filter(Boolean)
: null,
ghost,
pendingWire: gesture && gesture.kind === 'wire'
? { from: gesture.from, toPoint: gesture.toPoint, color: wireColor }
: null,
pressed: state.pressed
}, ...(layers.length > 0 ? layers : ['overlay']));
if (onSceneChange) onSceneChange();
}
// --- Gesture handlers ---
function beginPan(event) {
const point = screenPoint(event);
gesture = { kind: 'pan', lastX: point.x, lastY: point.y };
canvas.style.cursor = 'grabbing';
}
function tryPlace(hole) {
const ghost = ghostFor(hole);
if (ghost === null) return;
if (!ghost.valid) {
status.warn(`A ${getComponentDef(tool.type).label} does not fit there.`);
return;
}
const result = state.addComponent(tool.type, ghost.component.anchor, ghostExtraProps(hole));
if (!result.ok) {
status.warn(result.reason);
return;
}
for (const warning of result.warnings || []) status.warn(warning);
status.info(`Placed ${getComponentDef(tool.type).label}.`);
}
function onPointerDown(event) {
if (event.button !== 0 && event.button !== 1) return;
canvas.focus();
const world = worldPoint(event);
const screen = screenPoint(event);
if (event.button === 1 || spaceHeld) {
beginPan(event);
event.preventDefault();
return;
}
const hole = holeAt(world);
if (tool.kind === 'place') {
tryPlace(hole);
return;
}
if (tool.kind === 'wire') {
if (hole === null) {
beginPan(event);
return;
}
gesture = { kind: 'wire', from: hole, toPoint: world };
publishScene();
return;
}
// --- select mode ---
const component = componentAt(world);
// Interactive parts respond to a plain click, so the user can drive the
// simulation without switching tools.
if (component) {
if (component.type === 'pushButton') {
gesture = { kind: 'button', uid: component.uid };
state.setPressed(component.uid, true);
sim.setButton(component.uid, true);
publishScene('dynamic', 'overlay');
return;
}
if (component.type === 'dipSwitch8') {
const switchNumber = switchAt(component, world);
if (switchNumber > 0) {
const on = state.toggleSwitch(component.uid, switchNumber);
if (on !== null) {
sim.setSwitch(component.uid, switchNumber, on);
status.info(`Switch ${switchNumber} ${on ? 'on' : 'off'}.`);
}
return;
}
}
state.select(component.uid, event.shiftKey);
if (onSelectionChange) onSelectionChange();
const def = getComponentDef(component.type);
gesture = {
kind: 'maybeMove',
uid: component.uid,
movable: !def.anchorless,
startX: screen.x,
startY: screen.y
};
return;
}
const wire = hole ? state.wireAtHole(hole) : null;
if (wire) {
state.select(wire.uid, event.shiftKey);
if (onSelectionChange) onSelectionChange();
publishScene();
return;
}
if (!event.shiftKey) {
state.clearSelection();
if (onSelectionChange) onSelectionChange();
}
beginPan(event);
}
function onPointerMove(event) {
const world = worldPoint(event);
const screen = screenPoint(event);
if (gesture && gesture.kind === 'pan') {
viewport.panBy(screen.x - gesture.lastX, screen.y - gesture.lastY);
gesture.lastX = screen.x;
gesture.lastY = screen.y;
renderer.viewportChanged();
return;
}
if (gesture && gesture.kind === 'wire') {
gesture.toPoint = world;
const hole = holeAt(world);
// Snap the preview onto a hole when one is near.
if (hole !== null) {
const snapped = holeWorldPos(hole, state.boards);
if (snapped) gesture.toPoint = snapped;
}
hoverHole = hole;
publishScene();
return;
}
if (gesture && gesture.kind === 'maybeMove') {
const travelled = Math.hypot(screen.x - gesture.startX, screen.y - gesture.startY);
if (travelled > DRAG_THRESHOLD && gesture.movable) {
gesture = { kind: 'move', uid: gesture.uid };
} else {
return;
}
}
if (gesture && gesture.kind === 'move') {
const hole = holeAt(world);
if (hole !== null) {
const component = state.circuit.components.find(c => c.uid === gesture.uid);
if (component) {
const def = getComponentDef(component.type);
let anchor = hole;
if (def.dipStyle && hole.kind === 'main') {
anchor = Object.assign({}, hole, { row: channelSideRow(hole) });
}
if (!sameHole(component.anchor, anchor)) state.moveComponent(gesture.uid, anchor);
}
}
return;
}
if (gesture && gesture.kind === 'button') return;
// Idle hover. pointermove is bound to `window` so a drag that leaves the canvas
// keeps tracking - but that also means this fires for every mouse move anywhere
// on the page. Hit-testing is O(components), so bail out before doing any of it
// when the pointer is not actually over the canvas.
if (!isOverCanvas(event)) {
if (hoverHole !== null || hoverComponentUid !== null) {
hoverHole = null;
hoverComponentUid = null;
publishScene();
}
return;
}
const hole = holeAt(world);
const component = componentAt(world);
const componentUid = component ? component.uid : null;
const holeChanged = (hole === null) !== (hoverHole === null)
|| (hole !== null && hoverHole !== null && !sameHole(hole, hoverHole));
if (!holeChanged && componentUid === hoverComponentUid) return;
hoverHole = hole;
hoverComponentUid = componentUid;
canvas.style.cursor = cursorFor(hole, component);
publishScene();
}
function cursorFor(hole, component) {
if (spaceHeld) return 'grab';
if (tool.kind === 'place') return 'copy';
if (tool.kind === 'wire') return hole ? 'crosshair' : 'default';
if (component) {
if (component.type === 'pushButton' || component.type === 'dipSwitch8') return 'pointer';
return 'move';
}
return 'default';
}
function onPointerUp(event) {
if (!gesture) return;
if (gesture.kind === 'wire') {
const hole = holeAt(worldPoint(event));
if (hole !== null && !sameHole(hole, gesture.from)) {
const result = state.addWire(gesture.from, hole, wireColor);
if (!result.ok) status.warn(result.reason);
}
} else if (gesture.kind === 'button') {
state.setPressed(gesture.uid, false);
sim.setButton(gesture.uid, false);
}
const wasPan = gesture.kind === 'pan';
gesture = null;
canvas.style.cursor = wasPan ? 'default' : canvas.style.cursor;
publishScene('dynamic', 'overlay');
}
function onPointerLeave() {
if (hoverHole === null && hoverComponentUid === null) return;
hoverHole = null;
hoverComponentUid = null;
publishScene();
}
/** Wheel delta in pixels, whatever unit the browser reported it in. */
function wheelDeltaPixels(event) {
if (event.deltaMode === 1) return event.deltaY * WHEEL_LINE_PX;
if (event.deltaMode === 2) {
return event.deltaY * (canvas.clientHeight || WHEEL_PAGE_FALLBACK_PX);
}
return event.deltaY;
}
function onWheel(event) {
event.preventDefault();
const point = screenPoint(event);
viewport.zoomByWheel(point.x, point.y, wheelDeltaPixels(event));
renderer.viewportChanged();
if (onSceneChange) onSceneChange();
}
function onKeyDown(event) {
if (event.key === ' ' && !spaceHeld) {
spaceHeld = true;
canvas.style.cursor = 'grab';
event.preventDefault();
return;
}
if (event.key === 'Escape') {
if (gesture && gesture.kind === 'wire') {
gesture = null;
status.info('Wire cancelled.');
} else if (tool.kind !== 'select') {
options.setTool({ kind: 'select', type: null });
} else {
state.clearSelection();
if (onSelectionChange) onSelectionChange();
}
publishScene();
return;
}
if (event.key === 'Delete' || event.key === 'Backspace') {
const removed = state.deleteSelected();
if (removed > 0) {
status.info(`Deleted ${removed} item${removed === 1 ? '' : 's'}.`);
if (onSelectionChange) onSelectionChange();
}
event.preventDefault();
return;
}
if (event.key === 'r' || event.key === 'R') {
for (const uid of [...state.selection]) {
const result = state.rotateComponent(uid);
if (!result.ok && result.reason) status.warn(result.reason);
}
return;
}
if (event.key === '+' || event.key === '=') {
viewport.zoomAtCenter(renderer.width, renderer.height, 1.2);
renderer.viewportChanged();
} else if (event.key === '-' || event.key === '_') {
viewport.zoomAtCenter(renderer.width, renderer.height, 1 / 1.2);
renderer.viewportChanged();
}
}
function onKeyUp(event) {
if (event.key === ' ') {
spaceHeld = false;
canvas.style.cursor = 'default';
}
}
const invalidateRect = () => { canvasRect = null; };
bag.on(window, 'resize', invalidateRect);
// Capture, so an ancestor scrolling the canvas out from under us also counts.
bag.on(window, 'scroll', invalidateRect, true);
bag.on(canvas, 'pointerdown', onPointerDown);
bag.on(window, 'pointermove', onPointerMove);
bag.on(window, 'pointerup', onPointerUp);
bag.on(canvas, 'pointerleave', onPointerLeave);
bag.on(canvas, 'wheel', onWheel, { passive: false });
bag.on(canvas, 'keydown', onKeyDown);
bag.on(canvas, 'keyup', onKeyUp);
bag.on(canvas, 'contextmenu', e => e.preventDefault());
return {
get tool() { return tool; },
setTool(next) {
tool = next;
gesture = null;
canvas.style.cursor = cursorFor(hoverHole, null);
publishScene();
},
get wireColor() { return wireColor; },
setWireColor(color) {
wireColor = color;
for (const uid of [...state.selection]) state.setWireColor(uid, color);
publishScene('dynamic', 'overlay');
},
refresh: publishScene,
/** Drop the cached canvas rect. Call whenever the canvas may have moved. */
invalidateRect,
destroy() {
bag.removeAll();
}
};
}
@@ -0,0 +1,136 @@
// Pan / zoom state and the conversions between coordinate spaces.
//
// THREE SPACES, and every function here is named for the one it works in:
// screen space - CSS pixels relative to the canvas element's top-left. This is what
// mouse events give us (after subtracting the bounding rect).
// world space - the shared coordinate system all boards live in. board.x/board.y
// and board-geometry's world positions are in this space.
// board space - a single board's own frame. board-geometry owns it; nothing here
// touches it.
//
// The mapping is: screen = world * zoom + offset
// world = (screen - offset) / zoom
//
// devicePixelRatio is deliberately NOT part of this. It is a property of the canvas
// backing store, applied by the renderer as the base transform before the world
// transform, so every number in this file is in CSS pixels. See renderer.js.
/** Zoom limits. Below MIN a board is a few pixels tall; above MAX holes are huge. */
export const MIN_ZOOM = 0.15;
export const MAX_ZOOM = 6;
const ZOOM_STEP = 1.0015; // per unit of wheel deltaY
export function createViewport() {
let zoom = 1;
let offsetX = 0;
let offsetY = 0;
function clampZoom(value) {
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
}
return {
get zoom() { return zoom; },
get offsetX() { return offsetX; },
get offsetY() { return offsetY; },
/** Screen point (CSS px, canvas-relative) -> world point. */
screenToWorld(screenX, screenY) {
return { x: (screenX - offsetX) / zoom, y: (screenY - offsetY) / zoom };
},
/** World point -> screen point (CSS px, canvas-relative). */
worldToScreen(worldX, worldY) {
return { x: worldX * zoom + offsetX, y: worldY * zoom + offsetY };
},
/** Convert a screen-space distance to world units. */
screenToWorldDistance(distance) {
return distance / zoom;
},
/** Move the view by a screen-space delta (a drag). */
panBy(screenDX, screenDY) {
offsetX += screenDX;
offsetY += screenDY;
},
/**
* Zoom about a fixed screen point, so the world point under the cursor stays
* put. This is the whole trick to a wheel zoom that feels right.
*/
zoomAt(screenX, screenY, factor) {
const next = clampZoom(zoom * factor);
if (next === zoom) return;
const worldX = (screenX - offsetX) / zoom;
const worldY = (screenY - offsetY) / zoom;
zoom = next;
offsetX = screenX - worldX * zoom;
offsetY = screenY - worldY * zoom;
},
/** Wheel handler helper: converts a wheel delta into a zoom factor. */
zoomByWheel(screenX, screenY, deltaY) {
this.zoomAt(screenX, screenY, Math.pow(ZOOM_STEP, -deltaY));
},
/** Zoom about the centre of a viewport of the given CSS-pixel size. */
zoomAtCenter(width, height, factor) {
this.zoomAt(width / 2, height / 2, factor);
},
/** Set zoom directly, keeping the viewport centre fixed. */
setZoom(value, width, height) {
const next = clampZoom(value);
this.zoomAt(width / 2, height / 2, next / zoom);
},
/**
* Frame a world-space rect in a viewport of the given CSS-pixel size.
* @param {{x:number,y:number,w:number,h:number}} bounds
*/
fit(bounds, width, height, padding = 40) {
if (width <= 0 || height <= 0 || bounds.w <= 0 || bounds.h <= 0) return;
const scale = Math.min(
(width - padding * 2) / bounds.w,
(height - padding * 2) / bounds.h
);
zoom = clampZoom(scale);
offsetX = width / 2 - (bounds.x + bounds.w / 2) * zoom;
offsetY = height / 2 - (bounds.y + bounds.h / 2) * zoom;
},
/** World-space rect currently visible in a viewport of the given size. */
visibleWorldRect(width, height) {
const topLeft = this.screenToWorld(0, 0);
const bottomRight = this.screenToWorld(width, height);
return {
x: topLeft.x,
y: topLeft.y,
w: bottomRight.x - topLeft.x,
h: bottomRight.y - topLeft.y
};
},
/** Serializable state, for persisting the view between sessions. */
toJSON() {
return { zoom, offsetX, offsetY };
},
/** Restore from toJSON(). Ignores anything malformed. */
restore(state) {
if (!state || typeof state !== 'object') return false;
if (![state.zoom, state.offsetX, state.offsetY].every(Number.isFinite)) return false;
zoom = clampZoom(state.zoom);
offsetX = state.offsetX;
offsetY = state.offsetY;
return true;
}
};
}
/** True when two world-space rects overlap - used to cull off-screen boards. */
export function rectsIntersect(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
@@ -0,0 +1,96 @@
/**
* Shared constants for the breadboard simulation engine.
*
* IMMUTABILITY BOUNDARY (read this before editing anything in engine/):
* - constants.js, drive.js, union-find.js, net-builder.js and every model's
* pin/description table are PURE: no mutation, no I/O, safe to call from
* anywhere and freely testable.
* - net-state.js, event-queue.js and simulation.js own the HOT PATH. They use
* typed arrays and controlled in-place mutation on purpose. Nothing outside
* those three files may mutate their buffers; they expose accessor methods.
*/
/** Wire-format net level codes. These exact numbers go to the UI in `frame.netLevels`. */
export const LEVEL_LOW = 0;
export const LEVEL_HIGH = 1;
export const LEVEL_HIGHZ = 2;
export const LEVEL_WEAK_LOW = 3;
export const LEVEL_WEAK_HIGH = 4;
export const LEVEL_CONTENTION = 5;
/** Drive strengths, ordered. A higher strength always wins over a lower one. */
export const STRENGTH_HIGHZ = 0;
export const STRENGTH_WEAK = 1;
export const STRENGTH_STRONG = 2;
export const STRENGTH_SUPPLY = 3;
export const VALUE_LOW = 0;
export const VALUE_HIGH = 1;
/**
* What a chip input samples when its net is neither a clean low nor a clean
* high. A floating 74HC input is physically indeterminate, so the engine
* refuses to guess: it propagates UNKNOWN through three-valued logic and a
* gate whose output cannot be determined releases its driver to high-Z.
*/
export const INPUT_LOW = 0;
export const INPUT_HIGH = 1;
export const INPUT_UNKNOWN = 2;
/** Maps a net level code to what a chip input pin reads off it. */
export const LEVEL_TO_INPUT = new Uint8Array([
INPUT_LOW, // LEVEL_LOW
INPUT_HIGH, // LEVEL_HIGH
INPUT_UNKNOWN, // LEVEL_HIGHZ -- floating, indeterminate
INPUT_LOW, // LEVEL_WEAK_LOW -- a pull-down still reads as a low
INPUT_HIGH, // LEVEL_WEAK_HIGH -- a pull-up still reads as a high
INPUT_UNKNOWN, // LEVEL_CONTENTION
]);
export const SUPPLY_VOLTAGE = 5.0;
/** Nominal voltage a net sits at, by level code. NaN means "indeterminate". */
export const LEVEL_VOLTAGE = new Float64Array([
0.0, // LEVEL_LOW
SUPPLY_VOLTAGE, // LEVEL_HIGH
NaN, // LEVEL_HIGHZ
0.0, // LEVEL_WEAK_LOW
SUPPLY_VOLTAGE, // LEVEL_WEAK_HIGH
NaN, // LEVEL_CONTENTION
]);
/** Sentinel for "this pin is not connected to any net". */
export const NO_NET = -1;
/** Default gate propagation delay, ns. Per-type values live in each model. */
export const DEFAULT_DELAY_NS = 10;
/**
* Guards against zero-delay feedback loops (e.g. an inverter wired to itself
* through a component with no delay). Exceeding this at a single sim instant
* raises a warning and pauses instead of hanging the worker.
*/
export const MAX_EVENTS_PER_INSTANT = 100000;
/** LED current thresholds, amps. */
export const LED_WARN_CURRENT = 0.020;
export const LED_BURNOUT_CURRENT = 0.050;
/**
* Resistance an LED presents when nothing limits it. Real LEDs have a few ohms
* of bulk resistance; the point of the small number is that a resistor-less LED
* across 5 V computes a burnout-level current, which is what really happens.
*/
export const LED_INTRINSIC_OHMS = 10;
/** Forward voltage by LED colour. */
export const LED_FORWARD_VOLTAGE = Object.freeze({
red: 1.8,
yellow: 2.1,
orange: 2.0,
green: 2.1,
blue: 3.0,
white: 3.2,
});
export const DEFAULT_LED_FORWARD_VOLTAGE = 2.0;

Some files were not shown because too many files have changed in this diff Show More