diff --git a/CHANGELOG.md b/CHANGELOG.md index fb6b3c3..f516a49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - .NET 8 / Open XML SDK 3.x Migration +### Added +- **DocxodusWeb — serverless redline & ticket web app** - ASP.NET Minimal API app for Cloud Run / serverless deployment + - `POST /api/compare` — Upload two DOCX files, receive a redlined DOCX with tracked changes + - `POST /api/compare/html` — Upload two DOCX files, receive HTML redline preview + - Rendering issue ticket system with SQLite storage — submit two DOCX files + description to report comparison bugs + - Tickets automatically run the Docxodus comparison and store original, modified, and redlined documents + - Simple HTML/CSS/JS frontend with redline tool, ticket list, and ticket submission tabs + - Dockerfile for containerized deployment (Google Cloud Run, AWS Fargate, etc.) + - Configurable via `DATA_DIR` environment variable for persistent storage + ### Fixed (npm) - **TypeScript subpath exports not resolving under `moduleResolution: "node"` (Issue #113)** - Added `typesVersions` fallback to npm package.json so `docxodus/react` and `docxodus/worker` subpath imports resolve types correctly under all TypeScript module resolution modes. Also reordered export conditions to put `types` before `import` per TypeScript requirements. diff --git a/Docxodus.sln b/Docxodus.sln index df6695e..ece4c8b 100644 --- a/Docxodus.sln +++ b/Docxodus.sln @@ -17,6 +17,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "wasm", "wasm", "{1763F37B-6 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxodusWasm", "wasm\DocxodusWasm\DocxodusWasm.csproj", "{A4D71AD0-DF96-4702-A2A3-10C91BBD90E3}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "web", "web", "{8A2B6F4E-1C3D-4E5F-9A7B-2D4E6F8A0B1C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxodusWeb", "web\DocxodusWeb\DocxodusWeb.csproj", "{B5C7D9E1-2F4A-4B6C-8D0E-3F5A7B9C1D2E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -46,10 +50,15 @@ Global {A4D71AD0-DF96-4702-A2A3-10C91BBD90E3}.Debug|Any CPU.Build.0 = Debug|Any CPU {A4D71AD0-DF96-4702-A2A3-10C91BBD90E3}.Release|Any CPU.ActiveCfg = Release|Any CPU {A4D71AD0-DF96-4702-A2A3-10C91BBD90E3}.Release|Any CPU.Build.0 = Release|Any CPU + {B5C7D9E1-2F4A-4B6C-8D0E-3F5A7B9C1D2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5C7D9E1-2F4A-4B6C-8D0E-3F5A7B9C1D2E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5C7D9E1-2F4A-4B6C-8D0E-3F5A7B9C1D2E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5C7D9E1-2F4A-4B6C-8D0E-3F5A7B9C1D2E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {EDB9FC41-541D-41FE-A95C-4A25BF4823B6} = {3B57DC3E-0543-4470-B094-701C65C843D5} {15EAF76F-3C7C-4747-A686-6F9FC65D2968} = {3B57DC3E-0543-4470-B094-701C65C843D5} {A4D71AD0-DF96-4702-A2A3-10C91BBD90E3} = {1763F37B-6FCB-4FA2-B410-4E573BDBC84F} + {B5C7D9E1-2F4A-4B6C-8D0E-3F5A7B9C1D2E} = {8A2B6F4E-1C3D-4E5F-9A7B-2D4E6F8A0B1C} EndGlobalSection EndGlobal diff --git a/web/DocxodusWeb/.dockerignore b/web/DocxodusWeb/.dockerignore new file mode 100644 index 0000000..3e7c6e8 --- /dev/null +++ b/web/DocxodusWeb/.dockerignore @@ -0,0 +1,3 @@ +bin/ +obj/ +appdata/ diff --git a/web/DocxodusWeb/Data/TicketDbContext.cs b/web/DocxodusWeb/Data/TicketDbContext.cs new file mode 100644 index 0000000..4f1e866 --- /dev/null +++ b/web/DocxodusWeb/Data/TicketDbContext.cs @@ -0,0 +1,59 @@ +#nullable enable + +using Microsoft.EntityFrameworkCore; + +namespace DocxodusWeb.Data; + +public class TicketDbContext : DbContext +{ + public TicketDbContext(DbContextOptions options) : base(options) { } + + public DbSet Tickets => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Status).HasConversion(); + }); + } +} + +public class Ticket +{ + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string? SubmitterEmail { get; set; } + public TicketStatus Status { get; set; } = TicketStatus.Open; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + /// Path to the original .docx file on disk. + public string OriginalFilePath { get; set; } = string.Empty; + public string OriginalFileName { get; set; } = string.Empty; + + /// Path to the modified .docx file on disk. + public string ModifiedFilePath { get; set; } = string.Empty; + public string ModifiedFileName { get; set; } = string.Empty; + + /// Path to the redline .docx produced by Docxodus (generated on submission). + public string? RedlineFilePath { get; set; } + + /// Comparison log warnings/errors, if any. + public string? ComparisonLog { get; set; } + + /// Number of revisions detected in the redline. + public int? RevisionCount { get; set; } +} + +public enum TicketStatus +{ + Open, + InProgress, + Resolved, + WontFix, + Duplicate +} diff --git a/web/DocxodusWeb/Dockerfile b/web/DocxodusWeb/Dockerfile new file mode 100644 index 0000000..b1edbe6 --- /dev/null +++ b/web/DocxodusWeb/Dockerfile @@ -0,0 +1,30 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +# Copy solution and project files for restore +COPY Docxodus/Docxodus.csproj Docxodus/ +COPY web/DocxodusWeb/DocxodusWeb.csproj web/DocxodusWeb/ +RUN dotnet restore web/DocxodusWeb/DocxodusWeb.csproj + +# Copy everything and build +COPY Docxodus/ Docxodus/ +COPY web/DocxodusWeb/ web/DocxodusWeb/ +RUN dotnet publish web/DocxodusWeb/DocxodusWeb.csproj -c Release -o /app/publish + +# Runtime image +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app + +# Install native dependencies for SkiaSharp +RUN apt-get update && apt-get install -y --no-install-recommends \ + libfontconfig1 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /app/publish . + +# Cloud Run uses PORT env var +ENV ASPNETCORE_URLS=http://+:8080 +ENV DATA_DIR=/data +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "DocxodusWeb.dll"] diff --git a/web/DocxodusWeb/DocxodusWeb.csproj b/web/DocxodusWeb/DocxodusWeb.csproj new file mode 100644 index 0000000..7744e9d --- /dev/null +++ b/web/DocxodusWeb/DocxodusWeb.csproj @@ -0,0 +1,21 @@ + + + net8.0 + enable + enable + latest + DocxodusWeb + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + diff --git a/web/DocxodusWeb/Program.cs b/web/DocxodusWeb/Program.cs new file mode 100644 index 0000000..587942c --- /dev/null +++ b/web/DocxodusWeb/Program.cs @@ -0,0 +1,345 @@ +#nullable enable + +using System.Globalization; +using Docxodus; +using DocxodusWeb.Data; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +// Configure SQLite — store DB in a persistent data directory +var dataDir = Environment.GetEnvironmentVariable("DATA_DIR") ?? Path.Combine(Directory.GetCurrentDirectory(), "appdata"); +Directory.CreateDirectory(dataDir); +var uploadsDir = Path.Combine(dataDir, "uploads"); +Directory.CreateDirectory(uploadsDir); + +builder.Services.AddDbContext(opt => + opt.UseSqlite($"Data Source={Path.Combine(dataDir, "tickets.db")}")); + +// Allow large file uploads (100 MB) +builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 100 * 1024 * 1024); + +var app = builder.Build(); + +// Auto-migrate on startup +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); +} + +app.UseStaticFiles(); + +// ────────────────────────────────────────────── +// Redline API +// ────────────────────────────────────────────── + +app.MapPost("/api/compare", async (HttpRequest request) => +{ + var form = await request.ReadFormAsync(); + var originalFile = form.Files.GetFile("original"); + var modifiedFile = form.Files.GetFile("modified"); + + if (originalFile is null || modifiedFile is null) + return Results.BadRequest(new { error = "Both 'original' and 'modified' .docx files are required." }); + + var author = form["author"].FirstOrDefault() ?? "Docxodus"; + var detailThreshold = 0.0; + if (form.ContainsKey("detailThreshold") && + double.TryParse(form["detailThreshold"], NumberStyles.Float, CultureInfo.InvariantCulture, out var dt)) + detailThreshold = dt; + + var settings = new WmlComparerSettings + { + AuthorForRevisions = author, + DetailThreshold = detailThreshold, + DetectMoves = true, + DetectFormatChanges = true, + }; + + try + { + var originalBytes = await ReadFormFile(originalFile); + var modifiedBytes = await ReadFormFile(modifiedFile); + + var originalDoc = new WmlDocument("original.docx", originalBytes); + var modifiedDoc = new WmlDocument("modified.docx", modifiedBytes); + + var result = WmlComparer.Compare(originalDoc, modifiedDoc, settings); + + return Results.File(result.DocumentByteArray, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "redline.docx"); + } + catch (Exception ex) + { + return Results.Problem(detail: ex.Message, statusCode: 500); + } +}).DisableAntiforgery(); + +app.MapPost("/api/compare/html", async (HttpRequest request) => +{ + var form = await request.ReadFormAsync(); + var originalFile = form.Files.GetFile("original"); + var modifiedFile = form.Files.GetFile("modified"); + + if (originalFile is null || modifiedFile is null) + return Results.BadRequest(new { error = "Both 'original' and 'modified' .docx files are required." }); + + var author = form["author"].FirstOrDefault() ?? "Docxodus"; + var detailThreshold = 0.0; + if (form.ContainsKey("detailThreshold") && + double.TryParse(form["detailThreshold"], NumberStyles.Float, CultureInfo.InvariantCulture, out var dt)) + detailThreshold = dt; + + var settings = new WmlComparerSettings + { + AuthorForRevisions = author, + DetailThreshold = detailThreshold, + DetectMoves = true, + DetectFormatChanges = true, + }; + + try + { + var originalBytes = await ReadFormFile(originalFile); + var modifiedBytes = await ReadFormFile(modifiedFile); + + var originalDoc = new WmlDocument("original.docx", originalBytes); + var modifiedDoc = new WmlDocument("modified.docx", modifiedBytes); + + var result = WmlComparer.Compare(originalDoc, modifiedDoc, settings); + var htmlSettings = new WmlToHtmlConverterSettings + { + RenderTrackedChanges = true, + }; + var html = WmlToHtmlConverter.ConvertToHtml(result, htmlSettings); + + return Results.Content(html.ToString(), "text/html"); + } + catch (Exception ex) + { + return Results.Problem(detail: ex.Message, statusCode: 500); + } +}).DisableAntiforgery(); + +// ────────────────────────────────────────────── +// Ticket API +// ────────────────────────────────────────────── + +app.MapGet("/api/tickets", async (TicketDbContext db, string? status, int page = 1, int pageSize = 25) => +{ + var query = db.Tickets.AsQueryable(); + + if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, true, out var s)) + query = query.Where(t => t.Status == s); + + var total = await query.CountAsync(); + var tickets = await query + .OrderByDescending(t => t.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(t => new + { + t.Id, + t.Title, + t.Description, + t.SubmitterEmail, + Status = t.Status.ToString(), + t.CreatedAt, + t.UpdatedAt, + t.OriginalFileName, + t.ModifiedFileName, + t.RevisionCount, + t.ComparisonLog, + }) + .ToListAsync(); + + return Results.Ok(new { total, page, pageSize, tickets }); +}); + +app.MapGet("/api/tickets/{id:int}", async (int id, TicketDbContext db) => +{ + var t = await db.Tickets.FindAsync(id); + if (t is null) return Results.NotFound(); + + return Results.Ok(new + { + t.Id, + t.Title, + t.Description, + t.SubmitterEmail, + Status = t.Status.ToString(), + t.CreatedAt, + t.UpdatedAt, + t.OriginalFileName, + t.ModifiedFileName, + t.RevisionCount, + t.ComparisonLog, + }); +}); + +app.MapPost("/api/tickets", async (HttpRequest request, TicketDbContext db) => +{ + var form = await request.ReadFormAsync(); + var originalFile = form.Files.GetFile("original"); + var modifiedFile = form.Files.GetFile("modified"); + + if (originalFile is null || modifiedFile is null) + return Results.BadRequest(new { error = "Both 'original' and 'modified' .docx files are required." }); + + var title = form["title"].FirstOrDefault(); + var description = form["description"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(title)) + return Results.BadRequest(new { error = "'title' is required." }); + + var ticketDir = Path.Combine(uploadsDir, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(ticketDir); + + var originalPath = Path.Combine(ticketDir, "original.docx"); + var modifiedPath = Path.Combine(ticketDir, "modified.docx"); + await SaveFormFile(originalFile, originalPath); + await SaveFormFile(modifiedFile, modifiedPath); + + // Run redline and store result + string? redlinePath = null; + string? comparisonLog = null; + int? revisionCount = null; + + try + { + var originalBytes = await File.ReadAllBytesAsync(originalPath); + var modifiedBytes = await File.ReadAllBytesAsync(modifiedPath); + var originalDoc = new WmlDocument("original.docx", originalBytes); + var modifiedDoc = new WmlDocument("modified.docx", modifiedBytes); + + var log = new ComparisonLog(); + var settings = new WmlComparerSettings + { + AuthorForRevisions = "Docxodus", + DetailThreshold = 0, + DetectMoves = true, + DetectFormatChanges = true, + Log = log, + }; + + var result = WmlComparer.Compare(originalDoc, modifiedDoc, settings); + var revisions = WmlComparer.GetRevisions(result, settings); + + redlinePath = Path.Combine(ticketDir, "redline.docx"); + await File.WriteAllBytesAsync(redlinePath, result.DocumentByteArray); + revisionCount = revisions.Count; + + if (log.HasWarnings || log.HasErrors) + { + var parts = new List(); + foreach (var w in log.Warnings) parts.Add($"WARN: [{w.Code}] {w.Message}"); + foreach (var e in log.Errors) parts.Add($"ERROR: [{e.Code}] {e.Message}"); + comparisonLog = string.Join("\n", parts); + } + } + catch (Exception ex) + { + comparisonLog = $"Comparison failed: {ex.Message}"; + } + + var ticket = new Ticket + { + Title = title, + Description = description ?? string.Empty, + SubmitterEmail = form["email"].FirstOrDefault(), + OriginalFilePath = originalPath, + OriginalFileName = originalFile.FileName, + ModifiedFilePath = modifiedPath, + ModifiedFileName = modifiedFile.FileName, + RedlineFilePath = redlinePath, + ComparisonLog = comparisonLog, + RevisionCount = revisionCount, + }; + + db.Tickets.Add(ticket); + await db.SaveChangesAsync(); + + return Results.Created($"/api/tickets/{ticket.Id}", new + { + ticket.Id, + ticket.Title, + Status = ticket.Status.ToString(), + ticket.RevisionCount, + ticket.ComparisonLog, + }); +}).DisableAntiforgery(); + +app.MapPatch("/api/tickets/{id:int}", async (int id, HttpRequest request, TicketDbContext db) => +{ + var ticket = await db.Tickets.FindAsync(id); + if (ticket is null) return Results.NotFound(); + + var body = await request.ReadFromJsonAsync(); + if (body is null) return Results.BadRequest(); + + if (body.Status is not null && Enum.TryParse(body.Status, true, out var s)) + ticket.Status = s; + if (body.Title is not null) + ticket.Title = body.Title; + if (body.Description is not null) + ticket.Description = body.Description; + + ticket.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + + return Results.Ok(new { ticket.Id, Status = ticket.Status.ToString(), ticket.UpdatedAt }); +}); + +app.MapGet("/api/tickets/{id:int}/files/{which}", async (int id, string which, TicketDbContext db) => +{ + var ticket = await db.Tickets.FindAsync(id); + if (ticket is null) return Results.NotFound(); + + string? filePath = which.ToLowerInvariant() switch + { + "original" => ticket.OriginalFilePath, + "modified" => ticket.ModifiedFilePath, + "redline" => ticket.RedlineFilePath, + _ => null, + }; + + if (filePath is null || !File.Exists(filePath)) + return Results.NotFound(); + + var fileName = which.ToLowerInvariant() switch + { + "original" => ticket.OriginalFileName, + "modified" => ticket.ModifiedFileName, + "redline" => "redline.docx", + _ => "file.docx", + }; + + return Results.File(filePath, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + fileName); +}); + +// SPA fallback — serve index.html for non-API, non-file routes +app.MapFallbackToFile("index.html"); + +app.Run(); + +// ────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────── + +static async Task ReadFormFile(IFormFile file) +{ + using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + return ms.ToArray(); +} + +static async Task SaveFormFile(IFormFile file, string path) +{ + using var stream = File.Create(path); + await file.CopyToAsync(stream); +} + +record TicketUpdateDto(string? Status, string? Title, string? Description); diff --git a/web/DocxodusWeb/Properties/launchSettings.json b/web/DocxodusWeb/Properties/launchSettings.json new file mode 100644 index 0000000..08dd48e --- /dev/null +++ b/web/DocxodusWeb/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "DocxodusWeb": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5050", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/web/DocxodusWeb/wwwroot/app.js b/web/DocxodusWeb/wwwroot/app.js new file mode 100644 index 0000000..a4b35e2 --- /dev/null +++ b/web/DocxodusWeb/wwwroot/app.js @@ -0,0 +1,212 @@ +// ── Tab navigation ── +document.querySelectorAll('.tab').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); + btn.classList.add('active'); + document.getElementById(btn.dataset.tab).classList.add('active'); + if (btn.dataset.tab === 'tickets') loadTickets(); + }); +}); + +// ── Redline ── +const redlineForm = document.getElementById('redline-form'); +const redlineStatus = document.getElementById('redline-status'); + +function showStatus(el, msg, type) { + el.textContent = msg; + el.className = `status ${type}`; + el.hidden = false; +} + +redlineForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(redlineForm); + const btn = document.getElementById('btn-compare'); + btn.disabled = true; + showStatus(redlineStatus, 'Comparing documents...', 'info'); + + try { + const resp = await fetch('/api/compare', { method: 'POST', body: formData }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({ detail: resp.statusText })); + throw new Error(err.detail || err.error || 'Comparison failed'); + } + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'redline.docx'; + a.click(); + URL.revokeObjectURL(url); + showStatus(redlineStatus, 'Redline downloaded!', 'success'); + } catch (err) { + showStatus(redlineStatus, err.message, 'error'); + } finally { + btn.disabled = false; + } +}); + +document.getElementById('btn-preview').addEventListener('click', async () => { + const formData = new FormData(redlineForm); + const btn = document.getElementById('btn-preview'); + btn.disabled = true; + showStatus(redlineStatus, 'Generating HTML preview...', 'info'); + const preview = document.getElementById('html-preview'); + + try { + const resp = await fetch('/api/compare/html', { method: 'POST', body: formData }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({ detail: resp.statusText })); + throw new Error(err.detail || err.error || 'Comparison failed'); + } + const html = await resp.text(); + preview.innerHTML = ''; + const iframe = document.createElement('iframe'); + preview.appendChild(iframe); + preview.hidden = false; + iframe.srcdoc = html; + showStatus(redlineStatus, 'Preview ready.', 'success'); + } catch (err) { + showStatus(redlineStatus, err.message, 'error'); + preview.hidden = true; + } finally { + btn.disabled = false; + } +}); + +// ── Tickets list ── +let currentPage = 1; + +async function loadTickets(page = 1) { + currentPage = page; + const status = document.getElementById('ticket-filter').value; + const params = new URLSearchParams({ page, pageSize: 25 }); + if (status) params.set('status', status); + + try { + const resp = await fetch(`/api/tickets?${params}`); + const data = await resp.json(); + const tbody = document.querySelector('#ticket-table tbody'); + tbody.innerHTML = ''; + + for (const t of data.tickets) { + const tr = document.createElement('tr'); + tr.addEventListener('click', () => openTicketModal(t.id)); + const badgeClass = 'badge-' + t.status.toLowerCase().replace(/\s+/g, ''); + tr.innerHTML = ` + ${t.id} + ${esc(t.title)} + ${t.status} + ${t.revisionCount ?? '—'} + ${new Date(t.createdAt).toLocaleDateString()} + + Original · + Modified + ${t.revisionCount != null ? ` · Redline` : ''} + + `; + tbody.appendChild(tr); + } + + // Paging + const paging = document.getElementById('ticket-paging'); + const totalPages = Math.ceil(data.total / data.pageSize); + paging.innerHTML = ''; + for (let p = 1; p <= totalPages; p++) { + const btn = document.createElement('button'); + btn.textContent = p; + btn.disabled = p === currentPage; + btn.addEventListener('click', () => loadTickets(p)); + paging.appendChild(btn); + } + } catch (err) { + console.error('Failed to load tickets', err); + } +} + +document.getElementById('ticket-filter').addEventListener('change', () => loadTickets(1)); +document.getElementById('btn-refresh').addEventListener('click', () => loadTickets(currentPage)); + +// ── Ticket detail modal ── +const modal = document.getElementById('ticket-modal'); +let currentTicketId = null; + +async function openTicketModal(id) { + currentTicketId = id; + const resp = await fetch(`/api/tickets/${id}`); + const t = await resp.json(); + + document.getElementById('modal-title').textContent = `#${t.id} — ${t.title}`; + document.getElementById('modal-status').value = t.status; + + let html = ` +

Description: ${esc(t.description) || 'None'}

+

Submitter: ${esc(t.submitterEmail) || 'Anonymous'}

+

Created: ${new Date(t.createdAt).toLocaleString()}

+

Updated: ${new Date(t.updatedAt).toLocaleString()}

+

Revisions detected: ${t.revisionCount ?? 'N/A'}

+

Files: + ${esc(t.originalFileName)} · + ${esc(t.modifiedFileName)} + ${t.revisionCount != null ? ` · Redline` : ''} +

+ `; + if (t.comparisonLog) { + html += `

Comparison Log:

${esc(t.comparisonLog)}
`; + } + document.getElementById('modal-body').innerHTML = html; + modal.showModal(); +} + +document.getElementById('btn-close-modal').addEventListener('click', () => modal.close()); +modal.addEventListener('click', (e) => { if (e.target === modal) modal.close(); }); + +document.getElementById('btn-update-status').addEventListener('click', async () => { + const status = document.getElementById('modal-status').value; + await fetch(`/api/tickets/${currentTicketId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }), + }); + modal.close(); + loadTickets(currentPage); +}); + +// ── Submit ticket ── +const ticketForm = document.getElementById('ticket-form'); +const submitStatus = document.getElementById('submit-status'); + +ticketForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(ticketForm); + const btn = document.getElementById('btn-submit-ticket'); + btn.disabled = true; + showStatus(submitStatus, 'Uploading files and running comparison...', 'info'); + + try { + const resp = await fetch('/api/tickets', { method: 'POST', body: formData }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({ detail: resp.statusText })); + throw new Error(err.detail || err.error || 'Submission failed'); + } + const data = await resp.json(); + let msg = `Ticket #${data.id} created.`; + if (data.revisionCount != null) msg += ` ${data.revisionCount} revision(s) detected.`; + if (data.comparisonLog) msg += ` (see ticket for comparison warnings)`; + showStatus(submitStatus, msg, 'success'); + ticketForm.reset(); + } catch (err) { + showStatus(submitStatus, err.message, 'error'); + } finally { + btn.disabled = false; + } +}); + +// ── Util ── +function esc(s) { + if (!s) return ''; + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; +} diff --git a/web/DocxodusWeb/wwwroot/index.html b/web/DocxodusWeb/wwwroot/index.html new file mode 100644 index 0000000..aacdc06 --- /dev/null +++ b/web/DocxodusWeb/wwwroot/index.html @@ -0,0 +1,126 @@ + + + + + + Docxodus — Redline & Tickets + + + +
+

