Updated the proostekrant to work properly again.

This commit is contained in:
douwe
2026-10-09 10:33:21 +02:00
parent 56d143b74b
commit 256a81a770
133 changed files with 10491 additions and 837 deletions
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="icon" href="images/logo.jpg" type="image/jpeg" />
<link rel="stylesheet" href="css/site.css" />
<HeadOutlet />
</head>
<body>
<Routes />
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@@ -0,0 +1,55 @@
@inherits LayoutComponentBase
@inject AuthenticationStateProvider AuthStateProvider
<header class="masthead">
<div class="masthead-meta">
<span>@Krant.Location</span>
<span class="masthead-date">@Krant.DutchDate(DateTime.Now)</span>
<span>@(_isEditor ? "Redactie: " + _editorName : "NUL EURO")</span>
</div>
<p class="masthead-title"><a href="/">Proo<span class="long-s">s</span>tenKrant</a></p>
<p class="masthead-slogan">&bdquo;@Krant.Slogan&ldquo;</p>
<nav class="main-nav">
<NavLink href="/" Match="NavLinkMatch.All">Voorpagina</NavLink>
<NavLink href="/indienen">Krant indienen</NavLink>
<NavLink href="/archief">Archief</NavLink>
<AuthorizeView>
<Authorized>
<NavLink href="/dashboard">Dashboard</NavLink>
<NavLink href="/artikelen">Artikelen</NavLink>
<NavLink href="/seizoenen">Seizoenen</NavLink>
<NavLink href="/uitloggen" class="nav-uitloggen">Uitloggen</NavLink>
</Authorized>
<NotAuthorized>
<NavLink href="/login">Inloggen</NavLink>
</NotAuthorized>
</AuthorizeView>
</nav>
</header>
<main class="sheet">
@Body
</main>
<footer class="footer">
<div class="footer-inner">
<span>&copy; @DateTime.Now.Year @Krant.AppName &mdash; Alle rechten voorbehouden</span>
<span class="footer-eu">
Made and hosted in
<img src="images/european-flag.svg" class="eu-flag" alt="Europese vlag" width="24" height="16" />
Europe
</span>
</div>
</footer>
@code {
private bool _isEditor;
private string? _editorName;
protected override async Task OnInitializedAsync()
{
var state = await AuthStateProvider.GetAuthenticationStateAsync();
_isEditor = KrantAuth.IsEditor(state.User);
_editorName = KrantAuth.EditorName(state.User);
}
}
@@ -0,0 +1,90 @@
@page "/archief"
@inject LatexService Latex
@inject ArticleService Artikelen
@inject SeasonService Seizoenen
<PageTitle>Archief — ProostenKrant</PageTitle>
<h1 class="page-heading">Archief</h1>
<p class="page-intro">Alle gedrukte edities van de ProostenKrant en de gepubliceerde artikelen per seizoen.</p>
<h2 class="section-heading">Gedrukte edities</h2>
@if (_edities.Count > 0)
{
<table class="krant-tabel">
<thead>
<tr>
<th>Editie</th>
<th>Datum</th>
<th>Grootte</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var editie in _edities)
{
<tr>
<td>@editie.DisplayName</td>
<td>@Krant.DutchShortDate(editie.Date)</td>
<td>@(editie.SizeBytes / 1024) kB</td>
<td><a class="btn" href="/edities/@editie.FileName">Download PDF</a></td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="muted">Er zijn nog geen gedrukte edities beschikbaar.</p>
}
<h2 class="section-heading">Gepubliceerde artikelen</h2>
@foreach (var seizoen in _seizoenenMetArtikelen)
{
<section class="season-archive">
<h3 class="season-heading seizoen-@seizoen.Season?.CssKey">
@(seizoen.Season?.Name ?? "Zonder seizoen")
@if (seizoen.Season is not null && seizoen.Season.StartDate <= DateTime.Now && seizoen.Season.EndDate >= DateTime.Now)
{
<span class="tag">huidige editie</span>
}
</h3>
@if (seizoen.Articles.Count == 0)
{
<p class="muted">Nog geen gepubliceerde artikelen.</p>
}
else
{
<ul class="archive-article-list">
@foreach (var artikel in seizoen.Articles)
{
<li>
<a href="/artikelen/@artikel.Id" class="archive-article-link">@artikel.Title</a>
<span class="byline-inline">— @artikel.AuthorName, @Krant.DutchShortDate(artikel.EventDate)</span>
</li>
}
</ul>
}
</section>
}
@code {
private List<Edition> _edities = new();
private List<(Season? Season, List<Article> Articles)> _seizoenenMetArtikelen = new();
protected override async Task OnInitializedAsync()
{
_edities = Latex.ListEditions();
var seasons = await Seizoenen.GetAllAsync();
var published = await Artikelen.GetAllAsync("published");
_seizoenenMetArtikelen = seasons
.Select(s => (s, published.Where(a => a.SeasonId == s.Id).ToList()))
.Select(x => (Season: (Season?)x.Item1, Articles: x.Item2))
.ToList();
var overige = published.Where(a => a.SeasonId is null || seasons.All(s => s.Id != a.SeasonId)).ToList();
if (overige.Count > 0)
{
_seizoenenMetArtikelen.Add((null, overige));
}
}
}
@@ -0,0 +1,143 @@
@page "/artikelen/{Id:int}"
@inject ArticleService Artikelen
@inject UserService Gebruikers
@inject AuthenticationStateProvider AuthStateProvider
@inject IWebHostEnvironment Env
<PageTitle>@(_artikel?.Title ?? "Artikel") — ProostenKrant</PageTitle>
@if (_artikel is null)
{
<section class="card">
<h1 class="headline">Artikel niet gevonden</h1>
<p>Dit artikel bestaat niet (meer). <a href="/artikelen">Naar het overzicht</a>.</p>
</section>
return;
}
<article class="article-page">
<span class="kicker">@(string.IsNullOrEmpty(_artikel.SeasonName) ? "ProostenKrant" : _artikel.SeasonName)</span>
<h1 class="article-headline">@_artikel.Title</h1>
<p class="byline">
Door @_artikel.AuthorName
@if (_artikel.EventDate is not null)
{
<text>&mdash; @Krant.DutchShortDate(_artikel.EventDate)</text>
}
@if (_isEditor)
{
<span class="status status-@_artikel.Status">@(_artikel.Status == "published" ? "gepubliceerd" : "klad")</span>
}
</p>
@if (_deelnemers.Count > 0)
{
<div class="participant-row">
@foreach (var deelnemer in _deelnemers)
{
<span class="participant">
<img class="participant-avatar" src="@ProfileWebPath(deelnemer)" alt="@deelnemer.DisplayName" />
<span>@deelnemer.DisplayName</span>
</span>
}
</div>
}
@if (!string.IsNullOrEmpty(_artikel.WebImage))
{
<figure class="article-figure">
<img src="@_artikel.WebImage" alt="@_artikel.Title" />
</figure>
}
<div class="article-body">
@foreach (var alinea in _artikel.Content.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
<p>@alinea</p>
}
</div>
@if (_isEditor)
{
<div class="editor-actions">
@if (_artikel.Status == "draft")
{
<EditForm FormName="publiceer" Method="post" Model="PubliceerInput" OnValidSubmit="Publiceer" class="inline-form">
<button type="submit" class="btn btn-primary">Publiceren</button>
</EditForm>
}
else
{
<EditForm FormName="depubliceer" Method="post" Model="DepubliceerInput" OnValidSubmit="Depubliceer" class="inline-form">
<button type="submit" class="btn">Terug naar klad</button>
</EditForm>
}
<a class="btn" href="/indienen/bewerken/@_artikel.Id">Bewerken</a>
<EditForm FormName="verwijder" Method="post" Model="VerwijderInput" OnValidSubmit="Verwijder" class="inline-form">
<button type="submit" class="btn btn-danger">Verwijderen</button>
</EditForm>
</div>
}
</article>
@code {
[Parameter]
public int Id { get; set; }
private Article? _artikel;
private List<User> _deelnemers = new();
private bool _isEditor;
[SupplyParameterFromForm(FormName = "publiceer")]
private ActieInput PubliceerInput { get; set; } = new();
[SupplyParameterFromForm(FormName = "depubliceer")]
private ActieInput DepubliceerInput { get; set; } = new();
[SupplyParameterFromForm(FormName = "verwijder")]
private ActieInput VerwijderInput { get; set; } = new();
private sealed class ActieInput { }
protected override async Task OnInitializedAsync()
{
PubliceerInput ??= new();
DepubliceerInput ??= new();
VerwijderInput ??= new();
_artikel = await Artikelen.FindAsync(Id);
var state = await AuthStateProvider.GetAuthenticationStateAsync();
_isEditor = KrantAuth.IsEditor(state.User);
if (_artikel is not null && (_artikel.Status == "published" || _isEditor))
{
_deelnemers = await Gebruikers.GetByIdsAsync(_artikel.ParticipantIds);
}
}
private string ProfileWebPath(User user) => Gebruikers.GetProfileWebPath(user);
private async Task Publiceer()
{
if (_artikel is not null)
{
await Artikelen.SetStatusAsync(_artikel.Id, "published");
Nav.NavigateTo($"/artikelen/{Id}", forceLoad: true);
}
}
private async Task Depubliceer()
{
if (_artikel is not null)
{
await Artikelen.SetStatusAsync(_artikel.Id, "draft");
Nav.NavigateTo($"/artikelen/{Id}", forceLoad: true);
}
}
private async Task Verwijder()
{
if (_artikel is not null)
{
await Artikelen.DeleteAsync(_artikel.Id);
Nav.NavigateTo("/artikelen", forceLoad: true);
}
}
}
@@ -0,0 +1,190 @@
@page "/artikelen/nieuw"
@attribute [Authorize(Policy = "Editor")]
@using System.ComponentModel.DataAnnotations
@inject ArticleService Artikelen
@inject SeasonService Seizoenen
@inject UserService Gebruikers
@inject UploadService Uploads
<PageTitle>Nieuw artikel — ProostenKrant</PageTitle>
<section class="card">
<h1 class="headline">Nieuw artikel</h1>
<p class="muted">Artikel voor de redactie aanmaken en eventueel direct publiceren.</p>
@if (_fout is not null)
{
<p class="alert alert-error">@_fout</p>
}
<EditForm FormName="artikel-nieuw" Method="post" Model="Input" OnValidSubmit="Opslaan"
class="krant-form" enctype="multipart/form-data">
<div class="form-group">
<label for="title">Titel</label>
<InputText @bind-Value="Input.Title" id="title" class="form-control" placeholder="De kop boven het artikel" />
<ValidationMessage For="() => Input.Title" />
</div>
<div class="form-group">
<label for="content">Tekst</label>
<InputTextArea @bind-Value="Input.Content" id="content" rows="10" class="form-control" placeholder="Schrijf hier de inhoud van je artikel…" />
<ValidationMessage For="() => Input.Content" />
</div>
<div class="form-row">
<div class="form-group">
<label for="writer">Schrijver</label>
<InputSelect @bind-Value="Input.WriterId" id="writer" class="form-control">
<option value="0">— Kies schrijver —</option>
@foreach (var gebruiker in _gebruikers)
{
<option value="@gebruiker.Id">@gebruiker.DisplayName</option>
}
</InputSelect>
<ValidationMessage For="() => Input.WriterId" />
</div>
<div class="form-group">
<label for="eventdate">Datum van de gebeurtenis</label>
<InputDate @bind-Value="Input.EventDate" id="eventdate" class="form-control" />
<ValidationMessage For="() => Input.EventDate" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="season">Seizoen</label>
<InputSelect @bind-Value="Input.SeasonId" id="season" class="form-control">
<option value="0">— Kies seizoen —</option>
@foreach (var seizoen in _seizoenen)
{
<option value="@seizoen.Id">@seizoen.Name</option>
}
</InputSelect>
<ValidationMessage For="() => Input.SeasonId" />
</div>
<div class="form-group">
<label for="status">Status</label>
<InputSelect @bind-Value="Input.Status" id="status" class="form-control">
<option value="draft">Klad</option>
<option value="published">Gepubliceerd</option>
</InputSelect>
</div>
</div>
<div class="form-group">
<label>Deelnemers</label>
<div class="checkbox-group">
@foreach (var gebruiker in _gebruikers)
{
<label class="checkbox-label">
<input type="checkbox" name="Participants" value="@gebruiker.Id"
checked="@(_geselecteerd.Contains(gebruiker.Id))" />
<span>@gebruiker.DisplayName</span>
</label>
}
</div>
@if (_participantsFout is not null)
{
<span class="error-message">@_participantsFout</span>
}
</div>
<div class="form-group">
<label for="image">Afbeelding (optioneel)</label>
<input type="file" name="Afbeelding" id="image" class="form-control" accept="image/*" />
<span class="help-text">JPG, PNG, GIF of WEBP, maximaal 25 MB.</span>
@if (_afbeeldingFout is not null)
{
<span class="error-message">@_afbeeldingFout</span>
}
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Artikel opslaan</button>
<a class="btn" href="/artikelen">Annuleren</a>
</div>
</EditForm>
</section>
@code {
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm(FormName = "artikel-nieuw")]
private ArtikelInput Input { get; set; } = new();
private List<User> _gebruikers = new();
private List<Season> _seizoenen = new();
private List<int> _geselecteerd = new();
private string? _fout;
private string? _participantsFout;
private string? _afbeeldingFout;
protected override async Task OnInitializedAsync()
{
Input ??= new();
_gebruikers = await Gebruikers.GetAllAsync();
_seizoenen = await Seizoenen.GetAllAsync();
var huidig = await Seizoenen.GetCurrentAsync();
Input.SeasonId ??= huidig?.Id ?? (_seizoenen.FirstOrDefault()?.Id ?? 0);
Input.EventDate ??= DateTime.Today;
Input.Status ??= "published";
}
private async Task Opslaan()
{
Input ??= new();
var form = HttpContext.Request.Form;
_geselecteerd = form["Participants"].SelectMany(v => (v ?? "").Split(','))
.Select(s => int.TryParse(s, out var id) ? id : 0).Where(id => id > 0).ToList();
if (Input.WriterId <= 0)
{
_fout = "Kies een schrijver.";
return;
}
if (!_geselecteerd.Contains(Input.WriterId))
{
_geselecteerd.Insert(0, Input.WriterId);
}
if (_geselecteerd.Count == 0)
{
_participantsFout = "Selecteer minstens één deelnemer.";
return;
}
var (webPad, fout) = await Uploads.SaveArticleImageAsync(HttpContext.Request.Form.Files["Afbeelding"]);
if (fout is not null)
{
_afbeeldingFout = fout;
return;
}
var artikel = new Article
{
Title = Input.Title,
Content = Input.Content,
AuthorId = Input.WriterId,
Participants = string.Join(",", _geselecteerd),
EventDate = Input.EventDate,
SeasonId = Input.SeasonId,
ArticleImage = webPad,
Status = Input.Status == "published" ? "published" : "draft",
};
await Artikelen.CreateAsync(artikel);
Nav.NavigateTo("/artikelen?melding=" + Uri.EscapeDataString("Artikel succesvol opgeslagen."), forceLoad: true);
}
private sealed class ArtikelInput
{
[Required(ErrorMessage = "Geef het artikel een titel (minstens 5 tekens).")]
[MinLength(5, ErrorMessage = "De titel moet minstens 5 tekens bevatten.")]
[MaxLength(255)]
public string Title { get; set; } = "";
[Required(ErrorMessage = "Schrijf de tekst van het artikel (minstens 10 tekens).")]
[MinLength(10, ErrorMessage = "De tekst moet minstens 10 tekens bevatten.")]
public string Content { get; set; } = "";
public int WriterId { get; set; }
public DateTime? EventDate { get; set; }
public int? SeasonId { get; set; }
public string? Status { get; set; }
}
}
@@ -0,0 +1,63 @@
@page "/artikelen"
@attribute [Authorize(Policy = "Editor")]
@inject ArticleService ArtikelSvc
<PageTitle>Artikelen — ProostenKrant</PageTitle>
<h1 class="page-heading">Artikelen</h1>
<nav class="filter-bar">
<a href="/artikelen" class="btn @(SelectedStatus is null ? "btn-active" : "")">Alle</a>
<a href="/artikelen?status=draft" class="btn @(_status == "draft" ? "btn-active" : "")">Klad</a>
<a href="/artikelen?status=published" class="btn @(_status == "published" ? "btn-active" : "")">Gepubliceerd</a>
<a href="/artikelen/nieuw" class="btn btn-primary">Nieuw artikel</a>
</nav>
@if (_artikelen.Count == 0)
{
<p class="muted">Geen artikelen gevonden.</p>
}
else
{
<table class="krant-tabel">
<thead>
<tr>
<th>Titel</th>
<th>Schrijver</th>
<th>Gebeurtenis</th>
<th>Seizoen</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var artikel in _artikelen)
{
<tr>
<td><a href="/artikelen/@artikel.Id">@artikel.Title</a></td>
<td>@artikel.AuthorName</td>
<td>@Krant.DutchShortDate(artikel.EventDate)</td>
<td>@(artikel.SeasonName ?? "—")</td>
<td><span class="status status-@artikel.Status">@(artikel.Status == "published" ? "gepubliceerd" : "klad")</span></td>
<td><a class="btn" href="/artikelen/@artikel.Id">Openen</a></td>
</tr>
}
</tbody>
</table>
}
@code {
[SupplyParameterFromQuery(Name = "status")]
public string? Status { get; set; }
private string? _status;
private List<Article> _artikelen = new();
protected override async Task OnInitializedAsync()
{
_status = Status;
_artikelen = await ArtikelSvc.GetAllAsync(string.IsNullOrEmpty(Status) ? null : Status);
}
private string? SelectedStatus => _status;
}
@@ -0,0 +1,97 @@
@page "/indienen/bewerken/{Id:int}"
@using System.ComponentModel.DataAnnotations
@inject ArticleService Artikelen
@inject UserService Gebruikers
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>Verifieer schrijver — ProostenKrant</PageTitle>
@if (_artikel is null)
{
<section class="card">
<h1 class="headline">Artikel niet gevonden</h1>
<p><a href="/indienen/bewerken/lijst">Terug naar de lijst</a>.</p>
</section>
return;
}
<section class="card auth-card">
<span class="kicker">Beveiliging</span>
<h1 class="headline">Wie schreef dit artikel?</h1>
<p class="muted">
Om &bdquo;@_artikel.Title&ldquo; te bewerken, bevestig je eerst dat jij de schrijver bent.
</p>
@if (_fout is not null)
{
<p class="alert alert-error">@_fout</p>
}
<EditForm FormName="verifieer-schrijver" Method="post" Model="Input" OnValidSubmit="Verifieer" class="krant-form">
<div class="form-group">
<label for="writer">Schrijver</label>
<InputSelect @bind-Value="Input.WriterId" id="writer" class="form-control">
<option value="0">— Kies schrijver —</option>
@foreach (var gebruiker in _gebruikers)
{
<option value="@gebruiker.Id">@gebruiker.DisplayName</option>
}
</InputSelect>
<ValidationMessage For="() => Input.WriterId" />
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Doorgaan naar bewerken</button>
<a class="btn" href="/indienen/bewerken/lijst">Annuleren</a>
</div>
</EditForm>
</section>
@code {
[Parameter]
public int Id { get; set; }
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm(FormName = "verifieer-schrijver")]
private VerificatieInput Input { get; set; } = new();
private Article? _artikel;
private List<User> _gebruikers = new();
private string? _fout;
private sealed class VerificatieInput
{
[Required(ErrorMessage = "Kies de schrijver.")]
public int WriterId { get; set; }
}
protected override async Task OnInitializedAsync()
{
Input ??= new();
var state = await AuthStateProvider.GetAuthenticationStateAsync();
if (!KrantAuth.HasValidPassphrase(state.User) && !KrantAuth.IsEditor(state.User))
{
Nav.NavigateTo("/indienen/bewerken", forceLoad: true);
return;
}
_artikel = await Artikelen.FindAsync(Id);
_gebruikers = await Gebruikers.GetAllAsync();
}
private async Task Verifieer()
{
if (_artikel is null)
{
return;
}
if (Input.WriterId != _artikel.AuthorId)
{
_fout = "Alleen de schrijver van het artikel kan het bewerken.";
return;
}
await HttpContext.SignInAsync(KrantAuth.Scheme,
KrantAuth.PassphrasePrincipal(editArticleId: _artikel.Id));
Nav.NavigateTo("/indienen/formulier", forceLoad: true);
}
}
@@ -0,0 +1,47 @@
@page "/indienen/bewerken/lijst"
@inject ArticleService Artikelen
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>Kladartikelen — ProostenKrant</PageTitle>
<h1 class="page-heading">Kladartikelen bewerken</h1>
<p class="page-intro">Kies het artikel dat je wilt aanpassen. Alleen de schrijver van het artikel kan het bewerken.</p>
@if (_klad.Count == 0)
{
<p class="muted">Er zijn momenteel geen kladartikelen.</p>
}
else
{
<table class="krant-tabel">
<thead>
<tr><th>Titel</th><th>Schrijver</th><th>Gebeurtenis</th><th></th></tr>
</thead>
<tbody>
@foreach (var artikel in _klad)
{
<tr>
<td>@artikel.Title</td>
<td>@artikel.AuthorName</td>
<td>@Krant.DutchShortDate(artikel.EventDate)</td>
<td><a class="btn" href="/indienen/bewerken/@artikel.Id">Bewerken</a></td>
</tr>
}
</tbody>
</table>
}
@code {
private List<Article> _klad = new();
protected override async Task OnInitializedAsync()
{
var state = await AuthStateProvider.GetAuthenticationStateAsync();
if (!KrantAuth.HasValidPassphrase(state.User) && !KrantAuth.IsEditor(state.User))
{
Nav.NavigateTo("/indienen/bewerken", forceLoad: true);
return;
}
_klad = await Artikelen.GetAllAsync("draft");
}
}
@@ -0,0 +1,60 @@
@page "/indienen/bewerken"
@using System.ComponentModel.DataAnnotations
<PageTitle>Artikel bewerken — ProostenKrant</PageTitle>
<section class="card auth-card">
<span class="kicker">Beveiliging</span>
<h1 class="headline">Artikel bewerken</h1>
<p class="muted">Voer de paszin in om de lijst met kladartikelen te openen.</p>
@if (_fout is not null)
{
<p class="alert alert-error">@_fout</p>
}
<EditForm FormName="paszin-bewerken" Method="post" Model="Input" OnValidSubmit="Verifieer" class="krant-form">
<div class="form-group">
<label for="passphrase">Paszin</label>
<InputText type="password" @bind-Value="Input.Paszin" id="passphrase" class="form-control" autocomplete="off" />
<ValidationMessage For="() => Input.Paszin" />
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Verder</button>
<a class="btn" href="/">Annuleren</a>
</div>
</EditForm>
</section>
@code {
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm(FormName = "paszin-bewerken")]
private PaszinInput Input { get; set; } = new();
private string? _fout;
protected override Task OnInitializedAsync()
{
Input ??= new();
return Task.CompletedTask;
}
private sealed class PaszinInput
{
[Required(ErrorMessage = "Voer de paszin in.")]
public string Paszin { get; set; } = "";
}
private async Task Verifieer()
{
if (Input.Paszin != Krant.UniversalPassword)
{
_fout = "Onjuiste paszin. Probeer het opnieuw.";
return;
}
await HttpContext.SignInAsync(KrantAuth.Scheme, KrantAuth.PassphrasePrincipal());
Nav.NavigateTo("/indienen/bewerken/lijst", forceLoad: true);
}
}
@@ -0,0 +1,166 @@
@page "/dashboard"
@attribute [Authorize(Policy = "Editor")]
@inject AuthenticationStateProvider AuthStateProvider
@inject ArticleService Artikelen
@inject SeasonService Seizoenen
@inject LatexService Latex
@inject UserService Gebruikers
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Af
<PageTitle>Dashboard — ProostenKrant</PageTitle>
<h1 class="page-heading">Dashboard redactie</h1>
<p class="page-intro">Welkom, @_editorNaam. Overzicht van de krant en sneltoetsen voor de redactie.</p>
@if (_melding is not null)
{
<p class="alert alert-success">@_melding</p>
}
@if (_fout is not null)
{
<div class="alert alert-error">
<p>@_fout</p>
</div>
}
<div class="stats-grid">
<div class="stat-card">
<span class="stat-number">@_drafts</span>
<span class="stat-label">Kladartikelen</span>
<a href="/artikelen?status=draft" class="stat-link">Bekijken &rarr;</a>
</div>
<div class="stat-card">
<span class="stat-number">@_gepubliceerd</span>
<span class="stat-label">Gepubliceerd</span>
<a href="/artikelen?status=published" class="stat-link">Bekijken &rarr;</a>
</div>
<div class="stat-card">
<span class="stat-number">@_gebruikers.Count</span>
<span class="stat-label">Familieleden</span>
</div>
<div class="stat-card">
<span class="stat-number">@_edities.Count</span>
<span class="stat-label">Gedrukte edities</span>
<a href="/archief" class="stat-link">Archief &rarr;</a>
</div>
</div>
<div class="dashboard-columns">
<section class="dashboard-main">
<h2 class="section-heading">Huidige editie</h2>
@if (_huidigSeizoen is not null)
{
<div class="season-card seizoen-@_huidigSeizoen.CssKey">
<h3>@_huidigSeizoen.Name @DateTime.Now.Year</h3>
<p class="muted">
Van @Krant.DutchShortDate(_huidigSeizoen.StartDate) t/m @Krant.DutchShortDate(_huidigSeizoen.EndDate).
@(_seizoenDrafts.Count) klad, @(_seizoenPublished.Count) gepubliceerd.
</p>
<div class="form-actions">
<form method="post" action="/api/genereer-pdf" class="inline-form">
<input type="hidden" name="SeizoenId" value="@_huidigSeizoen.Id" />
<input type="hidden" name="__RequestVerificationToken" value="@_afToken" />
<button type="submit" class="btn btn-primary">Druk de @_huidigSeizoen.Name-editie</button>
</form>
</div>
</div>
}
else
{
<p class="muted">Er is geen lopend seizoen gedefinieerd.</p>
}
<h2 class="section-heading">Laatste artikelen</h2>
<table class="krant-tabel">
<thead>
<tr>
<th>Titel</th>
<th>Schrijver</th>
<th>Seizoen</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var artikel in _recent.Take(8))
{
<tr>
<td><a href="/artikelen/@artikel.Id">@artikel.Title</a></td>
<td>@artikel.AuthorName</td>
<td>@(artikel.SeasonName ?? "—")</td>
<td>
<span class="status status-@artikel.Status">@(artikel.Status == "published" ? "gepubliceerd" : "klad")</span>
</td>
</tr>
}
</tbody>
</table>
</section>
<aside class="dashboard-side">
<h2 class="section-heading">Sneltoetsen</h2>
<ul class="quick-links">
<li><a class="btn" href="/artikelen/nieuw">Nieuw artikel</a></li>
<li><a class="btn" href="/seizoenen">Seizoenen &amp; drukwerk</a></li>
<li><a class="btn" href="/artikelen?status=draft">Kladartikelen publiceren</a></li>
</ul>
<h2 class="section-heading">Laatste edities</h2>
<ul class="edition-list">
@foreach (var editie in _edities.Take(5))
{
<li>
<a href="/edities/@editie.FileName" class="edition-link">
<span class="edition-name">@editie.DisplayName</span>
<span class="edition-size">@(editie.SizeBytes / 1024) kB</span>
</a>
</li>
}
</ul>
</aside>
</div>
@code {
private string? _editorNaam;
private Season? _huidigSeizoen;
private List<Article> _recent = new();
private List<Article> _seizoenDrafts = new();
private List<Article> _seizoenPublished = new();
private List<User> _gebruikers = new();
private List<Edition> _edities = new();
private int _drafts;
private int _gepubliceerd;
private string? _melding;
private string? _fout;
private string? _afToken;
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
_afToken = Af.GetAndStoreTokens(HttpContext).RequestToken;
var state = await AuthStateProvider.GetAuthenticationStateAsync();
_editorNaam = KrantAuth.EditorName(state.User) ?? "redacteur";
_huidigSeizoen = await Seizoenen.GetCurrentAsync();
_recent = await Artikelen.GetAllAsync();
_gebruikers = await Gebruikers.GetAllAsync();
_edities = Latex.ListEditions();
_drafts = _recent.Count(a => a.Status == "draft");
_gepubliceerd = _recent.Count(a => a.Status == "published");
if (_huidigSeizoen is not null)
{
_seizoenDrafts = _recent.Where(a => a.SeasonId == _huidigSeizoen.Id && a.Status == "draft").ToList();
_seizoenPublished = await Artikelen.GetBySeasonAsync(_huidigSeizoen.Id);
}
LeesMelding();
}
private void LeesMelding()
{
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(new Uri(Nav.Uri).Query);
query.TryGetValue("melding", out var melding);
query.TryGetValue("fout", out var fout);
_melding = melding;
_fout = fout;
}
}
@@ -0,0 +1,12 @@
@page "/fout"
<PageTitle>Fout — ProostenKrant</PageTitle>
<section class="card">
<span class="kicker">Persstop</span>
<h1 class="headline">Er ging iets mis</h1>
<p>De redactie kon deze pagina niet drukken. Probeer het opnieuw of ga terug naar de voorpagina.</p>
<div class="form-actions">
<a class="btn btn-primary" href="/">Naar de voorpagina</a>
</div>
</section>
@@ -0,0 +1,169 @@
@page "/"
@inject ArticleService Artikelen
@inject SeasonService Seizoenen
@inject LatexService Latex
<PageTitle>ProostenKrant — Voorpagina</PageTitle>
<section class="edition-banner">
<div class="edition-banner-left">
@if (_huidigSeizoen is not null)
{
<span class="kicker seizoen-@_huidigSeizoen.CssKey">Editie @_huidigSeizoen.Name @DateTime.Now.Year</span>
<h2 class="edition-title">In wording</h2>
<p class="edition-sub">
@if (_drafts.Count == 0)
{
<text>Er liggen nog geen artikelen klaar voor de komende editie.</text>
}
else
{
<text>Er liggen <strong>@(_drafts.Count == 1 ? "één artikel" : $"{_drafts.Count} artikelen")</strong> klaar voor de komende editie.</text>
}
Sluit @Krant.DutchShortDate(_huidigSeizoen.EndDate).
</p>
}
else
{
<h2 class="edition-title">ProostenKrant</h2>
<p class="edition-sub">Een krant van en voor Proosten.</p>
}
</div>
<div class="edition-banner-right">
<a class="btn btn-primary" href="/indienen">Lever zelf een artikel in</a>
</div>
</section>
@if (_lead is not null)
{
<article class="lead-article">
<span class="kicker">Voorpagina nieuws</span>
<h2 class="lead-headline">@_lead.Title</h2>
<p class="byline">
Door @_lead.AuthorName
@if (_lead.EventDate is not null)
{
<text>&mdash; @Krant.DutchShortDate(_lead.EventDate)</text>
}
</p>
@if (!string.IsNullOrEmpty(_lead.WebImage))
{
<figure class="lead-figure">
<img src="@_lead.WebImage" alt="@_lead.Title" loading="lazy" />
</figure>
}
<p class="lead-text">@Teaser(_lead, 400)</p>
</article>
}
@if (_teasers.Count > 0)
{
<h3 class="section-heading">Ook in deze editie</h3>
<div class="teaser-columns">
@foreach (var artikel in _teasers)
{
<article class="teaser">
<h4 class="teaser-headline">@artikel.Title</h4>
<p class="byline">
Door @artikel.AuthorName
@if (artikel.SeasonName is not null)
{
<text>&mdash; @artikel.SeasonName</text>
}
</p>
<p class="teaser-text">@Teaser(artikel, 220)</p>
</article>
}
</div>
}
else if (_lead is null && _gepubliceerd.Count == 0)
{
<section class="card empty-state">
<h2 class="headline">De eerste editie wacht op jou</h2>
<p>Er zijn nog geen artikelen ingediend. De ProostenKrant wordt pas gedrukt als de familie schrijft.</p>
<p><a class="btn btn-primary" href="/indienen">Schrijf het eerste artikel</a></p>
</section>
}
@if (_gepubliceerd.Count > 0)
{
<h3 class="section-heading">Verschenen in de @_huidigSeizoen?.Name-editie</h3>
<div class="teaser-columns">
@foreach (var artikel in _gepubliceerd.Take(6))
{
<article class="teaser">
<h4 class="teaser-headline"><a href="/artikelen/@artikel.Id">@artikel.Title</a></h4>
<p class="byline">
Door @artikel.AuthorName
@if (artikel.EventDate is not null)
{
<text>&mdash; @Krant.DutchShortDate(artikel.EventDate)</text>
}
</p>
<p class="teaser-text">@Teaser(artikel, 200)</p>
<p class="teaser-more"><a href="/artikelen/@artikel.Id">Lees verder &rarr;</a></p>
</article>
}
</div>
}
<aside class="editions-strip">
<h3 class="section-heading">Verschenen edities</h3>
@if (_edities.Count > 0)
{
<ul class="edition-list">
@foreach (var editie in _edities.Take(4))
{
<li>
<a href="/edities/@editie.FileName" class="edition-link">
<span class="edition-name">@editie.DisplayName</span>
@if (editie.Date is not null)
{
<span class="edition-date">@Krant.DutchShortDate(editie.Date)</span>
}
<span class="edition-size">@Kb(editie.SizeBytes) kB &mdash; PDF</span>
</a>
</li>
}
</ul>
<p class="edition-more"><a href="/archief">Bekijk het volledige archief &rarr;</a></p>
}
else
{
<p class="muted">Er zijn nog geen gedrukte edities. Zodra de redactie de krant genereert, verschijnen hier de PDF&rsquo;s.</p>
}
</aside>
@code {
private Season? _huidigSeizoen;
private List<Article> _drafts = new();
private List<Article> _teasers = new();
private List<Article> _gepubliceerd = new();
private Article? _lead;
private List<Edition> _edities = new();
protected override async Task OnInitializedAsync()
{
_huidigSeizoen = await Seizoenen.GetCurrentAsync();
_drafts = await Artikelen.GetAllAsync("draft");
_lead = _drafts.FirstOrDefault();
_teasers = _drafts.Skip(1).ToList();
_gepubliceerd = _huidigSeizoen is not null
? await Artikelen.GetBySeasonAsync(_huidigSeizoen.Id)
: await Artikelen.GetAllAsync("published");
_edities = Latex.ListEditions();
}
private static string Teaser(Article a, int length)
{
var text = a.Content.Replace("\r", " ").Replace("\n", " ").Trim();
if (text.Length <= length)
{
return text;
}
var cut = text.LastIndexOf(' ', length);
return text[..(cut > 0 ? cut : length)] + "…";
}
private static string Kb(long bytes) => Math.Max(1, bytes / 1024).ToString("N0");
}
@@ -0,0 +1,64 @@
@page "/indienen"
@using System.ComponentModel.DataAnnotations
<PageTitle>Artikel indienen — ProostenKrant</PageTitle>
<section class="card auth-card">
<span class="kicker">Medewerking gevraagd</span>
<h1 class="headline">Lever een artikel in</h1>
<p class="muted">
De ProostenKrant bestaat door bijdragen van de familie. Voer de paszin van de familie in
om een artikel te schrijven (of een eerder artikel te bewerken).
</p>
@if (_fout is not null)
{
<p class="alert alert-error">@_fout</p>
}
<EditForm FormName="paszin" Method="post" Model="Input" OnValidSubmit="Verifieer" class="krant-form">
<div class="form-group">
<label for="passphrase">Paszin</label>
<InputText type="password" @bind-Value="Input.Paszin" id="passphrase" class="form-control"
placeholder="De geheime paszin van de familie" autocomplete="off" />
<ValidationMessage For="() => Input.Paszin" />
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Verder naar het formulier</button>
</div>
</EditForm>
</section>
@code {
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm(FormName = "paszin")]
private PaszinInput Input { get; set; } = new();
private string? _fout;
protected override Task OnInitializedAsync()
{
Input ??= new();
return Task.CompletedTask;
}
private sealed class PaszinInput
{
[Required(ErrorMessage = "Voer de paszin in.")]
public string Paszin { get; set; } = "";
}
private async Task Verifieer()
{
if (Input.Paszin != Krant.UniversalPassword)
{
_fout = "Onjuiste paszin. Probeer het opnieuw.";
return;
}
var principal = KrantAuth.PassphrasePrincipal();
await HttpContext.SignInAsync(KrantAuth.Scheme, principal);
Nav.NavigateTo("/indienen/formulier", forceLoad: true);
}
}
@@ -0,0 +1,49 @@
@page "/indienen/bevestiging"
@inject ArticleService Artikelen
<PageTitle>Bevestiging — ProostenKrant</PageTitle>
<section class="card">
<span class="kicker">Bedankt!</span>
<h1 class="headline">@( _artikel is not null ? "Artikel ingediend" : "Bevestiging")</h1>
@if (_artikel is null)
{
<p>Er is geen recent ingediend artikel gevonden.</p>
}
else
{
<p>Het artikel <strong>@_artikel.Title</strong> is veilig in de brievenbus van de redactie gevallen.</p>
<dl class="bevestiging-lijst">
<dt>Schrijver</dt>
<dd>@_artikel.AuthorName</dd>
<dt>Gebeurtenis</dt>
<dd>@Krant.DutchShortDate(_artikel.EventDate)</dd>
@if (_artikel.SeasonName is not null)
{
<dt>Editie</dt>
<dd>@_artikel.SeasonName</dd>
}
</dl>
<p class="muted">De redactie bekijkt alle inzendingen en publiceert ze in de komende editie.</p>
<div class="form-actions">
<a class="btn btn-primary" href="/indienen">Nog een artikel indienen</a>
<a class="btn" href="/">Naar de voorpagina</a>
</div>
}
</section>
@code {
[SupplyParameterFromQuery(Name = "artikel")]
public int? ArtikelId { get; set; }
private Article? _artikel;
protected override async Task OnInitializedAsync()
{
if (ArtikelId is int id)
{
_artikel = await Artikelen.FindAsync(id);
}
}
}
@@ -0,0 +1,231 @@
@page "/indienen/formulier"
@using System.ComponentModel.DataAnnotations
@inject ArticleService Artikelen
@inject SeasonService Seizoenen
@inject UserService Gebruikers
@inject UploadService Uploads
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>@(_bewerkArtikel is not null ? "Artikel bewerken" : "Artikel indienen") — ProostenKrant</PageTitle>
<section class="card">
<span class="kicker">@(_bewerkArtikel is not null ? "Bewerken" : "Medewerking gevraagd")</span>
<h1 class="headline">@( _bewerkArtikel is not null ? "Artikel bewerken" : "Nieuw artikel indienen")</h1>
@if (_fout is not null)
{
<p class="alert alert-error">@_fout</p>
}
<EditForm FormName="indienen" Method="post" Model="Input" OnValidSubmit="Opslaan"
class="krant-form" enctype="multipart/form-data">
<div class="form-group">
<label for="title">Titel</label>
<InputText @bind-Value="Input.Title" id="title" class="form-control" placeholder="De kop boven je artikel" />
<ValidationMessage For="() => Input.Title" />
</div>
<div class="form-group">
<label for="content">Tekst</label>
<InputTextArea @bind-Value="Input.Content" id="content" rows="10" class="form-control"
placeholder="Schrijf hier de inhoud van je artikel…" />
<ValidationMessage For="() => Input.Content" />
</div>
<div class="form-row">
<div class="form-group">
<label for="writer">Schrijver</label>
<InputSelect @bind-Value="Input.WriterId" id="writer" class="form-control">
<option value="0">— Kies schrijver —</option>
@foreach (var gebruiker in _gebruikers)
{
<option value="@gebruiker.Id">@gebruiker.DisplayName</option>
}
</InputSelect>
<ValidationMessage For="() => Input.WriterId" />
</div>
<div class="form-group">
<label for="eventdate">Datum van de gebeurtenis</label>
<InputDate @bind-Value="Input.EventDate" id="eventdate" class="form-control" />
<ValidationMessage For="() => Input.EventDate" />
</div>
</div>
<div class="form-group">
<label>Deelnemers</label>
<p class="help-text">Selecteer alle personages die bij dit verhaal betrokken zijn.</p>
<div class="checkbox-group">
@foreach (var gebruiker in _gebruikers)
{
<label class="checkbox-label">
<input type="checkbox" name="Participants" value="@gebruiker.Id"
checked="@(_geselecteerd.Contains(gebruiker.Id))" />
<span>@gebruiker.DisplayName</span>
</label>
}
</div>
@if (_participantsFout is not null)
{
<span class="error-message">@_participantsFout</span>
}
</div>
<div class="form-group">
<label for="image">Afbeelding</label>
<input type="file" name="Afbeelding" id="image" class="form-control" accept="image/*" />
<span class="help-text">
JPG, PNG, GIF of WEBP, maximaal 25 MB.
@if (_bewerkArtikel is not null && !string.IsNullOrEmpty(_bewerkArtikel.WebImage))
{
<text>Laat leeg om de huidige afbeelding te houden.</text>
}
</span>
@if (_afbeeldingFout is not null)
{
<span class="error-message">@_afbeeldingFout</span>
}
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
@(_bewerkArtikel is not null ? "Wijzigingen opslaan" : "Artikel indienen")
</button>
<a class="btn" href="/">Annuleren</a>
</div>
</EditForm>
</section>
@code {
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm(FormName = "indienen")]
private IndienInput Input { get; set; } = new();
private List<User> _gebruikers = new();
private List<int> _geselecteerd = new();
private Article? _bewerkArtikel;
private string? _fout;
private string? _participantsFout;
private string? _afbeeldingFout;
protected override async Task OnInitializedAsync()
{
Input ??= new();
var state = await AuthStateProvider.GetAuthenticationStateAsync();
var isEditor = KrantAuth.IsEditor(state.User);
var heeftPaszin = KrantAuth.HasValidPassphrase(state.User);
var bewerkId = KrantAuth.EditArticleId(state.User);
if (!isEditor && !heeftPaszin)
{
Nav.NavigateTo("/indienen", forceLoad: true);
return;
}
_gebruikers = await Gebruikers.GetAllAsync();
Input.EventDate ??= DateTime.Today;
if (bewerkId is int id)
{
_bewerkArtikel = await Artikelen.FindAsync(id);
if (_bewerkArtikel is null)
{
Nav.NavigateTo("/indienen/bewerken/lijst", forceLoad: true);
return;
}
// Alleen bij een GET-request het formulier vullen; bij een POST
// zijn de waarden al door de formulierbinding gezet.
if (string.Equals(HttpContext.Request.Method, "GET", StringComparison.OrdinalIgnoreCase))
{
Input.Title = _bewerkArtikel.Title;
Input.Content = _bewerkArtikel.Content;
Input.WriterId = _bewerkArtikel.AuthorId;
Input.EventDate = _bewerkArtikel.EventDate;
_geselecteerd = _bewerkArtikel.ParticipantIds;
}
}
}
private async Task Opslaan()
{
Input ??= new();
var form = HttpContext.Request.Form;
_geselecteerd = form["Participants"].SelectMany(v => (v ?? "").Split(','))
.Select(s => int.TryParse(s, out var id) ? id : 0).Where(id => id > 0).ToList();
if (Input.WriterId <= 0)
{
_fout = "Kies een schrijver.";
return;
}
if (!_geselecteerd.Contains(Input.WriterId))
{
_geselecteerd.Insert(0, Input.WriterId);
}
if (_geselecteerd.Count == 0)
{
_participantsFout = "Selecteer minstens één deelnemer.";
return;
}
string? webPad = _bewerkArtikel?.ArticleImage;
var bestand = HttpContext.Request.Form.Files["Afbeelding"];
if (bestand is { Length: > 0 } || _bewerkArtikel is null)
{
var (nieuwPad, fout) = await Uploads.SaveArticleImageAsync(bestand);
if (fout is not null)
{
_afbeeldingFout = fout;
return;
}
webPad = nieuwPad ?? webPad;
}
var season = await Seizoenen.GetByDateAsync(Input.EventDate ?? DateTime.Today);
if (_bewerkArtikel is not null)
{
_bewerkArtikel.Title = Input.Title;
_bewerkArtikel.Content = Input.Content;
_bewerkArtikel.AuthorId = Input.WriterId;
_bewerkArtikel.Participants = string.Join(",", _geselecteerd);
_bewerkArtikel.EventDate = Input.EventDate;
_bewerkArtikel.SeasonId = season?.Id ?? _bewerkArtikel.SeasonId;
_bewerkArtikel.ArticleImage = webPad;
await Artikelen.UpdateAsync(_bewerkArtikel);
var artikelId = _bewerkArtikel.Id;
await HttpContext.SignOutAsync(KrantAuth.Scheme);
Nav.NavigateTo($"/indienen/bevestiging?artikel={artikelId}", forceLoad: true);
}
else
{
var artikel = new Article
{
Title = Input.Title,
Content = Input.Content,
AuthorId = Input.WriterId,
Participants = string.Join(",", _geselecteerd),
EventDate = Input.EventDate,
SeasonId = season?.Id,
ArticleImage = webPad,
Status = "draft",
};
var nieuwId = await Artikelen.CreateAsync(artikel);
await HttpContext.SignOutAsync(KrantAuth.Scheme);
Nav.NavigateTo($"/indienen/bevestiging?artikel={nieuwId}", forceLoad: true);
}
}
private sealed class IndienInput
{
[Required(ErrorMessage = "Geef het artikel een titel (minstens 5 tekens).")]
[MinLength(5, ErrorMessage = "De titel moet minstens 5 tekens bevatten.")]
[MaxLength(255)]
public string Title { get; set; } = "";
[Required(ErrorMessage = "Schrijf de tekst van het artikel (minstens 10 tekens).")]
[MinLength(10, ErrorMessage = "De tekst moet minstens 10 tekens bevatten.")]
public string Content { get; set; } = "";
public int WriterId { get; set; }
public DateTime? EventDate { get; set; }
}
}
@@ -0,0 +1,78 @@
@page "/login"
@using System.ComponentModel.DataAnnotations
@inject UserService Gebruikers
<PageTitle>Inloggen — ProostenKrant</PageTitle>
<section class="card auth-card">
<h1 class="headline">Inloggen redactie</h1>
<p class="muted">Kies je naam en voer het familiewachtwoord in.</p>
@if (_fout is not null)
{
<p class="alert alert-error">@_fout</p>
}
<EditForm FormName="login" Method="post" Model="Input" OnValidSubmit="LoginUser" class="krant-form">
<div class="form-group">
<label for="username">Familielid</label>
<InputSelect @bind-Value="Input.Username" id="username" class="form-control">
<option value="">— Kies je naam —</option>
@foreach (var gebruiker in _gebruikers)
{
<option value="@gebruiker.Username">@gebruiker.DisplayName</option>
}
</InputSelect>
<ValidationMessage For="() => Input.Username" />
</div>
<div class="form-group">
<label for="password">Wachtwoord</label>
<InputText type="password" @bind-Value="Input.Password" id="password" class="form-control" autocomplete="current-password" />
<ValidationMessage For="() => Input.Password" />
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Inloggen</button>
</div>
</EditForm>
</section>
@code {
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm(FormName = "login")]
private LoginInput Input { get; set; } = new();
private List<User> _gebruikers = new();
private string? _fout;
protected override async Task OnInitializedAsync()
{
Input ??= new();
_gebruikers = await Gebruikers.GetAllAsync();
}
private async Task LoginUser()
{
var gebruiker = _gebruikers.FirstOrDefault(g =>
string.Equals(g.Username, Input.Username, StringComparison.OrdinalIgnoreCase));
if (gebruiker is null || Input.Password != Krant.UniversalPassword)
{
_fout = "Ongeldige gebruikersnaam of wachtwoord.";
return;
}
var principal = KrantAuth.EditorPrincipal(gebruiker);
await HttpContext.SignInAsync(KrantAuth.Scheme, principal);
Nav.NavigateTo("/dashboard", forceLoad: true);
}
private sealed class LoginInput
{
[Required(ErrorMessage = "Kies je naam.")]
public string Username { get; set; } = "";
[Required(ErrorMessage = "Voer het wachtwoord in.")]
public string Password { get; set; } = "";
}
}
@@ -0,0 +1,115 @@
@page "/seizoenen/{Id:int}"
@attribute [Authorize(Policy = "Editor")]
@inject SeasonService Seizoenen
@inject ArticleService Artikelen
@inject LatexService Latex
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Af
<PageTitle>@(_seizoen?.Name ?? "Seizoen") — ProostenKrant</PageTitle>
@if (_seizoen is null)
{
<section class="card">
<h1 class="headline">Seizoen niet gevonden</h1>
<p><a href="/seizoenen">Naar het seizoensoverzicht</a>.</p>
</section>
return;
}
<section class="card">
<span class="kicker seizoen-@_seizoen.CssKey">Editie @_seizoen.Name @_seizoen.StartDate.Year</span>
<h1 class="headline">@_seizoen.Name @(_seizoen.StartDate.Year)</h1>
<p class="muted">
@Krant.DutchShortDate(_seizoen.StartDate) &mdash; @Krant.DutchShortDate(_seizoen.EndDate).
@_artikelen.Count gepubliceerde artikelen in deze editie.
</p>
<div class="form-actions">
<form method="post" action="/api/genereer-pdf" class="inline-form">
<input type="hidden" name="SeizoenId" value="@Id" />
<input type="hidden" name="__RequestVerificationToken" value="@_afToken" />
<button type="submit" class="btn btn-primary">Druk de @_seizoen.Name-editie</button>
</form>
<a class="btn" href="/seizoenen">Terug</a>
</div>
</section>
@if (_fout is not null)
{
<p class="alert alert-error">@_fout</p>
}
@if (_melding is not null)
{
<p class="alert alert-success">
@_melding
@if (_nieuwPdf is not null)
{
<text>&nbsp;<a href="/edities/@_nieuwPdf">Download &rarr;</a></text>
}
</p>
}
<h2 class="section-heading">Artikelen in deze editie</h2>
@if (_artikelen.Count == 0)
{
<p class="muted">Er zijn nog geen gepubliceerde artikelen in deze editie.</p>
}
else
{
<table class="krant-tabel">
<thead>
<tr><th>Titel</th><th>Schrijver</th><th>Gebeurtenis</th><th></th></tr>
</thead>
<tbody>
@foreach (var artikel in _artikelen)
{
<tr>
<td><a href="/artikelen/@artikel.Id">@artikel.Title</a></td>
<td>@artikel.AuthorName</td>
<td>@Krant.DutchShortDate(artikel.EventDate)</td>
<td><a class="btn" href="/artikelen/@artikel.Id">Openen</a></td>
</tr>
}
</tbody>
</table>
}
@code {
[Parameter]
public int Id { get; set; }
[SupplyParameterFromForm(FormName = "genereer-detail")]
private ActieInput Input { get; set; } = new();
private sealed class ActieInput { }
private Season? _seizoen;
private List<Article> _artikelen = new();
private string? _melding;
private string? _fout;
private string? _nieuwPdf;
private string? _afToken;
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
_afToken = Af.GetAndStoreTokens(HttpContext).RequestToken;
_seizoen = await Seizoenen.FindAsync(Id);
if (_seizoen is not null)
{
_artikelen = await Artikelen.GetBySeasonAsync(Id);
}
LeesMelding();
}
private void LeesMelding()
{
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(new Uri(Nav.Uri).Query);
query.TryGetValue("melding", out var melding);
query.TryGetValue("fout", out var fout);
_melding = melding;
_fout = fout;
query.TryGetValue("pdf", out var pdf);
_nieuwPdf = pdf;
}
}
@@ -0,0 +1,129 @@
@page "/seizoenen"
@attribute [Authorize(Policy = "Editor")]
@inject SeasonService SeizoenSvc
@inject ArticleService Artikelen
@inject LatexService Latex
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Af
<PageTitle>Seizoenen — ProostenKrant</PageTitle>
<h1 class="page-heading">Seizoenen &amp; drukwerk</h1>
<p class="page-intro">Per seizoen wordt een editie van de krant gedrukt. Publiceer artikelen en druk daarna de editie.</p>
@if (_melding is not null)
{
<p class="alert alert-success">
@_melding
@if (_nieuwPdf is not null)
{
<text>&nbsp;<a href="/edities/@_nieuwPdf">Download de krant &rarr;</a></text>
}
</p>
}
@if (_fout is not null)
{
<div class="alert alert-error">
<p>@_fout</p>
</div>
}
<div class="season-grid">
@foreach (var seizoen in _seizoenInfo)
{
<section class="season-card seizoen-@seizoen.Season.CssKey">
<div class="season-card-head">
<h2>@seizoen.Season.Name @seizoen.Season.StartDate.Year</h2>
@if (seizoen.IsHuidig)
{
<span class="tag">lopende editie</span>
}
</div>
<p class="muted">
@Krant.DutchShortDate(seizoen.Season.StartDate) &mdash; @Krant.DutchShortDate(seizoen.Season.EndDate)
</p>
<ul class="season-stats">
<li><strong>@seizoen.Gepubliceerd</strong> gepubliceerd</li>
<li><strong>@seizoen.Klad</strong> klad</li>
</ul>
<div class="form-actions">
<form method="post" action="/api/genereer-pdf" class="inline-form">
<input type="hidden" name="SeizoenId" value="@seizoen.Season.Id" />
<input type="hidden" name="__RequestVerificationToken" value="@_afToken" />
<button type="submit" class="btn btn-primary">Druk deze editie</button>
</form>
<a class="btn" href="/seizoenen/@seizoen.Season.Id">Artikelen bekijken</a>
</div>
</section>
}
</div>
<h2 class="section-heading">Gedrukte edities</h2>
@if (_edities.Count > 0)
{
<table class="krant-tabel">
<thead>
<tr><th>Editie</th><th>Datum</th><th>Grootte</th><th></th></tr>
</thead>
<tbody>
@foreach (var editie in _edities)
{
<tr>
<td>@editie.DisplayName</td>
<td>@Krant.DutchShortDate(editie.Date)</td>
<td>@(editie.SizeBytes / 1024) kB</td>
<td><a class="btn" href="/edities/@editie.FileName">Download PDF</a></td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="muted">Er werd nog geen editie gedrukt.</p>
}
@code {
[SupplyParameterFromForm(FormName = "genereer-seizoen")]
private GenereerInput Input { get; set; } = new();
private sealed class GenereerInput
{
public int SeasonId { get; set; }
}
private List<(Season Season, int Gepubliceerd, int Klad, bool IsHuidig)> _seizoenInfo = new();
private List<Edition> _edities = new();
private string? _melding;
private string? _fout;
private string? _nieuwPdf;
private string? _afToken;
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
_afToken = Af.GetAndStoreTokens(HttpContext).RequestToken;
var seasons = await SeizoenSvc.GetAllAsync();
var alle = await Artikelen.GetAllAsync();
_seizoenInfo = seasons.Select(s =>
{
var vanS = alle.Where(a => a.SeasonId == s.Id).ToList();
return (s, vanS.Count(a => a.Status == "published"), vanS.Count(a => a.Status == "draft"),
s.StartDate <= DateTime.Now && s.EndDate >= DateTime.Now);
}).ToList();
_edities = Latex.ListEditions();
LeesMelding();
}
private void LeesMelding()
{
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(new Uri(Nav.Uri).Query);
query.TryGetValue("melding", out var melding);
query.TryGetValue("fout", out var fout);
_melding = melding;
_fout = fout;
query.TryGetValue("pdf", out var pdf);
_nieuwPdf = pdf;
}
}
@@ -0,0 +1,36 @@
@page "/uitloggen"
<PageTitle>Uitloggen — ProostenKrant</PageTitle>
<section class="card auth-card">
<h1 class="headline">Uitloggen</h1>
<p class="muted">Bevestig dat je wilt uitloggen bij de redactie.</p>
<EditForm FormName="uitloggen" Method="post" Model="Input" OnValidSubmit="UitloggenUitvoeren" class="krant-form">
<div class="form-actions">
<button type="submit" class="btn btn-primary">Uitloggen</button>
<a class="btn" href="/">Annuleren</a>
</div>
</EditForm>
</section>
@code {
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm(FormName = "uitloggen")]
private ActieInput Input { get; set; } = new();
private sealed class ActieInput { }
protected override Task OnInitializedAsync()
{
Input ??= new();
return Task.CompletedTask;
}
private async Task UitloggenUitvoeren()
{
await HttpContext.SignOutAsync(KrantAuth.Scheme);
Nav.NavigateTo("/", forceLoad: true);
}
}
+15
View File
@@ -0,0 +1,15 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1, .masthead-title" />
</Found>
<NotFound>
<PageTitle>Pagina niet gevonden</PageTitle>
<LayoutView Layout="typeof(Layout.MainLayout)">
<section class="card">
<h2 class="headline">404 — Pagina niet gevonden</h2>
<p>Deze pagina bestaat niet (meer). Keer terug naar de <a href="/">voorpagina</a>.</p>
</section>
</LayoutView>
</NotFound>
</Router>
@@ -0,0 +1,17 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using ProostenKrant
@using ProostenKrant.Components
@using ProostenKrant.Components.Layout
@using ProostenKrant.Models
@using ProostenKrant.Services
@inject KrantInfo Krant
@inject NavigationManager Nav
+42
View File
@@ -0,0 +1,42 @@
namespace ProostenKrant.Models;
public class Article
{
public int Id { get; set; }
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public int AuthorId { get; set; }
public string AuthorName { get; set; } = "";
public string? SeasonName { get; set; }
public string? Participants { get; set; }
public DateTime? EventDate { get; set; }
public int? SeasonId { get; set; }
public string? ArticleImage { get; set; }
public string Status { get; set; } = "draft";
public DateTime CreatedAt { get; set; }
public List<int> ParticipantIds
{
get
{
if (string.IsNullOrWhiteSpace(Participants))
{
return new List<int>();
}
var parts = Participants.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var ids = new List<int>();
foreach (var p in parts)
{
if (int.TryParse(p, out var id))
{
ids.Add(id);
}
}
return ids;
}
}
public string? WebImage => string.IsNullOrEmpty(ArticleImage)
? null
: $"/{ArticleImage.TrimStart('/')}";
}
+19
View File
@@ -0,0 +1,19 @@
namespace ProostenKrant.Models;
public class Season
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string? Description { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public bool IsActive { get; set; } = true;
public string CssKey => Name.ToLowerInvariant() switch
{
"lente" => "lente",
"zomer" => "zomer",
"herfst" => "herfst",
_ => "winter"
};
}
+10
View File
@@ -0,0 +1,10 @@
namespace ProostenKrant.Models;
public class User
{
public int Id { get; set; }
public string Username { get; set; } = "";
public string DisplayName { get; set; } = "";
public string? ProfileImage { get; set; }
public bool IsActive { get; set; } = true;
}
+81
View File
@@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddEnvironmentVariables();
builder.Services.AddRazorComponents();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthentication("Krant").AddCookie("Krant", options =>
{
options.Cookie.Name = "ProostenKrant";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.IsEssential = true;
options.Cookie.Path = "/";
options.LoginPath = "/login";
options.AccessDeniedPath = "/";
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true;
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Editor", policy => policy.RequireClaim("uid"));
});
builder.Services.AddAntiforgery();
builder.Services.AddSingleton<ProostenKrant.Services.Db>();
builder.Services.AddScoped<ProostenKrant.Services.UserService>();
builder.Services.AddScoped<ProostenKrant.Services.SeasonService>();
builder.Services.AddScoped<ProostenKrant.Services.ArticleService>();
builder.Services.AddScoped<ProostenKrant.Services.LatexService>();
builder.Services.AddScoped<ProostenKrant.Services.UploadService>();
builder.Services.AddScoped<ProostenKrant.Services.KrantInfo>();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/fout", createScopeForErrors: true);
}
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapRazorComponents<ProostenKrant.Components.App>();
// Krant drukken (LaTeX-compilatie). [FromForm] activeert automatische
// antiforgery-validatie; RequireAuthorization vereist een redactie-cookie.
app.MapPost("/api/genereer-pdf",
async ([FromForm] int? SeizoenId, ProostenKrant.Services.LatexService latex) =>
{
var result = await latex.GeneratePdfAsync(SeizoenId);
var target = result.Success
? $"/seizoenen?melding={Uri.EscapeDataString("De editie is succesvol gedrukt.")}&pdf={Uri.EscapeDataString(result.FileName!)}"
: $"/seizoenen?fout={Uri.EscapeDataString(result.Error ?? "onbekende fout")}";
return Results.Redirect(target);
}).RequireAuthorization("Editor");
app.MapGet("/edities/{bestand}", (string bestand) =>
{
var safe = Path.GetFileName(bestand);
if (!safe.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
{
return Results.NotFound();
}
var dirs = new[]
{
Path.Combine(app.Environment.ContentRootPath, app.Configuration["Paths:LatexOutput"] ?? "storage/latex"),
Path.Combine(app.Environment.ContentRootPath, app.Configuration["Paths:StaticArchive"] ?? "archive"),
};
var path = dirs.Select(d => Path.Combine(d, safe)).FirstOrDefault(File.Exists);
return path is null ? Results.NotFound() : Results.File(path, "application/pdf", safe);
});
app.Run();
+19
View File
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ProostenKrant</RootNamespace>
<InvariantGlobalization>false</InvariantGlobalization>
<!-- BL0008: formgebonden properties hebben een initializer, maar worden
in OnInitializedAsync bewaakt met ??= new(), dus null is onmogelijk. -->
<NoWarn>$(NoWarn);BL0008</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="MySqlConnector" Version="2.4.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,109 @@
using Dapper;
using ProostenKrant.Models;
namespace ProostenKrant.Services;
public class ArticleService
{
private readonly Db _db;
public ArticleService(Db db) => _db = db;
private const string SelectSql =
"SELECT a.id, a.title, a.content, a.author_id AS AuthorId, a.participants, " +
"a.event_date AS EventDate, a.season_id AS SeasonId, a.article_image AS ArticleImage, " +
"a.status, a.created_at AS CreatedAt, u.display_name AS AuthorName, s.name AS SeasonName " +
"FROM articles a " +
"LEFT JOIN users u ON a.author_id = u.id " +
"LEFT JOIN seasons s ON a.season_id = s.id ";
public async Task<List<Article>> GetAllAsync(string? status = null)
{
await using var con = await _db.OpenAsync();
var sql = SelectSql;
object? args = null;
if (status != null)
{
sql += "WHERE a.status = @status ";
args = new { status };
}
sql += "ORDER BY a.event_date DESC, a.created_at DESC";
var articles = await con.QueryAsync<Article>(sql, args);
return articles.ToList();
}
public async Task<List<Article>> GetBySeasonAsync(int seasonId)
{
await using var con = await _db.OpenAsync();
var articles = await con.QueryAsync<Article>(
SelectSql + "WHERE a.season_id = @seasonId AND a.status = 'published' " +
"ORDER BY a.event_date DESC", new { seasonId });
return articles.ToList();
}
public async Task<Article?> FindAsync(int id)
{
await using var con = await _db.OpenAsync();
return await con.QuerySingleOrDefaultAsync<Article>(
SelectSql + "WHERE a.id = @id", new { id });
}
public async Task<int> CreateAsync(Article a)
{
await using var con = await _db.OpenAsync();
var sql =
"INSERT INTO articles (title, content, author_id, participants, event_date, season_id, article_image, status) " +
"VALUES (@Title, @Content, @AuthorId, @Participants, @EventDate, @SeasonId, @ArticleImage, @Status);" +
"SELECT LAST_INSERT_ID();";
return await con.ExecuteScalarAsync<int>(sql, new
{
a.Title,
a.Content,
a.AuthorId,
Participants = string.Join(",", a.ParticipantIds),
EventDate = a.EventDate?.Date,
a.SeasonId,
a.ArticleImage,
a.Status,
});
}
public async Task UpdateAsync(Article a)
{
await using var con = await _db.OpenAsync();
await con.ExecuteAsync(
"UPDATE articles SET title = @Title, content = @Content, author_id = @AuthorId, " +
"participants = @Participants, event_date = @EventDate, season_id = @SeasonId, " +
"article_image = @ArticleImage WHERE id = @Id", new
{
a.Id,
a.Title,
a.Content,
a.AuthorId,
Participants = string.Join(",", a.ParticipantIds),
EventDate = a.EventDate?.Date,
a.SeasonId,
a.ArticleImage,
});
}
public async Task DeleteAsync(int id)
{
await using var con = await _db.OpenAsync();
await con.ExecuteAsync("DELETE FROM articles WHERE id = @id", new { id });
}
public async Task SetStatusAsync(int id, string status)
{
await using var con = await _db.OpenAsync();
await con.ExecuteAsync("UPDATE articles SET status = @status WHERE id = @id", new { id, status });
}
public async Task<int> CountBySeasonAsync(int seasonId, string status = "published")
{
await using var con = await _db.OpenAsync();
return await con.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM articles WHERE season_id = @seasonId AND status = @status",
new { seasonId, status });
}
}
+34
View File
@@ -0,0 +1,34 @@
using MySqlConnector;
namespace ProostenKrant.Services;
public sealed class Db
{
private readonly string _connectionString;
public Db(IConfiguration config)
{
var host = Environment.GetEnvironmentVariable("DB_HOST") ?? config["Db:Host"] ?? "localhost";
var name = Environment.GetEnvironmentVariable("DB_NAME") ?? config["Db:Name"] ?? "proostenkrant";
var user = Environment.GetEnvironmentVariable("DB_USER") ?? config["Db:User"] ?? "proostenkrant";
var password = Environment.GetEnvironmentVariable("DB_PASSWORD") ?? config["Db:Password"] ?? "proostenkrant";
var builder = new MySqlConnectionStringBuilder
{
Server = host,
Database = name,
UserID = user,
Password = password,
ConnectionTimeout = 30,
DefaultCommandTimeout = 30,
};
_connectionString = builder.ConnectionString;
}
public async Task<MySqlConnection> OpenAsync(CancellationToken ct = default)
{
var connection = new MySqlConnection(_connectionString);
await connection.OpenAsync(ct);
return connection;
}
}
+50
View File
@@ -0,0 +1,50 @@
using System.Security.Claims;
using ProostenKrant.Models;
namespace ProostenKrant.Services;
public static class KrantAuth
{
public const string Scheme = "Krant";
public const int PaszinValidityMinutes = 30;
public static ClaimsPrincipal EditorPrincipal(User user)
{
var identity = new ClaimsIdentity(Scheme);
identity.AddClaim(new Claim("uid", user.Id.ToString()));
identity.AddClaim(new Claim("naam", user.DisplayName));
identity.AddClaim(new Claim(ClaimTypes.Name, user.Username));
return new ClaimsPrincipal(identity);
}
public static ClaimsPrincipal PassphrasePrincipal(int? editArticleId = null)
{
var identity = new ClaimsIdentity(Scheme);
identity.AddClaim(new Claim("paszin", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()));
if (editArticleId is int id)
{
identity.AddClaim(new Claim("bewerk", id.ToString()));
}
return new ClaimsPrincipal(identity);
}
public static bool IsEditor(ClaimsPrincipal user) => user.HasClaim(c => c.Type == "uid");
public static bool HasValidPassphrase(ClaimsPrincipal user)
{
var claim = user.FindFirst("paszin")?.Value;
if (claim == null || !long.TryParse(claim, out var ts))
{
return false;
}
return DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(ts) <= TimeSpan.FromMinutes(PaszinValidityMinutes);
}
public static int? EditArticleId(ClaimsPrincipal user)
{
var claim = user.FindFirst("bewerk")?.Value;
return int.TryParse(claim, out var id) ? id : null;
}
public static string? EditorName(ClaimsPrincipal user) => user.FindFirst("naam")?.Value;
}
+25
View File
@@ -0,0 +1,25 @@
using System.Globalization;
namespace ProostenKrant.Services;
public class KrantInfo
{
private readonly IConfiguration _config;
public KrantInfo(IConfiguration config) => _config = config;
public string AppName => "ProostenKrant";
public string Slogan => "Een krant van en voor Proosten";
public string Location => _config["Krant:Location"] ?? "Regio Kempen";
public string UniversalPassword =>
Environment.GetEnvironmentVariable("UNIVERSAL_PASSWORD")
?? _config["UniversalPassword"]
?? "ProostenKrant2024!";
public string DutchDate(DateTime d) =>
d.ToString("dddd d MMMM yyyy", CultureInfo.GetCultureInfo("nl-NL"));
public string DutchShortDate(DateTime? d) =>
d?.ToString("d MMMM yyyy", CultureInfo.GetCultureInfo("nl-NL")) ?? "";
}
+730
View File
@@ -0,0 +1,730 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using ProostenKrant.Models;
namespace ProostenKrant.Services;
public record Edition(string FileName, string DisplayName, DateTime? Date, long SizeBytes);
public record PdfResult(bool Success, string? FileName, string? Error, string? LogTail);
/// <summary>
/// Krantenzet met blokindeling. Per artikel worden twee vormen gezet (verticaal:
/// gestapeld; horizontaal: tekst links, foto rechts) plus een brede kopvorm voor
/// het hoofdartikel. LaTeX meet in een eerste pase exacte blokhoogtes, C# lost daarna
/// de puzzel (best-fit bin packing) en LaTeX zet de pagina's definitief.
/// </summary>
public class LatexService
{
private readonly ArticleService _articles;
private readonly SeasonService _seasons;
private readonly UserService _users;
private readonly IWebHostEnvironment _env;
private readonly IConfiguration _config;
private readonly ILogger<LatexService> _logger;
// Spiegelen van de LaTeX-geometrie (zie BuildPreamble).
private const double MarginPt = 36; // 0.5in links/rechts
private const double ColSepPt = 12;
private const double TextWidthPt = 612 - 2 * MarginPt; // 540pt
private const double ColWidthPt = (TextWidthPt - 2 * ColSepPt) / 3; // 172pt
private const double WideWidthPt = 2 * ColWidthPt + ColSepPt; // 356pt
private const double BottomMarginPt = 36;
private const double BlockSepPt = 11 + 0.4 + 11; // boven+onder rond de scheidingslijn
private const double SafetyPt = 8;
private string OutputDir => Path.Combine(_env.ContentRootPath,
_config["Paths:LatexOutput"] ?? "storage/latex");
private string ArchiveDir => Path.Combine(_env.ContentRootPath,
_config["Paths:StaticArchive"] ?? "archive");
public LatexService(ArticleService articles, SeasonService seasons, UserService users,
IWebHostEnvironment env, IConfiguration config, ILogger<LatexService> logger)
{
_articles = articles;
_seasons = seasons;
_users = users;
_env = env;
_config = config;
_logger = logger;
}
// =====================================================================
// Editielijst (opslag + permanent archief)
// =====================================================================
public List<Edition> ListEditions()
{
var result = new List<Edition>();
foreach (var dir in new[] { OutputDir, ArchiveDir })
{
if (!Directory.Exists(dir))
{
continue;
}
foreach (var file in Directory.GetFiles(dir, "*.pdf"))
{
var name = Path.GetFileName(file);
var info = new FileInfo(file);
DateTime? date = null;
var match = Regex.Match(name, @"(\d{4}-\d{2}-\d{2})");
if (match.Success && DateTime.TryParseExact(match.Groups[1].Value, "yyyy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var dag))
{
date = dag;
}
else
{
var jaar = Regex.Match(name, @"(20\d{2})");
if (jaar.Success && int.TryParse(jaar.Groups[1].Value, out var j))
{
date = new DateTime(j, 1, 1);
}
}
result.Add(new Edition(name, PrettifyName(name), date, info.Length));
}
}
return result.OrderByDescending(e => e.Date).ToList();
}
private static string PrettifyName(string fileName)
{
var naam = Path.GetFileNameWithoutExtension(fileName);
var match = Regex.Match(naam, @"^Proosten[Kk]rant_(.+)$");
if (match.Success)
{
// Nieuwe edities: "ProostenKrant_Herfst_2026-10-09" -> "Herfst 2026-10-09"
return match.Groups[1].Value.Replace("_", " ");
}
match = Regex.Match(naam, @"^Proosten[Kk]rant(.+)$");
if (match.Success)
{
// Oude archiefedities: "ProostenKrantHerfst2022" -> "ProostenKrant Herfst 2022"
var rest = Regex.Replace(match.Groups[1].Value, @"(?<=[a-z])(?=[A-Z0-9(])|(?<=\d)(?=[A-Z])", " ");
return "ProostenKrant " + rest;
}
return naam;
}
// =====================================================================
// Generatie: meten -> puzzelen -> zetten
// =====================================================================
public async Task<PdfResult> GeneratePdfAsync(int? seasonId)
{
var season = seasonId is int id
? await _seasons.FindAsync(id)
: await _seasons.GetCurrentAsync();
var seasonName = season?.Name ?? "Algemeen";
var articles = seasonId is int sid
? await _articles.GetBySeasonAsync(sid)
: (await _articles.GetAllAsync("published"))
.Where(a => a.SeasonId is null || a.SeasonId == season?.Id).ToList();
if (articles.Count == 0)
{
return new PdfResult(false, null,
$"Er zijn nog geen gepubliceerde artikelen voor de editie {seasonName}. " +
"Publiceer eerst artikelen voordat je de krant drukt.", null);
}
var users = await _users.GetAllAsync();
var usersById = users.ToDictionary(u => u.Id);
Directory.CreateDirectory(OutputDir);
var baseName = $"ProostenKrant_{seasonName}_{DateTime.Today:yyyy-MM-dd}";
var pdfName = baseName + ".pdf";
// ---- Pase 1: blokken meten ----
var blocks = BuildBlocks(articles, usersById);
var metingBase = baseName + "_meting";
await File.WriteAllTextAsync(Path.Combine(OutputDir, metingBase + ".tex"),
BuildMeasureTex(blocks, seasonName));
RunPdflatex(metingBase);
var heightsFile = Path.Combine(OutputDir, "hoogtes.txt");
if (!File.Exists(heightsFile))
{
var logTail = ReadLogTail(metingBase);
Cleanup(metingBase);
return new PdfResult(false, null, "De blokhoogtes konden niet gemeten worden.", logTail);
}
var meting = ParseHeights(heightsFile);
if (meting.Hoogtes.Count < blocks.Count)
{
var logTail = ReadLogTail(metingBase);
Cleanup(metingBase);
return new PdfResult(false, null,
"Niet alle blokhoogtes konden gemeten worden (controleer de afbeeldingen).", logTail);
}
// ---- Puzzelen ----
var layout = Pack(blocks, meting);
// ---- Pase 2/3: definitief zetten ----
await File.WriteAllTextAsync(Path.Combine(OutputDir, baseName + ".tex"),
BuildFinalTex(blocks, layout, seasonName));
for (var run = 0; run < 2; run++)
{
RunPdflatex(baseName);
}
var pdfPath = Path.Combine(OutputDir, pdfName);
Cleanup(metingBase);
if (!File.Exists(pdfPath) || new FileInfo(pdfPath).Length < 1024)
{
return new PdfResult(false, null, "Er werd geen geldige PDF aangemaakt.", ReadLogTail(baseName));
}
foreach (var ext in new[] { ".aux", ".log", ".out" })
{
var tmp = Path.Combine(OutputDir, baseName + ext);
if (File.Exists(tmp))
{
File.Delete(tmp);
}
}
return new PdfResult(true, pdfName, null, null);
}
// =====================================================================
// Blokken (artikel x vorm)
// =====================================================================
private enum Vorm { V, H, W }
private sealed record Block(int ArtikelId, Vorm Vorm, string Body, double WidthPt);
private static string BoxName(int artikelId, Vorm vorm) => $"blk{artikelId}{vorm}";
private List<Block> BuildBlocks(List<Article> articles, Dictionary<int, User> usersById)
{
var blocks = new List<Block>();
foreach (var artikel in articles)
{
var tekst = EscapeLatex(artikel.Content.Replace("\r", "")
.Replace("\n\n", "\n").Replace("\n", "\\par "));
var titel = EscapeLatex(artikel.Title);
var datum = EscapeLatex(artikel.EventDate?.ToString("d MMMM yyyy", CultureInfo.GetCultureInfo("nl-NL")) ?? "");
var auteur = EscapeLatex(artikel.AuthorName);
var deelnemers = artikel.ParticipantIds
.Where(uid => usersById.ContainsKey(uid))
.Select(uid => usersById[uid])
.Take(2)
.ToList();
var image = ResolveImage(artikel.ArticleImage, mustExist: true);
blocks.Add(new Block(artikel.Id, Vorm.V,
BuildBodyV(titel, tekst, datum, auteur, deelnemers, image), ColWidthPt));
blocks.Add(new Block(artikel.Id, Vorm.H,
BuildBodyH(titel, tekst, datum, auteur, deelnemers, image), ColWidthPt));
blocks.Add(new Block(artikel.Id, Vorm.W,
BuildBodyW(titel, tekst, datum, auteur, deelnemers, image), WideWidthPt));
}
return blocks;
}
// Verticale vorm: titel, byline, tekst, schrijver(s), foto.
private string BuildBodyV(string titel, string tekst, string datum, string auteur,
List<User> deelnemers, string? image)
{
var sb = new StringBuilder();
sb.AppendLine(@"{\noindent{\large\itshape " + titel + @"}\par");
sb.AppendLine($@"\vspace{{3pt}}{{\footnotesize\scshape {datum} --- {auteur}}}\par");
sb.AppendLine($@"\vspace{{5pt}}{tekst}\par");
if (deelnemers.Count > 0)
{
sb.AppendLine($@"\vspace{{5pt}}{AvatarRow(ColWidthPt, deelnemers)}\par");
}
if (image is not null)
{
sb.AppendLine($@"\vspace{{5pt}}{FramedImage(image, "0.22")}\par");
}
sb.AppendLine("}");
return sb.ToString();
}
// Horizontale vorm: titel/tekst links, foto rechts, schrijver(s) eronder.
private string BuildBodyH(string titel, string tekst, string datum, string auteur,
List<User> deelnemers, string? image)
{
var sb = new StringBuilder();
sb.AppendLine(@"{\noindent");
sb.AppendLine(@"\begin{minipage}[t]{0.63\linewidth}");
sb.AppendLine($@"\vspace*{{0pt}}\noindent{{\large\itshape {titel}}}\par");
sb.AppendLine($@"\vspace{{3pt}}{{\footnotesize\scshape {datum} --- {auteur}}}\par");
sb.AppendLine($@"\vspace{{5pt}}{tekst}\par");
sb.AppendLine(@"\end{minipage}");
if (image is not null)
{
sb.AppendLine(@"\hfill");
sb.AppendLine(@"\begin{minipage}[t]{0.34\linewidth}");
sb.AppendLine($@"\vspace*{{0pt}}{FramedImage(image, "0.30")}\par");
sb.AppendLine(@"\end{minipage}");
}
if (deelnemers.Count > 0)
{
sb.AppendLine($@"\par\vspace{{5pt}}{AvatarRow(ColWidthPt, deelnemers)}\par");
}
sb.AppendLine("}");
return sb.ToString();
}
// Brede kopvorm: grootse titel, tekst links, foto rechts, schrijver(s) eronder.
private string BuildBodyW(string titel, string tekst, string datum, string auteur,
List<User> deelnemers, string? image)
{
var sb = new StringBuilder();
sb.AppendLine(@"{\noindent{\LARGE\itshape " + titel + @"}\par");
sb.AppendLine($@"\vspace{{3pt}}{{\footnotesize\scshape {datum} --- {auteur}}}\par");
sb.AppendLine(@"\vspace{6pt}\noindent");
sb.AppendLine(@"\begin{minipage}[t]{0.63\linewidth}");
sb.AppendLine($@"\vspace*{{0pt}}{tekst}\par");
sb.AppendLine(@"\end{minipage}");
if (image is not null)
{
sb.AppendLine(@"\hfill");
sb.AppendLine(@"\begin{minipage}[t]{0.34\linewidth}");
sb.AppendLine($@"\vspace*{{0pt}}{FramedImage(image, "0.26")}\par");
sb.AppendLine(@"\end{minipage}");
}
if (deelnemers.Count > 0)
{
sb.AppendLine($@"\par\vspace{{6pt}}{AvatarRow(WideWidthPt, deelnemers)}\par");
}
sb.AppendLine("}");
return sb.ToString();
}
/// <summary>
/// Rij ronde profielfoto's met de naam eronder. Diameter = 1/4 van de
/// artikelsbreedte, zoals gevraagd.
/// </summary>
private string AvatarRow(double blockBreedtePt, List<User> deelnemers)
{
var diameter = Math.Max(20, blockBreedtePt / 4);
var sb = new StringBuilder(@"\centering");
for (var i = 0; i < deelnemers.Count; i++)
{
if (i > 0)
{
sb.Append(@"\quad ");
}
var img = ProfileImagePath(deelnemers[i]);
sb.Append($@"\begin{{minipage}}[c]{{{diameter + 14:F1}pt}}");
sb.Append(@"\centering");
sb.Append($@"\begin{{tikzpicture}}\clip (0,0) circle ({diameter / 2:F1}pt);");
sb.Append($@"\node at (0,0) {{\includegraphics[width={diameter:F1}pt,height={diameter:F1}pt,keepaspectratio]{{{img}}}}};");
sb.Append(@"\end{tikzpicture}");
sb.Append($@"\par\vspace{{2pt}}{{\footnotesize {EscapeLatex(deelnemers[i].DisplayName)}}}");
sb.Append(@"\end{minipage}");
}
return sb.ToString();
}
private static string FramedImage(string path, string hoogteFactorTextheight)
{
return $@"\setlength{{\fboxsep}}{{3pt}}\framebox{{\includegraphics[max width=\dimexpr\linewidth-2\fboxsep-2\fboxrule\relax,max height={hoogteFactorTextheight}\textheight,keepaspectratio]{{{path}}}}}";
}
// =====================================================================
// TeX-generatie
// =====================================================================
private string BuildPreamble()
{
var sb = new StringBuilder();
sb.AppendLine(@"\documentclass[10pt]{article}");
sb.AppendLine(@"\usepackage[utf8]{inputenc}");
sb.AppendLine(@"\usepackage[T1]{fontenc}");
sb.AppendLine(@"\usepackage{microtype}");
sb.AppendLine(@"\usepackage[dutch]{babel}");
sb.AppendLine(@"\usepackage{newspaper}");
sb.AppendLine(@"\usepackage[letterpaper,left=36pt,right=36pt,top=0.55in,bottom=36pt,headheight=40pt,headsep=12pt]{geometry}");
sb.AppendLine(@"\usepackage{graphicx}");
sb.AppendLine(@"\usepackage{tikz}");
sb.AppendLine(@"\usepackage[export]{adjustbox}");
sb.AppendLine(@"\usepackage{calc}");
sb.AppendLine(@"\usepackage{newtxtext}");
sb.AppendLine();
sb.AppendLine(@"\date{\today}");
sb.AppendLine(@"\currentvolume{1}");
sb.AppendLine(@"\currentissue{1}");
sb.AppendLine($@"\SetPaperName{{{_config["Krant:PaperName"] ?? "Proo{s:}tenKrant"}}}");
sb.AppendLine($@"\SetHeaderName{{{_config["Krant:HeaderName"] ?? "ProostenKrant"}}}");
sb.AppendLine($@"\SetPaperLocation{{{_config["Krant:Location"] ?? "Regio Kempen"}}}");
sb.AppendLine($@"\SetPaperSlogan{{{_config["Krant:Slogan"] ?? "``Een krant van en voor Proosten.''"}}}");
sb.AppendLine($@"\SetPaperPrice{{{_config["Krant:Price"] ?? "NUL EURO"}}}");
sb.AppendLine();
// Scheiding tussen blokken: duidelijk groter dan de interne spacing.
sb.AppendLine(@"\newcommand{\blocksep}{\par\nointerlineskip\vspace{11pt}\noindent{\rule{\linewidth}{0.4pt}}\par\vspace{11pt}}");
sb.AppendLine(@"\newcommand{\colrule}{\hfill{\vrule width 0.3pt}\hfill}");
return sb.ToString();
}
private string BuildMeasureTex(List<Block> blocks, string seasonName)
{
var sb = new StringBuilder();
sb.Append(BuildPreamble());
sb.AppendLine(@"\newwrite\hoogtes");
sb.AppendLine(@"\immediate\openout\hoogtes=hoogtes.txt");
sb.AppendLine(@"\begin{document}");
sb.AppendLine(@"\maketitle");
sb.AppendLine($@"\noindent\textbf{{\LARGE Editie: {EscapeLatex(seasonName)}}}\par");
sb.AppendLine(@"\vspace{8pt}");
sb.AppendLine(@"\pdfsavepos");
// \pdflastypos is pas geldig nadat de pagina is uitgeleverd.
sb.AppendLine(@"\newpage");
sb.AppendLine(@"\immediate\write\hoogtes{MASTHEAD \the\pdflastypos}");
sb.AppendLine(@"\immediate\write\hoogtes{GEOM \the\textwidth,\the\textheight}");
sb.AppendLine();
foreach (var b in blocks)
{
sb.AppendLine($@"\expandafter\newsavebox\csname {BoxName(b.ArtikelId, b.Vorm)}\endcsname");
sb.AppendLine($@"\expandafter\savebox\csname {BoxName(b.ArtikelId, b.Vorm)}\endcsname{{\parbox{{{b.WidthPt:F1}pt}}{{{b.Body}}}}}");
sb.AppendLine($@"\setlength{{\dimen0}}{{\ht\csname {BoxName(b.ArtikelId, b.Vorm)}\endcsname}}%");
sb.AppendLine($@"\advance\dimen0 by \dp\csname {BoxName(b.ArtikelId, b.Vorm)}\endcsname");
sb.AppendLine($@"\immediate\write\hoogtes{{H {b.ArtikelId} {b.Vorm} \the\dimen0}}");
}
sb.AppendLine(@"\end{document}");
return sb.ToString();
}
private string BuildFinalTex(List<Block> blocks, List<Page> layout, string seasonName)
{
var sb = new StringBuilder();
sb.Append(BuildPreamble());
sb.AppendLine(@"\begin{document}");
sb.AppendLine(@"\maketitle");
sb.AppendLine($@"\noindent\textbf{{\LARGE Editie: {EscapeLatex(seasonName)}}}\par");
sb.AppendLine(@"\vspace{8pt}");
sb.AppendLine();
// Alle gebruikte blokken opnieuw definiëren (identiek aan de metingspase).
var gebruikt = new HashSet<(int, Vorm)>();
foreach (var page in layout)
{
foreach (var p in page.Columns.SelectMany(c => c).Concat(page.Lead is null ? Array.Empty<Placement>() : new[] { page.Lead }))
{
if (!gebruikt.Add((p.ArtikelId, p.Vorm)))
{
continue;
}
var b = blocks.First(x => x.ArtikelId == p.ArtikelId && x.Vorm == p.Vorm);
sb.AppendLine($@"\expandafter\newsavebox\csname {BoxName(b.ArtikelId, b.Vorm)}\endcsname");
sb.AppendLine($@"\expandafter\savebox\csname {BoxName(b.ArtikelId, b.Vorm)}\endcsname{{\parbox{{{b.WidthPt:F1}pt}}{{{b.Body}}}}}");
}
}
sb.AppendLine();
for (var i = 0; i < layout.Count; i++)
{
var page = layout[i];
sb.AppendLine(@"\noindent");
if (page.Lead is not null)
{
sb.AppendLine($@"\begin{{minipage}}[t]{{{WideWidthPt:F1}pt}}");
sb.AppendLine($@"\expandafter\usebox\csname {BoxName(page.Lead.ArtikelId, page.Lead.Vorm)}\endcsname\par\vspace{{14pt}}");
sb.AppendLine(@"\noindent");
sb.AppendLine($@"\begin{{minipage}}[t]{{{ColWidthPt:F1}pt}}");
sb.Append(ColumnBody(page.Columns[0]));
sb.AppendLine(@"\end{minipage}\colrule");
sb.AppendLine($@"\begin{{minipage}}[t]{{{ColWidthPt:F1}pt}}");
sb.Append(ColumnBody(page.Columns[1]));
sb.AppendLine(@"\end{minipage}");
sb.AppendLine(@"\end{minipage}\colrule");
}
else
{
sb.AppendLine($@"\begin{{minipage}}[t]{{{ColWidthPt:F1}pt}}");
sb.Append(ColumnBody(page.Columns[0]));
sb.AppendLine(@"\end{minipage}\colrule");
sb.AppendLine($@"\begin{{minipage}}[t]{{{ColWidthPt:F1}pt}}");
sb.Append(ColumnBody(page.Columns[1]));
sb.AppendLine(@"\end{minipage}\colrule");
}
sb.AppendLine($@"\begin{{minipage}}[t]{{{ColWidthPt:F1}pt}}");
sb.Append(ColumnBody(page.Columns[2]));
sb.AppendLine(@"\end{minipage}");
if (i < layout.Count - 1)
{
sb.AppendLine(@"\newpage");
}
}
sb.AppendLine(@"\end{document}");
return sb.ToString();
}
private static string ColumnBody(List<Placement> kolom)
{
var sb = new StringBuilder();
for (var i = 0; i < kolom.Count; i++)
{
if (i > 0)
{
sb.AppendLine(@"\blocksep");
}
sb.AppendLine($@"\expandafter\usebox\csname {BoxName(kolom[i].ArtikelId, kolom[i].Vorm)}\endcsname");
}
if (kolom.Count == 0)
{
sb.AppendLine(@"\vspace*{0pt}");
}
return sb.ToString();
}
// =====================================================================
// Puzzel: best-fit bin packing per pagina (3 kolommen, kopartikel op p.1)
// =====================================================================
private sealed record Placement(int ArtikelId, Vorm Vorm);
private sealed record Page(Placement? Lead, List<List<Placement>> Columns);
private sealed record MetingData(double MastheadPosySp, double TextHeightPt,
Dictionary<(int, Vorm), double> Hoogtes);
private MetingData ParseHeights(string pad)
{
double masthead = 0, texth = 0;
var hoogtes = new Dictionary<(int, Vorm), double>();
foreach (var regel in File.ReadLines(pad))
{
var delen = regel.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (delen.Length >= 2 && delen[0] == "MASTHEAD")
{
masthead = double.Parse(delen[1], CultureInfo.InvariantCulture);
}
else if (delen.Length >= 2 && delen[0] == "GEOM")
{
// "\the\textwidth,\the\textheight" (komma: \write snoeit spaties)
var getallen = Regex.Match(delen[1], @"([\d.]+)pt,([\d.]+)pt");
if (getallen.Success)
{
texth = double.Parse(getallen.Groups[2].Value, CultureInfo.InvariantCulture);
}
}
else if (delen.Length >= 4 && delen[0] == "H" &&
Enum.TryParse<Vorm>(delen[2], out var vorm))
{
hoogtes[(int.Parse(delen[1]), vorm)] = ParsePt(delen[3]);
}
}
return new MetingData(masthead, texth, hoogtes);
}
private static double ParsePt(string tex) =>
double.Parse(tex.TrimEnd('p', 't'), CultureInfo.InvariantCulture);
private List<Page> Pack(List<Block> blocks, MetingData meting)
{
double Hoogte(int id, Vorm v) => meting.Hoogtes.TryGetValue((id, v), out var h) ? h : 0;
// Pagina 1: beschikbare hoogte onder de krantenkop (posy = vanaf de
// onderkant van de pagina in scaled points).
var mastheadOver = meting.MastheadPosySp / 65536.0 - BottomMarginPt - 48;
var pag1Cap = Math.Min(meting.TextHeightPt - SafetyPt, mastheadOver);
var pagCap = meting.TextHeightPt - SafetyPt;
if (pag1Cap < 150)
{
pag1Cap = pagCap;
}
var resterend = blocks.Select(b => b.ArtikelId).Distinct().ToList();
var layout = new List<Page>();
// Kopartikel op pagina 1: het artikel met de meeste inhoud dat als
// brede kop past (max. 62% van de paginahoogte).
var leadGrens = pag1Cap * 0.62;
var leadId = resterend
.Where(id => Hoogte(id, Vorm.W) > 0 && Hoogte(id, Vorm.W) <= leadGrens)
.OrderByDescending(id => Hoogte(id, Vorm.W))
.FirstOrDefault();
double leadHoogte = 0;
if (leadId != 0)
{
leadHoogte = Hoogte(leadId, Vorm.W);
resterend.Remove(leadId);
}
var pagina = 0;
while (resterend.Count > 0)
{
var caps = new[]
{
pagina == 0 && leadId != 0 ? pag1Cap - leadHoogte - 14 : pag1Cap,
pagina == 0 && leadId != 0 ? pag1Cap - leadHoogte - 14 : pag1Cap,
pag1Cap,
};
if (pagina > 0)
{
caps[0] = caps[1] = caps[2] = pagCap;
}
var kolommen = new List<List<Placement>> { new(), new(), new() };
var geplaatstDezePagina = 0;
while (resterend.Count > 0)
{
// Best-fit: zoek over alle kolommen het hoogste blok dat nog past.
var best = (Kolom: -1, ArtikelId: 0, Vorm: Vorm.V, Hoogte: 0.0);
for (var k = 0; k < 3; k++)
{
if (caps[k] <= 0)
{
continue;
}
foreach (var id in resterend)
{
foreach (var vorm in new[] { Vorm.V, Vorm.H })
{
var h = Hoogte(id, vorm);
if (h > 0 && h <= caps[k] && h > best.Hoogte)
{
best = (k, id, vorm, h);
}
}
}
}
if (best.Kolom < 0)
{
break; // pagina vol
}
kolommen[best.Kolom].Add(new Placement(best.ArtikelId, best.Vorm));
caps[best.Kolom] -= best.Hoogte + (kolommen[best.Kolom].Count > 1 ? BlockSepPt : 0);
resterend.Remove(best.ArtikelId);
geplaatstDezePagina++;
}
if (geplaatstDezePagina == 0 && leadId == 0 || pagina > 0 && geplaatstDezePagina == 0)
{
// Vangnet: geen enkel blok past (kapotte meting) - niet oneindig doorgaan.
_logger.LogError("Packer plaatst niets meer; meting lijkt ongeldig.");
break;
}
layout.Add(new Page(
pagina == 0 && leadId != 0 ? new Placement(leadId, Vorm.W) : null,
kolommen));
pagina++;
pag1Cap = pagCap; // vanaf pagina 2 geldt de volle hoogte
}
if (layout.Count == 0)
{
layout.Add(new Page(null, new List<List<Placement>> { new(), new(), new() }));
}
return layout;
}
// =====================================================================
// Helper: pdflatex, paden, opschonen
// =====================================================================
private string? ResolveImage(string? webPath, bool mustExist)
{
if (string.IsNullOrEmpty(webPath))
{
return null;
}
var rel = webPath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar);
var abs = Path.GetFullPath(Path.Combine(_env.WebRootPath, rel));
if (!mustExist || File.Exists(abs))
{
return abs.Replace(Path.DirectorySeparatorChar, '/');
}
return null;
}
private string ProfileImagePath(User user)
{
// Webpad via de gedeelde resolver, dan omzetten naar een absoluut
// pad voor LaTeX (resources-map kan buiten de uploadmap hangen).
var web = _users.GetProfileWebPath(user).TrimStart('/');
return Path.GetFullPath(Path.Combine(_env.WebRootPath ?? ".",
web.Replace('/', Path.DirectorySeparatorChar))).Replace(Path.DirectorySeparatorChar, '/');
}
private void RunPdflatex(string baseName)
{
var psi = new ProcessStartInfo
{
FileName = "pdflatex",
Arguments = $"-interaction=nonstopmode \"{baseName}.tex\"",
WorkingDirectory = OutputDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var process = Process.Start(psi);
if (process == null)
{
throw new InvalidOperationException("pdflatex kon niet gestart worden.");
}
var output = process.StandardOutput.ReadToEnd();
process.StandardError.ReadToEnd();
process.WaitForExit(120_000);
if (process.ExitCode != 0)
{
_logger.LogWarning("pdflatex ({Base}) meldde fouten (exit {Code}). Log: {Log}",
baseName, process.ExitCode, output[..Math.Min(2000, output.Length)]);
}
}
private string? ReadLogTail(string baseName)
{
var logPath = Path.Combine(OutputDir, baseName + ".log");
return File.Exists(logPath)
? string.Join('\n', File.ReadLines(logPath).TakeLast(40))
: null;
}
private void Cleanup(string baseName)
{
foreach (var bestand in new[]
{
baseName + ".aux", baseName + ".log", baseName + ".out", baseName + ".pdf",
baseName + ".tex",
})
{
var pad = Path.Combine(OutputDir, bestand);
if (File.Exists(pad))
{
File.Delete(pad);
}
}
var hoogtes = Path.Combine(OutputDir, "hoogtes.txt");
if (File.Exists(hoogtes) && Environment.GetEnvironmentVariable("HOOGTES_BEWAREN") != "1")
{
File.Delete(hoogtes);
}
}
internal static string EscapeLatex(string text)
{
if (string.IsNullOrEmpty(text))
{
return string.Empty;
}
var sb = new StringBuilder(text.Length);
foreach (var ch in text)
{
switch (ch)
{
case '\\': sb.Append(@"\textbackslash{}"); break;
case '&': sb.Append(@"\&"); break;
case '%': sb.Append(@"\%"); break;
case '$': sb.Append(@"\$"); break;
case '#': sb.Append(@"\#"); break;
case '_': sb.Append(@"\_"); break;
case '{': sb.Append(@"\{"); break;
case '}': sb.Append(@"\}"); break;
case '~': sb.Append(@"\textasciitilde{}"); break;
case '^': sb.Append(@"\textasciicircum{}"); break;
default: sb.Append(ch); break;
}
}
return sb.ToString();
}
}
@@ -0,0 +1,46 @@
using Dapper;
using ProostenKrant.Models;
namespace ProostenKrant.Services;
public class SeasonService
{
private readonly Db _db;
public SeasonService(Db db) => _db = db;
public async Task<List<Season>> GetAllAsync()
{
await using var con = await _db.OpenAsync();
var seasons = await con.QueryAsync<Season>(
"SELECT id, name, description, start_date AS StartDate, end_date AS EndDate, is_active AS IsActive " +
"FROM seasons ORDER BY start_date");
return seasons.ToList();
}
public async Task<Season?> FindAsync(int id)
{
await using var con = await _db.OpenAsync();
return await con.QuerySingleOrDefaultAsync<Season>(
"SELECT id, name, description, start_date AS StartDate, end_date AS EndDate, is_active AS IsActive " +
"FROM seasons WHERE id = @id", new { id });
}
public async Task<Season?> GetCurrentAsync()
{
await using var con = await _db.OpenAsync();
return await con.QuerySingleOrDefaultAsync<Season>(
"SELECT id, name, description, start_date AS StartDate, end_date AS EndDate, is_active AS IsActive " +
"FROM seasons WHERE start_date <= CURDATE() AND end_date >= CURDATE() " +
"ORDER BY start_date DESC LIMIT 1");
}
public async Task<Season?> GetByDateAsync(DateTime date)
{
await using var con = await _db.OpenAsync();
return await con.QuerySingleOrDefaultAsync<Season>(
"SELECT id, name, description, start_date AS StartDate, end_date AS EndDate, is_active AS IsActive " +
"FROM seasons WHERE start_date <= @date AND end_date >= @date " +
"ORDER BY start_date DESC LIMIT 1", new { date = date.Date });
}
}
@@ -0,0 +1,40 @@
namespace ProostenKrant.Services;
public class UploadService
{
private readonly IWebHostEnvironment _env;
private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".gif", ".webp" };
private const long MaxBytes = 25 * 1024 * 1024;
public UploadService(IWebHostEnvironment env) => _env = env;
public async Task<(string? WebPath, string? Error)> SaveArticleImageAsync(IFormFile? file)
{
if (file == null || file.Length == 0)
{
return (null, null);
}
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
{
return (null, "Ongeldig bestandstype. Toegelaten: JPG, PNG, GIF of WEBP.");
}
if (file.Length > MaxBytes)
{
return (null, "De afbeelding is te groot (maximaal 25 MB).");
}
var baseName = Path.GetFileNameWithoutExtension(file.FileName)
.Replace(" ", "-", StringComparison.Ordinal);
var safeBase = new string(baseName.Where(char.IsLetterOrDigit).Take(40).ToArray());
var fileName = $"{Guid.NewGuid().ToString("N")[..8]}_{safeBase}{ext}";
var dir = Path.Combine(_env.WebRootPath, "uploads", "articles");
Directory.CreateDirectory(dir);
await using var stream = File.Create(Path.Combine(dir, fileName));
await file.CopyToAsync(stream);
return ($"uploads/articles/{fileName}", null);
}
}
+96
View File
@@ -0,0 +1,96 @@
using Dapper;
using ProostenKrant.Models;
namespace ProostenKrant.Services;
public class UserService
{
private readonly Db _db;
private readonly IWebHostEnvironment _env;
private readonly IConfiguration _config;
private static readonly string[] AfbeeldingExtensies = { ".jpg", ".jpeg", ".png", ".webp" };
public UserService(Db db, IWebHostEnvironment env, IConfiguration config)
{
_db = db;
_env = env;
_config = config;
}
public async Task<List<User>> GetAllAsync()
{
await using var con = await _db.OpenAsync();
var users = await con.QueryAsync<User>(
"SELECT id, username, display_name AS DisplayName, profile_image AS ProfileImage, is_active AS IsActive " +
"FROM users ORDER BY display_name");
return users.ToList();
}
public async Task<User?> FindAsync(int id)
{
await using var con = await _db.OpenAsync();
return await con.QuerySingleOrDefaultAsync<User>(
"SELECT id, username, display_name AS DisplayName, profile_image AS ProfileImage, is_active AS IsActive " +
"FROM users WHERE id = @id", new { id });
}
public async Task<List<User>> GetByIdsAsync(IEnumerable<int> ids)
{
var idList = ids.Distinct().ToList();
if (idList.Count == 0)
{
return new List<User>();
}
await using var con = await _db.OpenAsync();
var users = await con.QueryAsync<User>(
"SELECT id, username, display_name AS DisplayName, profile_image AS ProfileImage, is_active AS IsActive " +
"FROM users WHERE id IN @ids ORDER BY display_name", new { ids = idList });
return users.ToList();
}
/// <summary>
/// Zoekt de profielfoto van een familielid. Volgorde: eigen uploadmap,
/// resources-map (bijv. nieuwe familieleden), dan Unknown.jpg.
/// </summary>
public string GetProfileWebPath(User user)
{
var kandidaten = new List<(string Web, string Abs)>();
if (!string.IsNullOrEmpty(user.ProfileImage))
{
var rel = user.ProfileImage.TrimStart('/');
kandidaten.Add(("/" + rel, AbsPath(rel)));
}
kandidaten.Add(("/uploads/profiles/" + user.DisplayName + ".jpg",
AbsPath("uploads/profiles/" + user.DisplayName + ".jpg")));
var resources = _config["Paths:ResourceImages"] ?? "resources-images";
var resDir = Path.Combine(_env.WebRootPath ?? ".", resources);
if (Directory.Exists(resDir))
{
foreach (var bestand in Directory.GetFiles(resDir))
{
var naam = Path.GetFileNameWithoutExtension(bestand);
var ext = Path.GetExtension(bestand).ToLowerInvariant();
if (naam.Equals(user.DisplayName, StringComparison.OrdinalIgnoreCase) &&
AfbeeldingExtensies.Contains(ext))
{
kandidaten.Insert(1, ("/" + resources + "/" + Path.GetFileName(bestand), bestand));
}
}
}
foreach (var (web, abs) in kandidaten)
{
if (File.Exists(abs))
{
return web;
}
}
return "/uploads/profiles/Unknown.jpg";
}
private string AbsPath(string webPath) =>
Path.Combine(_env.WebRootPath ?? ".", webPath.Replace('/', Path.DirectorySeparatorChar));
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Information"
}
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Db": {
"Host": "localhost",
"Name": "proostenkrant",
"User": "proostenkrant",
"Password": "proostenkrant"
},
"UniversalPassword": "ProostenKrant2024!",
"Paths": {
"LatexOutput": "storage/latex",
"StaticArchive": "archive",
"ResourceImages": "resources-images"
},
"Krant": {
"PaperName": "Proo{s:}tenKrant",
"HeaderName": "ProostenKrant",
"Location": "Regio Kempen",
"Slogan": "``Een krant van en voor Proosten.''",
"Price": "NUL EURO"
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 600" width="900" height="600">
<rect width="900" height="600" fill="#003399"/>
<polygon points="450.00,66.67 442.52,89.70 418.30,89.70 437.89,103.93 430.41,126.97 450.00,112.73 469.59,126.97 462.11,103.93 481.70,89.70 457.48,89.70 " fill="#FFCC00"/>
<polygon points="350.00,93.46 342.52,116.49 318.30,116.49 337.89,130.73 330.41,153.76 350.00,139.53 369.59,153.76 362.11,130.73 381.70,116.49 357.48,116.49 " fill="#FFCC00"/>
<polygon points="276.79,166.67 269.31,189.70 245.09,189.70 264.69,203.93 257.20,226.97 276.79,212.73 296.39,226.97 288.90,203.93 308.50,189.70 284.28,189.70 " fill="#FFCC00"/>
<polygon points="250.00,266.67 242.52,289.70 218.30,289.70 237.89,303.93 230.41,326.97 250.00,312.73 269.59,326.97 262.11,303.93 281.70,289.70 257.48,289.70 " fill="#FFCC00"/>
<polygon points="276.79,366.67 269.31,389.70 245.09,389.70 264.69,403.93 257.20,426.97 276.79,412.73 296.39,426.97 288.90,403.93 308.50,389.70 284.28,389.70 " fill="#FFCC00"/>
<polygon points="350.00,439.87 342.52,462.90 318.30,462.90 337.89,477.14 330.41,500.17 350.00,485.94 369.59,500.17 362.11,477.14 381.70,462.90 357.48,462.90 " fill="#FFCC00"/>
<polygon points="450.00,466.67 442.52,489.70 418.30,489.70 437.89,503.93 430.41,526.97 450.00,512.73 469.59,526.97 462.11,503.93 481.70,489.70 457.48,489.70 " fill="#FFCC00"/>
<polygon points="550.00,439.87 542.52,462.90 518.30,462.90 537.89,477.14 530.41,500.17 550.00,485.94 569.59,500.17 562.11,477.14 581.70,462.90 557.48,462.90 " fill="#FFCC00"/>
<polygon points="623.21,366.67 615.72,389.70 591.50,389.70 611.10,403.93 603.61,426.97 623.21,412.73 642.80,426.97 635.31,403.93 654.91,389.70 630.69,389.70 " fill="#FFCC00"/>
<polygon points="650.00,266.67 642.52,289.70 618.30,289.70 637.89,303.93 630.41,326.97 650.00,312.73 669.59,326.97 662.11,303.93 681.70,289.70 657.48,289.70 " fill="#FFCC00"/>
<polygon points="623.21,166.67 615.72,189.70 591.50,189.70 611.10,203.93 603.61,226.97 623.21,212.73 642.80,226.97 635.31,203.93 654.91,189.70 630.69,189.70 " fill="#FFCC00"/>
<polygon points="550.00,93.46 542.52,116.49 518.30,116.49 537.89,130.73 530.41,153.76 550.00,139.53 569.59,153.76 562.11,130.73 581.70,116.49 557.48,116.49 " fill="#FFCC00"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB