Updated the proostekrant to work properly again.
This commit is contained in:
@@ -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">„@Krant.Slogan“</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>© @DateTime.Now.Year @Krant.AppName — 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>— @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 „@_artikel.Title“ 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 →</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 →</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 →</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 & 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>— @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>— @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>— @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 →</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 — PDF</span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<p class="edition-more"><a href="/archief">Bekijk het volledige archief →</a></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted">Er zijn nog geen gedrukte edities. Zodra de redactie de krant genereert, verschijnen hier de PDF’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) — @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> <a href="/edities/@_nieuwPdf">Download →</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 & 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> <a href="/edities/@_nieuwPdf">Download de krant →</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) — @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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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('/')}";
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")) ?? "";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
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 |
Reference in New Issue
Block a user