Docxodus

+ +
+ +
+ +
+

Compare Documents

+

Upload two Word documents to generate a redline with tracked changes.

+
+
+ + +
+
+ Options +
+ + +
+
+
+ + +
+
+ + +
+ + +
+

Rendering Issue Tickets

+
+ + +
+ + + + + + + +
IDTitleStatusRevisionsCreatedActions
+
+
+ + +
+

Submit a Rendering Issue

+

Upload two documents that produce incorrect redline output, along with a description of the issue. + The comparison will run automatically and the result will be stored for review.

+
+ + + +
+ + +
+
+ +
+
+ +
+
+ + + + + + + + + diff --git a/web/DocxodusWeb/wwwroot/style.css b/web/DocxodusWeb/wwwroot/style.css new file mode 100644 index 0000000..70e0da4 --- /dev/null +++ b/web/DocxodusWeb/wwwroot/style.css @@ -0,0 +1,225 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #f8f9fa; + --surface: #ffffff; + --border: #dee2e6; + --primary: #2563eb; + --primary-hover: #1d4ed8; + --danger: #dc2626; + --text: #1a1a1a; + --muted: #6b7280; + --radius: 6px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +header { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 1rem 2rem; + display: flex; + align-items: center; + gap: 2rem; +} + +header h1 { + font-size: 1.4rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +nav { display: flex; gap: 0.25rem; } + +.tab { + background: none; + border: none; + padding: 0.5rem 1rem; + cursor: pointer; + border-radius: var(--radius); + font-size: 0.9rem; + color: var(--muted); + transition: all 0.15s; +} +.tab:hover { background: var(--bg); color: var(--text); } +.tab.active { background: var(--primary); color: #fff; } + +main { max-width: 960px; margin: 2rem auto; padding: 0 1rem; } + +.panel { display: none; } +.panel.active { display: block; } + +.panel h2 { margin-bottom: 0.5rem; } +.panel > p { color: var(--muted); margin-bottom: 1.5rem; } + +/* Forms */ +label { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.85rem; + font-weight: 500; + color: var(--muted); +} + +input[type="text"], input[type="email"], input[type="number"], textarea, select { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.95rem; + color: var(--text); + background: var(--surface); +} +textarea { resize: vertical; } + +input[type="file"] { + padding: 0.5rem; + border: 2px dashed var(--border); + border-radius: var(--radius); + cursor: pointer; + background: var(--bg); +} +input[type="file"]:hover { border-color: var(--primary); } + +.file-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin: 1rem 0; +} + +form > label { margin-bottom: 1rem; } + +details { margin: 1rem 0; } +summary { cursor: pointer; font-size: 0.9rem; color: var(--muted); } +.options-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-top: 0.75rem; +} + +.actions { + display: flex; + gap: 0.75rem; + margin-top: 1rem; +} + +button { + padding: 0.55rem 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + font-size: 0.9rem; + background: var(--surface); + transition: all 0.15s; +} +button:hover { background: var(--bg); } + +button[type="submit"], .actions button:first-child { + background: var(--primary); + color: #fff; + border-color: var(--primary); +} +button[type="submit"]:hover, .actions button:first-child:hover { + background: var(--primary-hover); +} +button:disabled { opacity: 0.5; cursor: not-allowed; } + +/* Status messages */ +.status { + margin-top: 1rem; + padding: 0.75rem 1rem; + border-radius: var(--radius); + font-size: 0.9rem; +} +.status.info { background: #dbeafe; color: #1e40af; } +.status.success { background: #dcfce7; color: #166534; } +.status.error { background: #fee2e2; color: #991b1b; } + +/* Table */ +table { + width: 100%; + border-collapse: collapse; + background: var(--surface); + border-radius: var(--radius); + overflow: hidden; + border: 1px solid var(--border); + margin-top: 1rem; +} +th, td { padding: 0.6rem 0.75rem; text-align: left; font-size: 0.9rem; } +th { background: var(--bg); font-weight: 600; color: var(--muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; } +tr:not(:last-child) td { border-bottom: 1px solid var(--border); } +tbody tr:hover { background: #f1f5f9; cursor: pointer; } + +.toolbar { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.paging { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; + justify-content: center; +} + +/* Badge */ +.badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; +} +.badge-open { background: #dbeafe; color: #1e40af; } +.badge-inprogress { background: #fef3c7; color: #92400e; } +.badge-resolved { background: #dcfce7; color: #166534; } +.badge-wontfix { background: #f3f4f6; color: #6b7280; } +.badge-duplicate { background: #f3f4f6; color: #6b7280; } + +/* Modal */ +dialog { + border: none; + border-radius: 8px; + padding: 0; + max-width: 600px; + width: 90%; + box-shadow: 0 20px 60px rgba(0,0,0,0.15); +} +dialog::backdrop { background: rgba(0,0,0,0.4); } +.modal-content { padding: 1.5rem; } +.modal-content h3 { margin-bottom: 1rem; } +#modal-body { margin-bottom: 1rem; font-size: 0.9rem; line-height: 1.7; } +#modal-body p { margin-bottom: 0.5rem; } +#modal-body .log { background: var(--bg); padding: 0.5rem; border-radius: var(--radius); white-space: pre-wrap; font-family: monospace; font-size: 0.8rem; margin-top: 0.5rem; } +.modal-actions { display: flex; gap: 0.5rem; align-items: center; border-top: 1px solid var(--border); padding-top: 1rem; } +.modal-actions select { flex: 0 0 auto; } +#btn-close-modal { margin-left: auto; } + +/* HTML preview */ +#html-preview { + margin-top: 1.5rem; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: auto; + max-height: 600px; + background: #fff; +} +#html-preview iframe { + width: 100%; + min-height: 500px; + border: none; +} + +/* Responsive */ +@media (max-width: 640px) { + header { flex-direction: column; gap: 0.75rem; } + .file-row, .options-grid { grid-template-columns: 1fr; } +}