Updated the proostekrant to work properly again.

This commit is contained in:
douwe
2026-10-09 10:33:21 +02:00
parent 56d143b74b
commit 256a81a770
133 changed files with 10491 additions and 837 deletions
+14
View File
@@ -0,0 +1,14 @@
# Houd de build-context klein (context is de repo-root)
.git
.vs
.env
app
Latex
Build
Proostenkrant.NET
docker
node_modules
**/bin
**/obj
*.pdf
*.log
+16
View File
@@ -0,0 +1,16 @@
# ProostenKrant Environment Configuration
# Database Configuration
DB_HOST=mysql
DB_NAME=proostenkrant
DB_USER=proostenkrant
DB_PASSWORD=proostenkrant
# Application Configuration
UNIVERSAL_PASSWORD=ProostenKrant2024!
# PHP Configuration
UPLOAD_MAX_FILESIZE=50M
POST_MAX_SIZE=52M
MEMORY_LIMIT=512M
MAX_EXECUTION_TIME=300
+65
View File
@@ -0,0 +1,65 @@
# Dependencies
/vendor
composer.lock
# IDE
.idea
.vscode
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Docker
.docker
# Environment
.env
.env.local
.env.*.local
# Logs
*.log
logs/
# Storage
app/storage/*
!app/storage/.gitkeep
# Public uploads (keep directory, ignore contents)
app/public/images/articles/*
!app/public/images/articles/.gitkeep
app/public/images/profiles/*
!app/public/images/profiles/.gitkeep
# LaTeX output
app/storage/latex/*
!app/storage/latex/.gitkeep
# Database dumps
*.sql
backup/
# Temporary files
*.tmp
*.temp
cache/
# Build files
/build
/obj
# Old project files
*.exe
*.pdb
*.dll
*.config
*.sln
# Blazor (.NET) build artifacts
src/**/bin/
src/**/obj/
*.user
-495
View File
@@ -1,495 +0,0 @@
This is pdfTeX, Version 3.141592653-2.6-1.40.24 (TeX Live 2022) (preloaded format=pdflatex 2022.12.23) 23 DEC 2022 20:23
entering extended mode
restricted \write18 enabled.
%&-line parsing enabled.
**../Latex/Main.tex
(../Latex/Main.tex
LaTeX2e <2022-11-01> patch level 1
L3 programming layer <2022-12-17>
(c:/texlive/2022/texmf-dist/tex/latex/base/article.cls
Document Class: article 2022/07/02 v1.4n Standard LaTeX document class
(c:/texlive/2022/texmf-dist/tex/latex/base/size10.clo
File: size10.clo 2022/07/02 v1.4n Standard LaTeX file (size option)
)
\c@part=\count185
\c@section=\count186
\c@subsection=\count187
\c@subsubsection=\count188
\c@paragraph=\count189
\c@subparagraph=\count190
\c@figure=\count191
\c@table=\count192
\abovecaptionskip=\skip48
\belowcaptionskip=\skip49
\bibindent=\dimen140
)
(c:/texlive/2022/texmf-dist/tex/latex/base/inputenc.sty
Package: inputenc 2021/02/14 v1.3d Input encoding file
\inpenc@prehook=\toks16
\inpenc@posthook=\toks17
)
(c:/texlive/2022/texmf-dist/tex/latex/base/fontenc.sty
Package: fontenc 2021/04/29 v2.0v Standard LaTeX package
)
(c:/texlive/2022/texmf-dist/tex/latex/microtype/microtype.sty
Package: microtype 2022/06/23 v3.0f Micro-typographical refinements (RS)
(c:/texlive/2022/texmf-dist/tex/latex/graphics/keyval.sty
Package: keyval 2022/05/29 v1.15 key=value parser (DPC)
\KV@toks@=\toks18
)
(c:/texlive/2022/texmf-dist/tex/latex/etoolbox/etoolbox.sty
Package: etoolbox 2020/10/05 v2.5k e-TeX tools for LaTeX (JAW)
\etb@tempcnta=\count193
)
\MT@toks=\toks19
\MT@tempbox=\box51
\MT@count=\count194
LaTeX Info: Redefining \noprotrusionifhmode on input line 1045.
LaTeX Info: Redefining \leftprotrusion on input line 1046.
LaTeX Info: Redefining \rightprotrusion on input line 1056.
LaTeX Info: Redefining \textls on input line 1234.
\MT@outer@kern=\dimen141
LaTeX Info: Redefining \textmicrotypecontext on input line 1858.
\MT@listname@count=\count195
(c:/texlive/2022/texmf-dist/tex/latex/microtype/microtype-pdftex.def
File: microtype-pdftex.def 2022/06/23 v3.0f Definitions specific to pdftex (RS)
LaTeX Info: Redefining \lsstyle on input line 900.
LaTeX Info: Redefining \lslig on input line 900.
\MT@outer@space=\skip50
)
Package microtype Info: Loading configuration file microtype.cfg.
(c:/texlive/2022/texmf-dist/tex/latex/microtype/microtype.cfg
File: microtype.cfg 2022/06/23 v3.0f microtype main configuration file (RS)
))
(c:/texlive/2022/texmf-dist/tex/latex/newspaper/newspaper.sty
Package: newspaper
(c:/texlive/2022/texmf-dist/tex/latex/yfonts/yfonts.sty
Package: yfonts 2019/04/04 v1.4 (WaS&NP)
Now handling font encoding LY ...
... no UTF-8 mapping file for font encoding LY
Now handling font encoding LYG ...
... no UTF-8 mapping file for font encoding LYG
LaTeX Info: Composite with undeclared \" in encoding LYG on input line 58.
)
\c@volume=\count196
\c@issue=\count197
)
(c:/texlive/2022/texmf-dist/tex/generic/babel/babel.sty
Package: babel 2022/11/30 3.83 The Babel package
\babel@savecnt=\count198
\U@D=\dimen142
\l@unhyphenated=\language87
(c:/texlive/2022/texmf-dist/tex/generic/babel/txtbabel.def)
\bbl@readstream=\read2
\bbl@dirlevel=\count199
(c:/texlive/2022/texmf-dist/tex/generic/babel-dutch/dutch.ldf
Language: dutch 2021/08/29 v3.8l Dutch support from the babel system
Package babel Info: Making " an active character on input line 127.
))
(c:/texlive/2022/texmf-dist/tex/generic/babel/locale/nl/babel-dutch.tex
Package babel Info: Importing font and identification data for dutch
(babel) from babel-nl.ini. Reported on input line 11.
)
(c:/texlive/2022/texmf-dist/tex/latex/graphics/graphicx.sty
Package: graphicx 2021/09/16 v1.2d Enhanced LaTeX Graphics (DPC,SPQR)
(c:/texlive/2022/texmf-dist/tex/latex/graphics/graphics.sty
Package: graphics 2022/03/10 v1.4e Standard LaTeX Graphics (DPC,SPQR)
(c:/texlive/2022/texmf-dist/tex/latex/graphics/trig.sty
Package: trig 2021/08/11 v1.11 sin cos tan (DPC)
)
(c:/texlive/2022/texmf-dist/tex/latex/graphics-cfg/graphics.cfg
File: graphics.cfg 2016/06/04 v1.11 sample graphics configuration
)
Package graphics Info: Driver file: pdftex.def on input line 107.
(c:/texlive/2022/texmf-dist/tex/latex/graphics-def/pdftex.def
File: pdftex.def 2022/09/22 v1.2b Graphics/color driver for pdftex
))
\Gin@req@height=\dimen143
\Gin@req@width=\dimen144
)
(c:/texlive/2022/texmf-dist/tex/latex/pgf/frontendlayer/tikz.sty
(c:/texlive/2022/texmf-dist/tex/latex/pgf/basiclayer/pgf.sty
(c:/texlive/2022/texmf-dist/tex/latex/pgf/utilities/pgfrcs.sty
(c:/texlive/2022/texmf-dist/tex/generic/pgf/utilities/pgfutil-common.tex
\pgfutil@everybye=\toks20
\pgfutil@tempdima=\dimen145
\pgfutil@tempdimb=\dimen146
(c:/texlive/2022/texmf-dist/tex/generic/pgf/utilities/pgfutil-common-lists.tex)
) (c:/texlive/2022/texmf-dist/tex/generic/pgf/utilities/pgfutil-latex.def
\pgfutil@abb=\box52
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/utilities/pgfrcs.code.tex
(c:/texlive/2022/texmf-dist/tex/generic/pgf/pgf.revision.tex)
Package: pgfrcs 2021/05/15 v3.1.9a (3.1.9a)
))
Package: pgf 2021/05/15 v3.1.9a (3.1.9a)
(c:/texlive/2022/texmf-dist/tex/latex/pgf/basiclayer/pgfcore.sty
(c:/texlive/2022/texmf-dist/tex/latex/pgf/systemlayer/pgfsys.sty
(c:/texlive/2022/texmf-dist/tex/generic/pgf/systemlayer/pgfsys.code.tex
Package: pgfsys 2021/05/15 v3.1.9a (3.1.9a)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/utilities/pgfkeys.code.tex
\pgfkeys@pathtoks=\toks21
\pgfkeys@temptoks=\toks22
(c:/texlive/2022/texmf-dist/tex/generic/pgf/utilities/pgfkeysfiltered.code.tex
\pgfkeys@tmptoks=\toks23
))
\pgf@x=\dimen147
\pgf@y=\dimen148
\pgf@xa=\dimen149
\pgf@ya=\dimen150
\pgf@xb=\dimen151
\pgf@yb=\dimen152
\pgf@xc=\dimen153
\pgf@yc=\dimen154
\pgf@xd=\dimen155
\pgf@yd=\dimen156
\w@pgf@writea=\write3
\r@pgf@reada=\read3
\c@pgf@counta=\count266
\c@pgf@countb=\count267
\c@pgf@countc=\count268
\c@pgf@countd=\count269
\t@pgf@toka=\toks24
\t@pgf@tokb=\toks25
\t@pgf@tokc=\toks26
\pgf@sys@id@count=\count270
(c:/texlive/2022/texmf-dist/tex/generic/pgf/systemlayer/pgf.cfg
File: pgf.cfg 2021/05/15 v3.1.9a (3.1.9a)
)
Driver file for pgf: pgfsys-pdftex.def
(c:/texlive/2022/texmf-dist/tex/generic/pgf/systemlayer/pgfsys-pdftex.def
File: pgfsys-pdftex.def 2021/05/15 v3.1.9a (3.1.9a)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/systemlayer/pgfsys-common-pdf.def
File: pgfsys-common-pdf.def 2021/05/15 v3.1.9a (3.1.9a)
)))
(c:/texlive/2022/texmf-dist/tex/generic/pgf/systemlayer/pgfsyssoftpath.code.tex
File: pgfsyssoftpath.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgfsyssoftpath@smallbuffer@items=\count271
\pgfsyssoftpath@bigbuffer@items=\count272
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/systemlayer/pgfsysprotocol.code.tex
File: pgfsysprotocol.code.tex 2021/05/15 v3.1.9a (3.1.9a)
)) (c:/texlive/2022/texmf-dist/tex/latex/xcolor/xcolor.sty
Package: xcolor 2022/06/12 v2.14 LaTeX color extensions (UK)
(c:/texlive/2022/texmf-dist/tex/latex/graphics-cfg/color.cfg
File: color.cfg 2016/01/02 v1.6 sample color configuration
)
Package xcolor Info: Driver file: pdftex.def on input line 227.
(c:/texlive/2022/texmf-dist/tex/latex/graphics/mathcolor.ltx)
Package xcolor Info: Model `cmy' substituted by `cmy0' on input line 1353.
Package xcolor Info: Model `hsb' substituted by `rgb' on input line 1357.
Package xcolor Info: Model `RGB' extended on input line 1369.
Package xcolor Info: Model `HTML' substituted by `rgb' on input line 1371.
Package xcolor Info: Model `Hsb' substituted by `hsb' on input line 1372.
Package xcolor Info: Model `tHsb' substituted by `hsb' on input line 1373.
Package xcolor Info: Model `HSB' substituted by `hsb' on input line 1374.
Package xcolor Info: Model `Gray' substituted by `gray' on input line 1375.
Package xcolor Info: Model `wave' substituted by `hsb' on input line 1376.
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcore.code.tex
Package: pgfcore 2021/05/15 v3.1.9a (3.1.9a)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmath.code.tex
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmathcalc.code.tex
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmathutil.code.tex)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmathparser.code.tex
\pgfmath@dimen=\dimen157
\pgfmath@count=\count273
\pgfmath@box=\box53
\pgfmath@toks=\toks27
\pgfmath@stack@operand=\toks28
\pgfmath@stack@operation=\toks29
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.code.tex
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.basic.code.te
x)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.trigonometric
.code.tex)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.random.code.t
ex)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.comparison.co
de.tex)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.base.code.tex
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.round.code.te
x)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.misc.code.tex
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmathfunctions.integerarithm
etics.code.tex)))
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmathfloat.code.tex
\c@pgfmathroundto@lastzeros=\count274
))
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfint.code.tex)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcorepoints.code.tex
File: pgfcorepoints.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgf@picminx=\dimen158
\pgf@picmaxx=\dimen159
\pgf@picminy=\dimen160
\pgf@picmaxy=\dimen161
\pgf@pathminx=\dimen162
\pgf@pathmaxx=\dimen163
\pgf@pathminy=\dimen164
\pgf@pathmaxy=\dimen165
\pgf@xx=\dimen166
\pgf@xy=\dimen167
\pgf@yx=\dimen168
\pgf@yy=\dimen169
\pgf@zx=\dimen170
\pgf@zy=\dimen171
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcorepathconstruct.cod
e.tex
File: pgfcorepathconstruct.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgf@path@lastx=\dimen172
\pgf@path@lasty=\dimen173
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcorepathusage.code.te
x
File: pgfcorepathusage.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgf@shorten@end@additional=\dimen174
\pgf@shorten@start@additional=\dimen175
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcorescopes.code.tex
File: pgfcorescopes.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgfpic=\box54
\pgf@hbox=\box55
\pgf@layerbox@main=\box56
\pgf@picture@serial@count=\count275
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcoregraphicstate.code
.tex
File: pgfcoregraphicstate.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgflinewidth=\dimen176
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcoretransformations.c
ode.tex
File: pgfcoretransformations.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgf@pt@x=\dimen177
\pgf@pt@y=\dimen178
\pgf@pt@temp=\dimen179
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcorequick.code.tex
File: pgfcorequick.code.tex 2021/05/15 v3.1.9a (3.1.9a)
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcoreobjects.code.tex
File: pgfcoreobjects.code.tex 2021/05/15 v3.1.9a (3.1.9a)
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcorepathprocessing.co
de.tex
File: pgfcorepathprocessing.code.tex 2021/05/15 v3.1.9a (3.1.9a)
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcorearrows.code.tex
File: pgfcorearrows.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgfarrowsep=\dimen180
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcoreshade.code.tex
File: pgfcoreshade.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgf@max=\dimen181
\pgf@sys@shading@range@num=\count276
\pgf@shadingcount=\count277
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcoreimage.code.tex
File: pgfcoreimage.code.tex 2021/05/15 v3.1.9a (3.1.9a)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcoreexternal.code.tex
File: pgfcoreexternal.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgfexternal@startupbox=\box57
))
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcorelayers.code.tex
File: pgfcorelayers.code.tex 2021/05/15 v3.1.9a (3.1.9a)
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcoretransparency.code
.tex
File: pgfcoretransparency.code.tex 2021/05/15 v3.1.9a (3.1.9a)
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcorepatterns.code.tex
File: pgfcorepatterns.code.tex 2021/05/15 v3.1.9a (3.1.9a)
) (c:/texlive/2022/texmf-dist/tex/generic/pgf/basiclayer/pgfcorerdf.code.tex
File: pgfcorerdf.code.tex 2021/05/15 v3.1.9a (3.1.9a)
))) (c:/texlive/2022/texmf-dist/tex/generic/pgf/modules/pgfmoduleshapes.code.te
x
File: pgfmoduleshapes.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgfnodeparttextbox=\box58
)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/modules/pgfmoduleplot.code.tex
File: pgfmoduleplot.code.tex 2021/05/15 v3.1.9a (3.1.9a)
)
(c:/texlive/2022/texmf-dist/tex/latex/pgf/compatibility/pgfcomp-version-0-65.st
y
Package: pgfcomp-version-0-65 2021/05/15 v3.1.9a (3.1.9a)
\pgf@nodesepstart=\dimen182
\pgf@nodesepend=\dimen183
)
(c:/texlive/2022/texmf-dist/tex/latex/pgf/compatibility/pgfcomp-version-1-18.st
y
Package: pgfcomp-version-1-18 2021/05/15 v3.1.9a (3.1.9a)
)) (c:/texlive/2022/texmf-dist/tex/latex/pgf/utilities/pgffor.sty
(c:/texlive/2022/texmf-dist/tex/latex/pgf/utilities/pgfkeys.sty
(c:/texlive/2022/texmf-dist/tex/generic/pgf/utilities/pgfkeys.code.tex))
(c:/texlive/2022/texmf-dist/tex/latex/pgf/math/pgfmath.sty
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmath.code.tex))
(c:/texlive/2022/texmf-dist/tex/generic/pgf/utilities/pgffor.code.tex
Package: pgffor 2021/05/15 v3.1.9a (3.1.9a)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/math/pgfmath.code.tex)
\pgffor@iter=\dimen184
\pgffor@skip=\dimen185
\pgffor@stack=\toks30
\pgffor@toks=\toks31
))
(c:/texlive/2022/texmf-dist/tex/generic/pgf/frontendlayer/tikz/tikz.code.tex
Package: tikz 2021/05/15 v3.1.9a (3.1.9a)
(c:/texlive/2022/texmf-dist/tex/generic/pgf/libraries/pgflibraryplothandlers.co
de.tex
File: pgflibraryplothandlers.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgf@plot@mark@count=\count278
\pgfplotmarksize=\dimen186
)
\tikz@lastx=\dimen187
\tikz@lasty=\dimen188
\tikz@lastxsaved=\dimen189
\tikz@lastysaved=\dimen190
\tikz@lastmovetox=\dimen191
\tikz@lastmovetoy=\dimen192
\tikzleveldistance=\dimen193
\tikzsiblingdistance=\dimen194
\tikz@figbox=\box59
\tikz@figbox@bg=\box60
\tikz@tempbox=\box61
\tikz@tempbox@bg=\box62
\tikztreelevel=\count279
\tikznumberofchildren=\count280
\tikznumberofcurrentchild=\count281
\tikz@fig@count=\count282
(c:/texlive/2022/texmf-dist/tex/generic/pgf/modules/pgfmodulematrix.code.tex
File: pgfmodulematrix.code.tex 2021/05/15 v3.1.9a (3.1.9a)
\pgfmatrixcurrentrow=\count283
\pgfmatrixcurrentcolumn=\count284
\pgf@matrix@numberofcolumns=\count285
)
\tikz@expandcount=\count286
(c:/texlive/2022/texmf-dist/tex/generic/pgf/frontendlayer/tikz/libraries/tikzli
brarytopaths.code.tex
File: tikzlibrarytopaths.code.tex 2021/05/15 v3.1.9a (3.1.9a)
)))
(c:/texlive/2022/texmf-dist/tex/latex/tools/multicol.sty
Package: multicol 2021/11/30 v1.9d multicolumn formatting (FMi)
\c@tracingmulticols=\count287
\mult@box=\box63
\multicol@leftmargin=\dimen195
\c@unbalance=\count288
\c@collectmore=\count289
\doublecol@number=\count290
\multicoltolerance=\count291
\multicolpretolerance=\count292
\full@width=\dimen196
\page@free=\dimen197
\premulticols=\dimen198
\postmulticols=\dimen199
\multicolsep=\skip51
\multicolbaselineskip=\skip52
\partial@page=\box64
\last@line=\box65
\maxbalancingoverflow=\dimen256
\mult@rightbox=\box66
\mult@grightbox=\box67
\mult@firstbox=\box68
\mult@gfirstbox=\box69
\@tempa=\box70
\@tempa=\box71
\@tempa=\box72
\@tempa=\box73
\@tempa=\box74
\@tempa=\box75
\@tempa=\box76
\@tempa=\box77
\@tempa=\box78
\@tempa=\box79
\@tempa=\box80
\@tempa=\box81
\@tempa=\box82
\@tempa=\box83
\@tempa=\box84
\@tempa=\box85
\@tempa=\box86
\@tempa=\box87
\@tempa=\box88
\@tempa=\box89
\@tempa=\box90
\@tempa=\box91
\@tempa=\box92
\@tempa=\box93
\@tempa=\box94
\@tempa=\box95
\@tempa=\box96
\@tempa=\box97
\@tempa=\box98
\@tempa=\box99
\@tempa=\box100
\@tempa=\box101
\@tempa=\box102
\@tempa=\box103
\@tempa=\box104
\@tempa=\box105
\c@minrows=\count293
\c@columnbadness=\count294
\c@finalcolumnbadness=\count295
\last@try=\dimen257
\multicolovershoot=\dimen258
\multicolundershoot=\dimen259
\mult@nat@firstbox=\box106
\colbreak@box=\box107
\mc@col@check@num=\count296
)
(c:/texlive/2022/texmf-dist/tex/latex/subfiles/subfiles.sty
Package: subfiles 2020/11/14 v2.2 Multi-file projects (package)
(c:/texlive/2022/texmf-dist/tex/latex/import/import.sty
Package: import 2020/04/01 v 6.2
))
! LaTeX Error: File `newspaper-mod.sty' not found.
Type X to quit or <RETURN> to proceed,
or enter new name. (Default extension: sty)
Enter file name:
! Emergency stop.
<read *>
l.35 ^^M
End of file on the terminal!
Here is how much of TeX's memory you used:
14643 strings out of 476093
293783 string characters out of 5795582
1848787 words of memory out of 5000000
34817 multiletter control sequences out of 15000+600000
513050 words of font info for 33 fonts, out of 8000000 for 9000
1141 hyphenation exceptions out of 8191
84i,0n,89p,415b,329s stack positions out of 10000i,1000n,20000p,200000b,200000s
! ==> Fatal error occurred, no output PDF file produced!
Binary file not shown.
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
</configuration>
Binary file not shown.
+48
View File
@@ -0,0 +1,48 @@
# ProostenKrant — Blazor versie
# De texlive-installatie zit in een eigen Docker-laag; normale builds
# herinstalleren LaTeX dus NIET. Gebruik `clean` met zorg (verwijdert ook de MySQL-data).
.PHONY: help build up down restart logs logs-app logs-mysql shell mysql clean
help:
@echo "ProostenKrant (Blazor) development commands"
@echo ""
@echo " build - Build images en start containers"
@echo " up - Start containers"
@echo " down - Stop containers"
@echo " restart - Restart containers"
@echo " logs - Vol alle logs"
@echo " logs-app - Vol app logs"
@echo " shell - Shell in de app container"
@echo " mysql - MySQL shell"
@echo " clean - Verwijder containers én volumes (mysql data gaat verloren!)"
build:
docker compose up -d --build
@echo "Applicatie draait op http://localhost:8080"
up:
docker compose up -d
@echo "Applicatie draait op http://localhost:8080"
down:
docker compose down
restart:
docker compose restart
logs:
docker compose logs -f
logs-app:
docker compose logs -f app
shell:
docker compose exec app bash
mysql:
docker compose exec mysql mysql -u proostenkrant -pproostenkrant proostenkrant
clean:
docker compose down -v
@echo "Containers en volumes verwijderd (ook mysql-data)"
-25
View File
@@ -1,25 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.4.33205.214
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Proostenkrant.NET", "Proostenkrant.NET\Proostenkrant.NET.csproj", "{F984715F-2FCA-4A9A-B8A9-020D482FD394}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{F984715F-2FCA-4A9A-B8A9-020D482FD394}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F984715F-2FCA-4A9A-B8A9-020D482FD394}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F984715F-2FCA-4A9A-B8A9-020D482FD394}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F984715F-2FCA-4A9A-B8A9-020D482FD394}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {08AC80BF-ECB6-4A5C-8241-1E5F99C8524F}
EndGlobalSection
EndGlobal
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
</configuration>
-47
View File
@@ -1,47 +0,0 @@
using System.Linq;
namespace Proostenkrant.NET
{
internal static class ArticleGenerator
{
internal static string GenerateArticle(string[][] rows)
{
var text = string.Empty;
foreach (var row in rows)
{
var writers = row[1].Split(',').Select(w => w.Trim()).ToArray();
var title = row[2];
var date = row[3];
var mainText = row[4];
var imageName = row[5];
text += GenerateHeader(writers[0], title, date);
text += GenerateBody(writers, date, mainText, imageName);
text += "\n\n";
}
return text;
}
static string GenerateHeader(string writer, string title, string date)
{
var text = string.Empty;
text += "\\headline{" + title + "}\r\n";
text += "\\byline{" + date + "}{" + writer + "}\r\n";
return text;
}
static string GenerateBody(string[] writers, string date, string mainText, string imageName)
{
var text = string.Empty;
if (writers.Length == 2)
text += "\\showTwoWriters{Images/ProfilePictures/" + writers[0] + ".jpg}{" + writers[0] + "}{Images/ProfilePictures/" + writers[1] + ".jpg}{" + writers[1] + "}\r\n";
else
text += "\\showOneWriter{Images/ProfilePictures/" + writers[0] + ".jpg}{" + writers[0] + "}\r\n";
text += "\\par " + mainText + "\r\n";
text += "\\showInlineImage{Images/Articles/" + imageName + ".jpg}{}\r\n";
return text;
}
}
}
-93
View File
@@ -1,93 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing.Printing;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
namespace Proostenkrant.NET
{
internal static class GoogleFormsReader
{
const string tsvDataUrl = "https://docs.google.com/spreadsheets/d/e/2PACX-1vRfxfdT8f4MQjXWmZylnXCGl14T0x5mxNw3W1Xd8GrZfPivjcqRGIvmb2YoAf77M00PrGPSJ6Zo-hRs/pub?output=tsv";
const string downloadURL = "https://drive.google.com/uc?export=download&id=FILEID";
public static string[][] GetData()
{
var tsvData = GetTsvData();
var data = ConvertData(tsvData);
data = FilterByTimestamp(data);
var imageNames = LoadImages(data);
return data.Select((v, i) =>
{
v[5] = imageNames[i];
return v;
}).ToArray();
}
static string GetTsvData()
{
var client = new HttpClient();
var downloadTsvTask = client.GetStringAsync(tsvDataUrl);
downloadTsvTask.Wait();
return downloadTsvTask.Result;
}
static string[][] ConvertData(string tsvData)
{
// Split rows
var tsvRows = tsvData.Split('\r').Where(v => v.Length > 0);
// Split values
var rows = tsvRows.Select(r => r.Split('\t').Where(v => v.Length > 0));
// Remove header rows
rows = rows.ToList().GetRange(2, rows.Count() - 2);
return rows.Select(r => r.ToArray()).ToArray();
}
static string[][] FilterByTimestamp(string[][] data)
{
Console.WriteLine("Filter by starting date");
var startDate = DateTime.Parse(Console.ReadLine(), CultureInfo.CreateSpecificCulture("nl-BE"));
return data.Where(row => {
var timeStamp = DateTime.Parse(row[0].Trim('\n'), CultureInfo.CreateSpecificCulture("nl-BE"));
return startDate < timeStamp;
}).ToArray();
}
static string[] LoadImages(string[][] data)
{
var names = new List<string>();
foreach (var row in data)
{
var name = Regex.Replace(row[2], string.Concat(Path.GetInvalidFileNameChars()), string.Empty, RegexOptions.Compiled);
name = name.Replace(":", "");
names.Add(name);
var filename = Program.c_articleImagesPath + $"/{name}.jpg";
if (File.Exists(filename)) continue;
var id = row[5].Replace("https://drive.google.com/open?id=", string.Empty);
var url = downloadURL.Replace("FILEID", id);
Console.WriteLine("Downloading images using the browser...");
var process = Process.Start(url);
process.WaitForExit();
Console.Write("Drag image here : ");
var path = Console.ReadLine()
.Trim('\"')
.Replace("file:///", "")
.Replace("%20", " ");
File.Copy(path, filename);
}
return names.ToArray();
}
}
}
-26
View File
@@ -1,26 +0,0 @@
using System.Diagnostics;
using System.IO;
namespace Proostenkrant.NET
{
internal static class LatexCompiler
{
internal static void Compile(string text)
{
File.WriteAllText(Program.c_articlePath, text);
GeneratePdf();
}
static void GeneratePdf()
{
var processInfo = new ProcessStartInfo("cmd.exe", "/c pdflatex " + Program.c_mainPath);
processInfo.WorkingDirectory = Program.c_latexPath;
processInfo.CreateNoWindow = true;
processInfo.UseShellExecute = true;
var process = Process.Start(processInfo);
process.WaitForExit();
}
}
}
-23
View File
@@ -1,23 +0,0 @@
using System;
namespace Proostenkrant.NET
{
public class Program
{
internal const string c_latexPath = "../Latex";
internal const string c_mainPath = c_latexPath + "/Main.tex";
internal const string c_articlePath = c_latexPath + "/Articles.tex";
internal const string c_imagesPath = c_latexPath + "/Images";
internal const string c_articleImagesPath = c_imagesPath + "/Articles";
static void Main(string[] args)
{
var rows = GoogleFormsReader.GetData();
var text = ArticleGenerator.GenerateArticle(rows);
LatexCompiler.Compile(text);
Console.WriteLine("Done!");
Console.ReadLine();
}
}
}
@@ -1,62 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{F984715F-2FCA-4A9A-B8A9-020D482FD394}</ProjectGuid>
<OutputType>Exe</OutputType>
<RootNamespace>Proostenkrant.NET</RootNamespace>
<AssemblyName>Proostenkrant.NET</AssemblyName>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>..\Build\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<DocumentationFile>
</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Drawing" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="ArticleGenerator.cs" />
<Compile Include="GoogleFormsReader.cs" />
<Compile Include="LatexCompiler.cs" />
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
</ItemGroup>
<ItemGroup>
<WCFMetadata Include="Connected Services\" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
@@ -1,36 +0,0 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("Proostenkrant.NET")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("Proostenkrant.NET")]
[assembly: AssemblyCopyright("Copyright © 2022")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("f984715f-2fca-4a9a-b8a9-020d482fd394")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
@@ -1,4 +0,0 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETFramework,Version=v4.7.2", FrameworkDisplayName = ".NET Framework 4.7.2")]
@@ -1 +0,0 @@
d148d1bffd2526b85787265a7a80fb6e24f784fe831258ab90e3a9e2c87090c4
@@ -1,7 +0,0 @@
C:\Users\douwe\Projects\Proostenkrant.NET\Build\Proostenkrant.NET.exe.config
C:\Users\douwe\Projects\Proostenkrant.NET\Build\Proostenkrant.NET.exe
C:\Users\douwe\Projects\Proostenkrant.NET\Build\Proostenkrant.NET.pdb
C:\Users\douwe\Projects\Proostenkrant.NET\Proostenkrant.NET\obj\Debug\Proostenkrant.NET.csproj.AssemblyReference.cache
C:\Users\douwe\Projects\Proostenkrant.NET\Proostenkrant.NET\obj\Debug\Proostenkrant.NET.csproj.CoreCompileInputs.cache
C:\Users\douwe\Projects\Proostenkrant.NET\Proostenkrant.NET\obj\Debug\Proostenkrant.NET.exe
C:\Users\douwe\Projects\Proostenkrant.NET\Proostenkrant.NET\obj\Debug\Proostenkrant.NET.pdb
Binary file not shown.
Binary file not shown.
@@ -1,4 +0,0 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETFramework,Version=v4.7.2", FrameworkDisplayName = ".NET Framework 4.7.2")]
+124 -2
View File
@@ -1,2 +1,124 @@
# ProostenKrant # ProostenKrant 3.0 — Blazor
A newspaper for our family
De ProostenKrant is een webkrant van en voor de familie Proosten: familieleden leveren
artikelen in via de website, de redactie publiceert ze per seizoeneditie en drukt de
krant als PDF via LaTeX.
Deze versie vervangt de PHP/MySQL-applicatie door een **Blazor Server**-applicatie
(.NET 10) met dezelfde database en hetzelfde LaTeX-krantenontwerp.
## Architectuur
| Onderdeel | Keuze |
|---------------|---------------------------------------------------|
| Framework | Blazor Web App (static SSR, geen JavaScript nodig) |
| Database | MySQL 8 (zelfde schema als voorheen, Dapper) |
| Authenticatie | Cookie-auth met universeel wachtwoord + paszin |
| PDF-generatie | LaTeX (`newspaper`-pakket) aangeroepen vanuit .NET |
| Hosting | Docker Compose: `app` (Blazor + texlive) + `mysql`|
**Waarom Blazor Server en niet WebAssembly?** De applicatie is servergebonden:
MySQL-toegang, bestandsuploads en LaTeX-compilatie gebeuren allemaal op de server.
Met WASM zou je een aparte REST-API met token-authenticatie moeten bouwen en dezelfde
werkzaamheden toch op de server doen. Server-side rendering is bovendien sneller voor
een kleine gebruikersgroep en werkt zonder JavaScript.
**Waarom LaTeX en niet een .NET PDF-bibliotheek?** .NET kan goede PDF's genereren
(bijv. met QuestPDF, gratis Community-licentie), maar echte krantenzetting —
automatische tekstverdeling over drie kolommen, microtype, woordafbreking,
kopbalancering — is waar TeX in uitblinkt. De krant blijft daarom met LaTeX gedrukt,
nu aangestuurd vanuit .NET.
## Snel starten
```bash
docker compose up -d --build
```
Regressietest (draait tegen de draaiende stack; maakt testartikelen en drukt een proefeditie):
```bash
scripts/e2e-test.sh
```
Daarna:
- Website: http://localhost:8080
- Inloggen redactie: kies je naam + universel wachtwoord (standaard `ProostenKrant2024!`,
via `UNIVERSAL_PASSWORD` in `.env`)
- Paszin voor inzendingen: hetzelfde wachtwoord
De eerste build duurt lang (de texlive-installatie is ±2 GB). Daarna cachet Docker de
LaTeX-laag: **hertesten vraagt geen herinstallatie meer**, zolang je niet
`--no-cache` gebruikt.
## Gebruik
| Route | Doel |
|------------------------------|---------------------------------------------------|
| `/` | Voorpagina (krantenstijl) |
| `/indienen` | Artikel inleveren via paszin |
| `/indienen/bewerken` | Kladartikel bewerken (alleen de schrijver) |
| `/archief` | Gedrukte edities + gepubliceerde artikelen |
| `/login` | Redactie-login |
| `/dashboard` | Overzicht, statistieken, krant drukken |
| `/artikelen`, `/artikelen/nieuw`, `/artikelen/{id}` | Artikelbeheer (redactie) |
| `/seizoenen`, `/seizoenen/{id}` | Edities drukken (redactie) |
| `/edities/{bestand}` | PDF-download |
Werkstroom: familie dient in via `/indienen` (status *klad*) → redactie publiceert
via `/artikelen/{id}` → redactie drukt de editie via `/seizoenen` of `/dashboard`.
## Projectstructuur
```
proostenkrant.net/
├── src/ProostenKrant/ # Blazor-applicatie
│ ├── Components/Pages/ # Alle pagina's (Razor, static SSR)
│ ├── Services/ # Db, ArticleService, LatexService, auth, uploads
│ └── wwwroot/ # css, fonts, EU-vlag, uploads (volume-mounted)
├── deploy/
│ ├── Dockerfile # Build + runtime met texlive (gecachete laag)
│ └── mysql/init.sql # Database-initialisatie
├── compose.yml # app + mysql stack
├── app/ # (legacy) PHP-versie — niet verwijderd, zie hieronder
└── Latex/ # (legacy) origineel krantenontwerp ter referentie
```
De artikel- en profielfoto's uit de PHP-versie worden hergebruikt via volumes:
`app/public/images/articles` en `app/public/images/profiles` worden gekoppeld aan de
uploadmappen van de Blazor-app. De gedrukte PDF's landen in `app/storage/latex`.
De Europese vlag in de voettekst is een SVG (`src/ProostenKrant/wwwroot/images/european-flag.svg`)
volgens de officiële specificaties (12 gouden sterren, cirkelradius ⅓ van de hoogte).
## Deployen op een server
1. Vereiste: Docker + Docker Compose op de server.
2. Clone de repository en zet in `.env` een **sterk wachtwoord** voordat je live gaat:
- `UNIVERSAL_PASSWORD` — inlogwachtwoord én paszin (standaard `ProostenKrant2024!`)
- `DB_PASSWORD` / `MYSQL_ROOT_PASSWORD` — database (`MYSQL_ROOT_PASSWORD` staat in `compose.yml`)
3. Start: `docker compose up -d --build` — de database wordt automatisch geïnitialiseerd
(users incl. Kevin, Luc en Tilde; seizoenen).
4. Zet eventueel een reverse proxy met HTTPS (Caddy/nginx) voor `http://server:8080`.
Overweeg de MySQL-poort (`3306`) dicht te zetten in `compose.yml` als de database
niet van buitenaf benodigd is.
5. Backups: `resources/` (repo), `app/public/images/` (foto's) en de mysql-data volume
bevatten alles; `docker compose down -v` verwijdert de data definitief.
De eerste build installeert texlive eenmalig (±2 GB, enkele minuten); daarna pakken
Docker-lagen alles en duurt een rebuild seconden.
## Configuratie
Omgevingsvariabelen (of `.env`):
- `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` — database (standaard `mysql`/`proostenkrant`)
- `UNIVERSAL_PASSWORD` — inlogwachtwoord en paszin (standaard `ProostenKrant2024!`)
## Legacy PHP-versie
De oude PHP-applicatie staat nog in `app/` en is niet verwijderd; de oude
`docker-compose`-bestanden zijn verplaatst naar `legacy/` zodat Compose automatisch
de Blazor-stack (`compose.yml`) pakt. Zodra de Blazor-versie goedgekeurd is, kunnen
`app/`, `docker/` en `legacy/` worden opgeruimd.
+26
View File
@@ -0,0 +1,26 @@
<?php
// Application configuration
return [
'app_name' => 'ProostenKrant',
'app_description' => 'Een krant van en voor Proosten',
'app_slogan' => 'Een krant van en voor Proosten',
'universal_password' => getenv('UNIVERSAL_PASSWORD') ?: 'wachtwoord',
'upload_paths' => [
'articles' => __DIR__ . '/../public/images/articles/',
'profiles' => __DIR__ . '/../public/images/profiles/',
],
'latex_paths' => [
'templates' => __DIR__ . '/../templates/latex/',
'output' => __DIR__ . '/../storage/latex/',
],
'seasons' => [
1 => ['name' => 'Lente', 'color' => '#90EE90'],
2 => ['name' => 'Zomer', 'color' => '#FFD700'],
3 => ['name' => 'Herfst', 'color' => '#FF8C00'],
4 => ['name' => 'Winter', 'color' => '#ADD8E6'],
],
];
+10
View File
@@ -0,0 +1,10 @@
<?php
// Database configuration
return [
'host' => getenv('DB_HOST') ?: 'mysql',
'database' => getenv('DB_NAME') ?: 'proostenkrant',
'username' => getenv('DB_USER') ?: 'proostenkrant',
'password' => getenv('DB_PASSWORD') ?: 'proostenkrant',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
];
+45
View File
@@ -0,0 +1,45 @@
<?php
// Route configuration
return [
'GET' => [
'/' => 'PublicController@index',
'/index.php' => 'PublicController@index',
'/archive' => 'PublicController@archive',
// Submission routes
'/submit' => 'SubmissionController@showPassphrase',
'/submit/passphrase' => 'SubmissionController@showPassphrase',
'/submit/form' => 'SubmissionController@showForm',
'/submit/confirmation' => 'SubmissionController@showConfirmation',
'/submit/edit' => 'SubmissionController@showEditPassphrase',
'/submit/edit/list' => 'SubmissionController@showEditList',
'/submit/edit/(\d+)' => 'SubmissionController@showWriterVerification',
// Legacy routes (for logged in users)
'/login' => 'AuthController@showLogin',
'/logout' => 'AuthController@logout',
'/dashboard' => 'DashboardController@index',
'/articles' => 'ArticleController@index',
'/articles/new' => 'ArticleController@new',
'/articles/create' => 'ArticleController@create',
'/articles/(\d+)' => 'ArticleController@show',
'/articles/(\d+)/verify-writer' => 'ArticleController@showVerifyWriter',
'/articles/(\d+)/verify-passphrase' => 'ArticleController@showVerifyPassphrase',
'/seasons' => 'SeasonController@index',
'/generate-pdf' => 'PdfController@generate',
],
'POST' => [
// Submission routes
'/submit/verify' => 'SubmissionController@verifyPassphrase',
'/submit/store' => 'SubmissionController@store',
'/submit/edit/verify' => 'SubmissionController@verifyEditPassphrase',
'/submit/edit/verify-writer' => 'SubmissionController@verifyWriter',
// Legacy routes
'/login' => 'AuthController@login',
'/articles' => 'ArticleController@store',
'/upload-image' => 'UploadController@upload',
'/articles/verify-writer' => 'ArticleController@verifyWriter',
'/articles/verify-passphrase' => 'ArticleController@verifyPassphrase',
],
];
+19
View File
@@ -0,0 +1,19 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
# Redirect all requests to index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]
</IfModule>
# Allow .htaccess overrides
AllowOverride All
# PHP settings
<IfModule mod_php.c>
php_value upload_max_filesize 50M
php_value post_max_size 52M
php_value max_execution_time 300
</IfModule>
+1291
View File
File diff suppressed because it is too large Load Diff
+582
View File
@@ -0,0 +1,582 @@
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
}
/* Container */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* Header */
.header {
background: linear-gradient(135deg, #8B4513, #A0522D);
color: white;
padding: 20px 0;
margin-bottom: 30px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header-content {
text-align: center;
margin-bottom: 20px;
}
.logo {
font-size: 2.5rem;
font-weight: bold;
margin-bottom: 5px;
}
.logo a {
color: white;
text-decoration: none;
}
.logo a:hover {
text-decoration: underline;
}
.slogan {
font-style: italic;
font-size: 1.2rem;
margin-bottom: 5px;
}
.location {
font-size: 1rem;
opacity: 0.9;
}
/* Navigation */
.nav {
display: flex;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
padding-top: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.nav a {
color: white;
text-decoration: none;
padding: 10px 15px;
border-radius: 5px;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.nav a.btn {
background-color: #4CAF50;
}
.nav a.btn:hover {
background-color: #45a049;
}
.nav a.btn-logout {
background-color: #f44336;
}
.nav a.btn-logout:hover {
background-color: #d32f2f;
}
/* Main Content */
.main {
padding: 20px 0;
min-height: calc(100vh - 200px);
}
/* Footer */
.footer {
background-color: #8B4513;
color: white;
text-align: center;
padding: 20px 0;
margin-top: 30px;
}
/* Alerts */
.alert {
padding: 15px 20px;
margin-bottom: 20px;
border-radius: 5px;
display: flex;
justify-content: space-between;
align-items: center;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert .close {
cursor: pointer;
font-weight: bold;
}
/* Login Page */
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 70vh;
}
.login-card {
background-color: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 500px;
}
.login-card h2 {
text-align: center;
color: #8B4513;
margin-bottom: 20px;
}
.login-card p {
text-align: center;
color: #666;
margin-bottom: 30px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
/* Form Styles */
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.form-control {
padding: 12px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 1rem;
transition: border-color 0.3s;
}
.form-control:focus {
border-color: #8B4513;
outline: none;
}
.form-control.error {
border-color: #f44336;
}
.error-message {
color: #f44336;
font-size: 0.9rem;
margin-top: 5px;
}
label {
font-weight: 500;
color: #333;
}
/* Buttons */
.btn {
display: inline-block;
padding: 12px 24px;
background-color: #8B4513;
color: white;
text-decoration: none;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.3s;
}
.btn:hover {
background-color: #A0522D;
}
.btn-primary {
background-color: #4CAF50;
}
.btn-primary:hover {
background-color: #45a049;
}
.btn-secondary {
background-color: #607d8b;
}
.btn-secondary:hover {
background-color: #455a64;
}
.btn-small {
padding: 8px 12px;
font-size: 0.9rem;
background-color: #607d8b;
}
.btn-small:hover {
background-color: #455a64;
}
/* Tables */
.articles-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.articles-table th,
.articles-table td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.articles-table th {
background-color: #f5f5f5;
font-weight: 600;
}
.articles-table tr:hover {
background-color: #f9f9f9;
}
/* Status Badges */
.status-badge {
padding: 5px 10px;
border-radius: 15px;
font-size: 0.85rem;
font-weight: 500;
}
.status-badge.published {
background-color: #d4edda;
color: #155724;
}
.status-badge.draft {
background-color: #fff3cd;
color: #856404;
}
.status-badge.archived {
background-color: #f8d7da;
color: #721c24;
}
/* Cards */
.article-card,
.season-card,
.dashboard-card {
background-color: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.article-card h4,
.season-card h4,
.dashboard-card h3 {
color: #8B4513;
margin-bottom: 10px;
}
.article-card .article-meta {
color: #666;
font-size: 0.9rem;
margin-bottom: 10px;
}
.article-card p {
color: #444;
line-height: 1.5;
}
.article-image {
width: 100%;
max-height: 200px;
object-fit: cover;
border-radius: 5px;
margin-top: 15px;
}
/* Grids */
.articles-grid,
.seasons-grid,
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin: 20px 0;
}
.dashboard-grid {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
/* Season Banner */
.season-banner,
.season-info {
padding: 20px;
border-radius: 10px;
margin: 20px 0;
color: #333;
}
.season-banner h3,
.season-info h3 {
color: #333;
}
/* Form Layout */
.form-row {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.form-row .form-group {
flex: 1;
}
.form-row .full-width {
flex: none;
width: 100%;
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 15px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 8px 12px;
background-color: #f5f5f5;
border-radius: 5px;
transition: background-color 0.3s;
}
.checkbox-label:hover {
background-color: #e0e0e0;
}
.checkbox-label input[type="checkbox"] {
cursor: pointer;
}
.form-actions {
display: flex;
gap: 15px;
margin-top: 30px;
}
/* Article Detail */
.article-detail {
background-color: white;
border-radius: 10px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.article-detail h2 {
color: #8B4513;
margin-bottom: 20px;
}
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.meta-item {
display: flex;
flex-direction: column;
}
.meta-item strong {
color: #8B4513;
}
.article-image-container {
margin: 20px 0;
text-align: center;
}
.article-image {
max-width: 100%;
max-height: 500px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.article-content {
line-height: 1.8;
color: #444;
}
.article-actions {
margin-top: 30px;
display: flex;
gap: 15px;
}
/* Quick Links */
.quick-links {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin-top: 20px;
}
.quick-links a {
display: inline-block;
padding: 10px 20px;
background-color: white;
color: #8B4513;
text-decoration: none;
border: 1px solid #ddd;
border-radius: 5px;
transition: all 0.3s;
}
.quick-links a:hover {
background-color: #8B4513;
color: white;
}
/* Season Detail */
.season-detail {
background-color: white;
border-radius: 10px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.season-detail h2 {
color: #8B4513;
margin-bottom: 20px;
}
.season-info {
background-color: #f9f9f9;
padding: 15px;
border-radius: 5px;
margin-bottom: 30px;
}
.season-actions {
margin-top: 20px;
}
.back-link {
margin-top: 30px;
}
.back-link a {
color: #8B4513;
text-decoration: none;
}
.back-link a:hover {
text-decoration: underline;
}
/* Responsive */
@media (max-width: 768px) {
.header-content {
text-align: left;
}
.logo {
font-size: 2rem;
}
.nav {
flex-direction: column;
align-items: center;
}
.form-row {
flex-direction: column;
gap: 0;
}
.articles-grid,
.seasons-grid,
.dashboard-grid {
grid-template-columns: 1fr;
}
.article-meta {
flex-direction: column;
gap: 10px;
}
.form-actions {
flex-direction: column;
}
}
/* Print Styles */
@media print {
.nav,
.header-content {
display: none;
}
.article-card,
.season-card,
.dashboard-card {
box-shadow: none;
page-break-inside: avoid;
}
}
View File
View File
+114
View File
@@ -0,0 +1,114 @@
<?php
// Error reporting
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
// Start session
session_start();
// Set timezone
ini_set('date.timezone', 'Europe/Brussels');
// Autoload classes
spl_autoload_register(function ($class) {
$prefix = 'App\\';
$baseDir = __DIR__ . '/../src/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return;
}
$relativeClass = substr($class, $len);
// Convert namespace separators to directory separators
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
// Convert to lowercase for directory names
$parts = explode('/', $file);
$newParts = [];
foreach ($parts as $part) {
if (strpos($part, '.php') === false) {
$newParts[] = strtolower($part);
} else {
$newParts[] = $part;
}
}
$file = implode('/', $newParts);
if (file_exists($file)) {
require $file;
}
});
// Load configuration
$config = include __DIR__ . '/../config/app.php';
// Database configuration
$databaseConfig = include __DIR__ . '/../config/database.php';
// Set database environment variables for Docker
if (!getenv('DB_HOST')) {
putenv('DB_HOST=mysql');
}
if (!getenv('DB_NAME')) {
putenv('DB_NAME=proostenkrant');
}
if (!getenv('DB_USER')) {
putenv('DB_USER=proostenkrant');
}
if (!getenv('DB_PASSWORD')) {
putenv('DB_PASSWORD=proostenkrant');
}
// Load routes
$routes = include __DIR__ . '/../config/routes.php';
// Get current request
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Route matching
$matched = false;
foreach ($routes[$method] ?? [] as $pattern => $handler) {
// Convert route pattern to regex
// First, replace route parameter patterns before quoting
$patternForRegex = preg_replace('/\(\d+\)/', '(\d+)', $pattern);
$patternForRegex = preg_replace('/\(\w+\)/', '([^\/]+)', $patternForRegex);
$regex = '#^' . $patternForRegex . '$#';
if (preg_match($regex, $uri, $matches)) {
// Remove first match (full match)
array_shift($matches);
// Parse handler
list($controllerName, $actionName) = explode('@', $handler);
// Instantiate controller
$controllerClass = 'App\\Controllers\\' . $controllerName;
if (class_exists($controllerClass)) {
$controller = new $controllerClass();
// Call action with parameters
if (method_exists($controller, $actionName)) {
call_user_func_array([$controller, $actionName], $matches);
$matched = true;
break;
}
}
}
}
// Handle 404
if (!$matched) {
http_response_code(404);
// Show a simple 404 page
echo '<!DOCTYPE html>';
echo '<html lang="nl">';
echo '<head><meta charset="UTF-8"><title>404 - Pagina niet gevonden</title></head>';
echo '<body style="text-align: center; padding: 50px;">';
echo '<h1>404 - Pagina niet gevonden</h1>';
echo '<p><a href="/">Ga terug naar de homepagina</a></p>';
echo '</body></html>';
}
+97
View File
@@ -0,0 +1,97 @@
// ProostenKrant JavaScript
document.addEventListener('DOMContentLoaded', function() {
// Auto-hide alerts after 5 seconds
const alerts = document.querySelectorAll('.alert');
alerts.forEach(alert => {
setTimeout(() => {
alert.style.opacity = '0';
setTimeout(() => {
alert.remove();
}, 500);
}, 5000);
});
// Form validation feedback
const formGroups = document.querySelectorAll('.form-group');
formGroups.forEach(group => {
const input = group.querySelector('.form-control');
const errorMessage = group.querySelector('.error-message');
if (input && errorMessage && errorMessage.textContent.trim() !== '') {
input.addEventListener('focus', function() {
input.classList.remove('error');
errorMessage.style.display = 'none';
});
}
});
// Auto-focus first input on login page
if (document.querySelector('.login-form')) {
const firstInput = document.querySelector('.login-form .form-control');
if (firstInput) {
firstInput.focus();
}
}
// Smooth scrolling for anchor links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function(e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// Image preview for article form
const imageInputs = [document.getElementById('article_image'), document.getElementById('image')];
imageInputs.forEach(input => {
if (input) {
const form = input.closest('form');
const previewContainer = document.createElement('div');
previewContainer.className = 'image-preview';
previewContainer.style.display = 'none';
previewContainer.style.marginTop = '15px';
input.parentNode.parentNode.appendChild(previewContainer);
input.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = function(e) {
previewContainer.innerHTML = '<img src="' + e.target.result + '" style="max-width: 200px; max-height: 200px; border-radius: 5px;" />';
previewContainer.style.display = 'block';
};
reader.readAsDataURL(file);
} else {
previewContainer.style.display = 'none';
}
});
}
});
// Confirm before logout
const logoutLinks = document.querySelectorAll('.btn-logout, a[href="/logout"]');
logoutLinks.forEach(link => {
link.addEventListener('click', function(e) {
if (!confirm('Weet u zeker dat u wilt uitloggen?')) {
e.preventDefault();
}
});
});
// Season color indicator
const seasonCards = document.querySelectorAll('.season-card');
seasonCards.forEach(card => {
const bgColor = card.style.backgroundColor;
if (bgColor) {
card.style.borderLeft = '5px solid ' + bgColor;
}
});
});
+1
View File
@@ -0,0 +1 @@
/var/www/html/storage/latex
+192
View File
@@ -0,0 +1,192 @@
<?php
namespace App\Controllers;
use App\Models\Article;
use App\Models\Season;
use App\Models\User;
class ArticleController extends Controller {
public function index() {
$this->requireAuth();
$articles = Article::all();
$this->view('articles/index', ['articles' => $articles]);
}
public function new() {
$this->requireAuth();
$seasons = Season::all();
$users = User::all();
$this->view('articles/new', [
'seasons' => $seasons,
'users' => $users
]);
}
public function create() {
// This will handle the form display for creating a new article
$this->new();
}
public function store() {
$this->requireAuth();
$validationRules = [
'title' => 'required|min:5|max:255',
'content' => 'required|min:10',
'author_id' => 'required',
'event_date' => 'required|date',
'season_id' => 'required',
];
$errors = $this->validate($_POST, $validationRules);
if (!empty($errors)) {
$_SESSION['errors'] = $errors;
$_SESSION['old_input'] = $_POST;
$this->redirect('/articles/new');
}
// Process participants
$participants = $_POST['participants'] ?? [];
if (empty($participants)) {
$participants = [$_POST['author_id']];
}
$articleImage = null;
if (isset($_FILES['article_image']) && $_FILES['article_image']['error'] === UPLOAD_ERR_OK) {
$uploadDir = $this->config['upload_paths']['articles'];
$filename = uniqid() . '_' . basename($_FILES['article_image']['name']);
$targetPath = $uploadDir . $filename;
if (move_uploaded_file($_FILES['article_image']['tmp_name'], $targetPath)) {
$articleImage = 'images/articles/' . $filename;
}
}
$articleData = [
'title' => $_POST['title'],
'content' => $_POST['content'],
'author_id' => $_POST['author_id'],
'participants' => implode(',', $participants),
'event_date' => $_POST['event_date'],
'season_id' => $_POST['season_id'],
'article_image' => $articleImage,
'status' => 'published',
];
$articleId = Article::create($articleData);
$_SESSION['success'] = 'Artikel succesvol toegevoegd!';
$this->redirect('/articles');
}
public function show($id) {
$article = Article::find($id);
if (!$article) {
$this->redirect('/articles');
}
$participantIds = explode(',', $article['participants']);
$participants = User::getByIds($participantIds);
$this->view('articles/show', [
'article' => $article,
'participants' => $participants
]);
}
public function showVerifyWriter($id) {
$this->requireAuth();
$article = Article::find($id);
if (!$article) {
$_SESSION['error'] = 'Artikel niet gevonden.';
$this->redirect('/articles');
}
$users = User::all();
$this->view('articles/verify_writer', [
'article' => $article,
'users' => $users
]);
}
public function showVerifyPassphrase($id) {
$this->requireAuth();
$article = Article::find($id);
if (!$article) {
$_SESSION['error'] = 'Artikel niet gevonden.';
$this->redirect('/articles');
}
// Ensure edit_article_id is set in session for the passphrase verification
if (!isset($_SESSION['edit_article_id']) || $_SESSION['edit_article_id'] != $id) {
$_SESSION['edit_article_id'] = $id;
}
$this->view('articles/verify_passphrase', [
'article' => $article
]);
}
public function verifyWriter() {
$this->requireAuth();
$articleId = $_POST['article_id'] ?? null;
$writerId = $_POST['writer_id'] ?? null;
if (!$articleId || !$writerId) {
$_SESSION['error'] = 'Ongeldige verificatie.';
$this->redirect('/articles');
}
$article = Article::find($articleId);
if (!$article) {
$_SESSION['error'] = 'Artikel niet gevonden.';
$this->redirect('/articles');
}
if ($article['author_id'] != $writerId) {
$_SESSION['error'] = 'Enkel de schrijver van het artikel kan deze bewerken.';
$this->redirect('/articles');
}
$this->redirect('/articles/' . $articleId . '/verify-passphrase');
}
public function verifyPassphrase() {
$this->requireAuth();
$articleId = $_SESSION['edit_article_id'] ?? null;
$passphrase = $_POST['passphrase'] ?? '';
$universalPassword = $this->config['universal_password'] ?? 'ProostenKrant2024!';
if (!$articleId) {
$_SESSION['error'] = 'Geen artikel geselecteerd voor bewerken.';
$this->redirect('/articles');
}
$article = Article::find($articleId);
if (!$article) {
$_SESSION['error'] = 'Artikel niet gevonden.';
$this->redirect('/articles');
}
if ($passphrase !== $universalPassword) {
$_SESSION['error'] = 'Onjuiste paszin. Probeer het opnieuw.';
$this->redirect('/articles/' . $articleId . '/verify-passphrase');
}
// Redirect to submission form with article data
$this->redirect('/submit/form');
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Controllers;
use App\Models\User;
use App\Models\Setting;
class AuthController extends Controller {
public function showLogin() {
if ($this->isLoggedIn()) {
$this->redirect('/dashboard');
}
$this->view('login', ['page_title' => 'Inloggen']);
}
public function login() {
$password = $_POST['password'] ?? '';
$username = $_POST['username'] ?? '';
$universalPassword = Setting::get('universal_password', $this->config['universal_password']);
$user = User::findByUsername($username);
if ($user && $password === $universalPassword) {
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['display_name'] = $user['display_name'];
$_SESSION['is_logged_in'] = true;
$redirect = $_SESSION['redirect_after_login'] ?? '/dashboard';
unset($_SESSION['redirect_after_login']);
$this->redirect($redirect);
} else {
$_SESSION['error'] = 'Ongeldige gebruikersnaam of wachtwoord';
$this->redirect('/login');
}
}
public function logout() {
session_unset();
session_destroy();
$this->redirect('/login');
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace App\Controllers;
class Controller {
protected $config;
protected $data = [];
public function __construct() {
$this->config = include __DIR__ . '/../../config/app.php';
}
protected function view($viewName, $data = []) {
$this->data = array_merge($this->data, $data);
// Try different view directories
$viewPaths = [
__DIR__ . '/../views/' . $viewName . '.php',
__DIR__ . '/../views/pages/' . $viewName . '.php',
__DIR__ . '/../views/public/' . $viewName . '.php',
__DIR__ . '/../views/submission/' . $viewName . '.php',
__DIR__ . '/../views/articles/' . $viewName . '.php',
__DIR__ . '/../views/seasons/' . $viewName . '.php',
];
$viewPath = null;
foreach ($viewPaths as $path) {
if (file_exists($path)) {
$viewPath = $path;
break;
}
}
if (!$viewPath) {
die("View not found: " . $viewName);
}
// Make config available to views
$this->data['config'] = $this->config;
extract($this->data);
require $viewPath;
}
protected function redirect($url) {
header("Location: {$url}");
exit();
}
protected function validate($data, $rules) {
$errors = [];
foreach ($rules as $field => $ruleString) {
$rulesList = explode('|', $ruleString);
foreach ($rulesList as $rule) {
$ruleParts = explode(':', $rule);
$ruleName = $ruleParts[0];
$ruleParam = $ruleParts[1] ?? null;
if ($ruleName === 'required' && empty($data[$field])) {
$errors[$field] = "Het veld {$field} is verplicht";
}
if ($ruleName === 'min' && isset($data[$field]) && strlen($data[$field]) < $ruleParam) {
$errors[$field] = "Het veld {$field} moet minstens {$ruleParam} karakters lang zijn";
}
if ($ruleName === 'max' && isset($data[$field]) && strlen($data[$field]) > $ruleParam) {
$errors[$field] = "Het veld {$field} mag maximaal {$ruleParam} karakters lang zijn";
}
if ($ruleName === 'date' && isset($data[$field]) && !$this->isValidDate($data[$field])) {
$errors[$field] = "Het veld {$field} moet een geldige datum zijn";
}
}
}
return $errors;
}
protected function isValidDate($date) {
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date);
}
protected function isLoggedIn() {
return isset($_SESSION['user_id']);
}
protected function requireAuth() {
if (!$this->isLoggedIn()) {
$_SESSION['redirect_after_login'] = $_SERVER['REQUEST_URI'];
$this->redirect('/login');
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Controllers;
use App\Models\Article;
use App\Models\Season;
use App\Models\User;
class DashboardController extends Controller {
public function index() {
$this->requireAuth();
$currentSeason = Season::getCurrent();
$userId = $_SESSION['user_id'];
// Get articles by current user
$userArticles = Article::all();
// Get all users
$users = User::all();
$this->view('dashboard', [
'currentSeason' => $currentSeason,
'userArticles' => $userArticles,
'users' => $users
]);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Controllers;
use App\Models\Article;
use App\Models\Season;
class HomeController extends Controller {
public function index() {
// Always show the public home page now
$this->redirect('/');
}
public function test() {
$this->view('test', ['page_title' => 'Test']);
}
}
+285
View File
@@ -0,0 +1,285 @@
<?php
namespace App\Controllers;
use App\Models\Article;
use App\Models\Season;
use App\Models\User;
class PdfController extends Controller {
public function generate() {
$this->requireAuth();
$seasonId = $_GET['season_id'] ?? null;
if ($seasonId) {
$articles = Article::getBySeason($seasonId);
$season = Season::find($seasonId);
} else {
$articles = Article::all('published');
$season = Season::getCurrent();
}
if (empty($articles)) {
$_SESSION['error'] = 'Geen artikelen gevonden voor deze editie';
$this->redirect('/articles');
}
// Generate LaTeX
$latexContent = $this->generateLatex($articles, $season);
// Save LaTeX file
$latexDir = $this->config['latex_paths']['output'];
if (!is_dir($latexDir)) {
mkdir($latexDir, 0755, true);
}
$latexFilename = $latexDir . 'ProostenKrant_' . ($season ? $season['name'] : 'Algemeen') . '_' . date('Y-m-d') . '.tex';
file_put_contents($latexFilename, $latexContent);
// Generate PDF using LaTeX compiler
$this->compileLatex($latexFilename);
$_SESSION['success'] = 'PDF succesvol gegenereerd!';
$this->redirect('/articles');
}
private function generateLatex($articles, $season = null) {
$config = $this->config;
$latex = "\\documentclass{article}\\
";
$latex .= "\\usepackage[utf8]{inputenc}\\
";
$latex .= "\\usepackage[T1]{fontenc}\\
";
$latex .= "\\usepackage{microtype}\\
";
$latex .= "\\usepackage[dutch]{babel}\\
";
$latex .= "\\usepackage{newspaper}\\
";
$latex .= "\\usepackage{graphicx}\\
";
$latex .= "\\usepackage{tikz}\\
";
$latex .= "\\usepackage{multicol}\\
";
$latex .= "\\usepackage{geometry}\\
";
$latex .= "\\usepackage{array}\\
";
$latex .= "\\usepackage{wrapfig}\\
";
$latex .= "\\usepackage{lipsum}\\
";
$latex .= "\\usepackage{newtxtext,newtxmath}\\
";
$latex .= "\\usepackage{etoolbox}\\
";
// Custom styling from original project
$latex .= "\\newcommand{\\headlinestyle}{\\itshape\\huge}\\
";
$latex .= "\\newcommand{\\bylinestyle}{\\scshape\\Large}\\
";
$latex .= "\\patchcmd{\\headline}{#1}{\\headlinestyle #1}{}{}\\
";
$latex .= "\\patchcmd{\\byline}{#1}{\\bylinestyle #1}{}{}\\
";
// Improved image handling commands
$latex .= $this->getImageCommands();
// Document metadata
$latex .= "\\\\date{\\today}\\
";
$latex .= "\\currentvolume{1}\\
";
$latex .= "\\currentissue{1}\\
";
$latex .= "\\SetPaperName{Proo{s:}tenKrant}\\
";
$latex .= "\\SetHeaderName{ProostenKrant}\\
";
$latex .= "\\SetPaperLocation{$config['app_location']}\\
";
$latex .= "\\SetPaperSlogan{{$config['app_slogan']}}\\
";
$latex .= "\\SetPaperPrice{$config['app_price']}\\
";
$latex .= "\\\\begin{document}\\
";
$latex .= "\\maketitle\\\\\\
";
// Determine season name
$seasonName = $season ? $season['name'] : 'Algemeen';
$latex .= "\\\\noindent\\textbf{\\LARGE Editie: {$seasonName}}\\
";
$latex .= "\\\\\\
";
$latex .= "\\\\begin{multicols}{3}\\
";
// Add articles
foreach ($articles as $article) {
$latex .= $this->articleToLatex($article);
}
$latex .= "\\\\end{multicols}\\
";
$latex .= "\\end{document}";
return $latex;
}
private function getImageCommands() {
return
"\\newcommand{\\showInlineImage}[2]\n" .
"{\n" .
" \\begin{center}\n" .
" \\begin{minipage}{0.9\\columnwidth}\n" .
" \\centering\n" .
" \\framebox{\n" .
" \\resizebox{\\linewidth}{!}{\\\\includegraphics{#1}}\n" .
" }\\\\\n" .
" \\small #2\\\n" .
" \\end{minipage}\n" .
" \\end{center}\n" .
"}\n\n" .
"\\newcommand{\\showPortraitImage}[2]\n" .
"{\n" .
" \\begin{minipage}[t]{0.45\\columnwidth}\n" .
" \\centering\n" .
" \\begin{tikzpicture}\n" .
" \\clip (0,0) circle (1.5cm);\n" .
" \\node[anchor=center] at (0,0) {\\\\includegraphics[width=3cm, height=3cm, keepaspectratio]{#1}};\n" .
" \\end{tikzpicture}\n" .
" \\smallskip\\\n" .
" #2\\\n" .
" \\end{minipage}\n" .
"}\n\n" .
"\\newcommand{\\showLandscapeImage}[2]\n" .
"{\n" .
" \\begin{minipage}[t]{\\columnwidth}\n" .
" \\centering\n" .
" \\includegraphics[width=\\linewidth, keepaspectratio]{#1}\\par\n" .
" \\small #2\\\n" .
" \\end{minipage}\n" .
"}\n\n" .
"\\newcommand{\\showOneWriter}[2]\n" .
"{\n" .
" \\hfill\n" .
" \\begin{minipage}[t]{0.4\\columnwidth}\n" .
" \\centering\n" .
" \\begin{tikzpicture}\n" .
" \\clip (0,0) circle (1.2cm);\n" .
" \\node[anchor=center] at (0,0) {\\\\includegraphics[width=2.5cm, height=2.5cm, keepaspectratio]{#1}};\n" .
" \\end{tikzpicture}\n" .
" \\\n" .
" \\small #2\\\n" .
" \\end{minipage}\n" .
" \\hfill\n" .
"}\n\n" .
"\\newcommand{\\showTwoWriters}[4]\n" .
"{\n" .
" \\begin{center}\n" .
" \\begin{minipage}[t]{0.8\\columnwidth}\n" .
" \\centering\n" .
" \\begin{tikzpicture}\n" .
" \\clip (0,0) circle (1cm);\n" .
" \\node[anchor=west] at (0,0) {\\\\includegraphics[width=2cm, height=2cm, keepaspectratio]{#1}};\n" .
" \\end{tikzpicture}\n" .
" \\hspace{0.5cm}\n" .
" \\begin{tikzpicture}\n" .
" \\clip (0,0) circle (1cm);\n" .
" \\node[anchor=east] at (0,0) {\\\\includegraphics[width=2cm, height=2cm, keepaspectratio]{#3}};\n" .
" \\end{tikzpicture}\n" .
" \\\n" .
" \\small #2 \\quad #4\\\n" .
" \\end{minipage}\n" .
" \\end{center}\n" .
"}\n\n";
}
private function articleToLatex($article) {
$latex = "\\headline{{$article['title']}}\\
";
$latex .= "\\byline{{$article['event_date']}}{{$article['author_name']}}\\
";
// Get base public path from config
$uploadPaths = $this->config['upload_paths'] ?? [];
$profilesPath = $uploadPaths['profiles'] ?? __DIR__ . '/../../public/images/profiles/';
$articlesPath = $uploadPaths['articles'] ?? __DIR__ . '/../../public/images/articles/';
$publicPath = dirname(dirname($profilesPath)) . '/';
// Get participants
$participantIds = explode(',', $article['participants']);
$participants = User::getByIds($participantIds);
if (count($participants) === 1) {
$profileImage = $participants[0]['profile_image'] ? $publicPath . $participants[0]['profile_image'] : $profilesPath . 'Unknown.jpg';
$latex .= "\\showOneWriter{{$profileImage}}{{$participants[0]['display_name']}}\\
";
} elseif (count($participants) === 2) {
$profileImage1 = $participants[0]['profile_image'] ? $publicPath . $participants[0]['profile_image'] : $profilesPath . 'Unknown.jpg';
$profileImage2 = $participants[1]['profile_image'] ? $publicPath . $participants[1]['profile_image'] : $profilesPath . 'Unknown.jpg';
$latex .= "\\showTwoWriters{{$profileImage1}}{{$participants[0]['display_name']}}{{$profileImage2}}{{$participants[1]['display_name']}}\\
";
}
// Content
$content = $article['content'];
// Escape special LaTeX characters
$content = str_replace(['#', '$', '%', '&', '_', '{', '}', '\\', '~', '^'],
['\\#', '\\$', '\\%', '\\&', '\\_', '\\{', '\\}', '\\\\', '\\~', '\\^'],
$content);
$latex .= "\\par {$content}\\par\n\\\n";
// Article image
if ($article['article_image']) {
$imagePath = $publicPath . $article['article_image'];
// Check if image exists
if (file_exists($imagePath)) {
$latex .= "\\showInlineImage{{$imagePath}}{}\\
";
}
}
$latex .= "\\\n\\\n";
return $latex;
}
private function compileLatex($latexFilename) {
$outputDir = pathinfo($latexFilename, PATHINFO_DIRNAME);
$baseName = pathinfo($latexFilename, PATHINFO_FILENAME);
// Run pdflatex twice to resolve references
$commands = [
"cd {$outputDir} && pdflatex -interaction=nonstopmode {$latexFilename}",
"cd {$outputDir} && pdflatex -interaction=nonstopmode {$latexFilename}",
];
foreach ($commands as $command) {
shell_exec($command);
}
// Clean up temporary files
$tempFiles = [$baseName . '.aux', $baseName . '.log', $baseName . '.out'];
foreach ($tempFiles as $tempFile) {
$tempPath = $outputDir . '/' . $tempFile;
if (file_exists($tempPath)) {
unlink($tempPath);
}
}
}
}
+141
View File
@@ -0,0 +1,141 @@
<?php
namespace App\Controllers;
use App\Models\Article;
use App\Models\Season;
use App\Models\User;
class PublicController extends Controller {
public function index() {
$config = $this->config;
// Get current season
$currentSeason = Season::getCurrent();
// Get recent articles (draft status for WIP)
$draftArticles = Article::all('draft');
// Get published articles for archive preview
$publishedArticles = Article::all('published');
// Scan for PDF files in multiple directories
$pdfFiles = [];
// Check storage/latex directory
$pdfDirs = [
__DIR__ . '/../../storage/latex/',
__DIR__ . '/../../../Latex/',
__DIR__ . '/../../../Build/',
__DIR__ . '/../../../Proostenkrant.NET/',
];
foreach ($pdfDirs as $pdfDir) {
if (is_dir($pdfDir)) {
$files = scandir($pdfDir);
foreach ($files as $file) {
if (pathinfo($file, PATHINFO_EXTENSION) === 'pdf') {
$filepath = $pdfDir . $file;
$pdfFiles[] = [
'filename' => $file,
'path' => $this->getPdfPath($pdfDir, $file),
'date' => date('Y-m-d', filemtime($filepath)),
'fullpath' => $filepath
];
}
}
}
}
// Sort by date descending
usort($pdfFiles, function($a, $b) {
return strtotime($b['date']) - strtotime($a['date']);
});
$users = User::all();
$usersById = [];
foreach ($users as $user) {
$usersById[$user['id']] = $user['display_name'];
}
$this->view('public/home', [
'page_title' => 'Home',
'show_nav' => true,
'config' => $config,
'currentSeason' => $currentSeason,
'draftArticles' => $draftArticles,
'publishedArticles' => $publishedArticles,
'pdfFiles' => $pdfFiles,
'usersById' => $usersById
]);
}
private function getPdfPath($directory, $filename) {
// Map directory to public URL
$baseDir = __DIR__ . '/../../';
if (strpos($directory, '/storage/latex') !== false) {
return '/storage/latex/' . $filename;
} elseif (strpos($directory, '/Latex/') !== false) {
return '/Latex/' . $filename;
} elseif (strpos($directory, '/Build/') !== false) {
return '/Build/' . $filename;
} elseif (strpos($directory, '/Proostenkrant.NET/') !== false) {
return '/Proostenkrant.NET/' . $filename;
}
return '/' . basename($directory) . '/' . $filename;
}
public function archive() {
$pdfFiles = [];
// Check multiple directories for PDFs
$pdfDirs = [
__DIR__ . '/../../storage/latex/',
__DIR__ . '/../../../Latex/',
__DIR__ . '/../../../Build/',
__DIR__ . '/../../../Proostenkrant.NET/',
];
foreach ($pdfDirs as $pdfDir) {
if (is_dir($pdfDir)) {
$files = scandir($pdfDir);
foreach ($files as $file) {
if (pathinfo($file, PATHINFO_EXTENSION) === 'pdf') {
$filepath = $pdfDir . $file;
// Try to extract date and edition from filename
$basename = pathinfo($file, PATHINFO_FILENAME);
$date = date('Y-m-d', filemtime($filepath));
// Check if filename has a date pattern
if (preg_match('/(\d{4}-\d{2}-\d{2})/', $basename, $matches)) {
$date = $matches[1];
}
$editionName = str_replace(['_', '-'], ' ', $basename);
$pdfFiles[] = [
'filename' => $file,
'path' => $this->getPdfPath($pdfDir, $file),
'date' => $date,
'edition_name' => $editionName,
'fullpath' => $filepath
];
}
}
}
}
// Sort by date descending
usort($pdfFiles, function($a, $b) {
return strtotime($b['date']) - strtotime($a['date']);
});
$this->view('public/archive', [
'page_title' => 'Archief',
'show_nav' => true,
'pdfFiles' => $pdfFiles
]);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Controllers;
use App\Models\Season;
use App\Models\Article;
class SeasonController extends Controller {
public function index() {
$this->requireAuth();
$seasons = Season::all();
$currentSeason = Season::getCurrent();
foreach ($seasons as &$season) {
$season['article_count'] = count(Article::getBySeason($season['id']));
}
$this->view('seasons/index', [
'seasons' => $seasons,
'currentSeason' => $currentSeason
]);
}
public function show($id) {
$season = Season::find($id);
if (!$season) {
$this->redirect('/seasons');
}
$articles = Article::getBySeason($id);
$this->view('seasons/show', [
'season' => $season,
'articles' => $articles
]);
}
}
+287
View File
@@ -0,0 +1,287 @@
<?php
namespace App\Controllers;
use App\Models\Article;
use App\Models\User;
class SubmissionController extends Controller {
public function showPassphrase() {
$this->view('submission/passphrase', [
'page_title' => 'Artikel Indienen - Beveiliging',
'show_nav' => true,
'next_page' => '/submit/form'
]);
}
public function verifyPassphrase() {
$passphrase = $_POST['passphrase'] ?? '';
$universalPassword = $this->config['universal_password'] ?? 'ProostenKrant2024!';
if ($passphrase === $universalPassword) {
// Store in session that user has passed the passphrase check
$_SESSION['passphrase_verified'] = true;
$_SESSION['passphrase_timestamp'] = time();
// Redirect to submission form
$this->redirect('/submit/form');
} else {
$_SESSION['error'] = 'Onjuiste paszin. Probeer het opnieuw.';
$this->redirect('/submit');
}
}
public function showForm() {
// Check if passphrase is verified OR if we're coming from article edit flow
$isEditFlow = isset($_SESSION['edit_article_id']) && $_SESSION['edit_article_id'];
if (!$isEditFlow && (!isset($_SESSION['passphrase_verified']) || !$_SESSION['passphrase_verified'])) {
$this->redirect('/submit');
}
$users = User::all();
// Get old input if validation failed
$oldInput = $_SESSION['old_input'] ?? [];
unset($_SESSION['old_input']);
$errors = $_SESSION['errors'] ?? [];
unset($_SESSION['errors']);
// Check if we're editing an existing article
$articleId = $_SESSION['edit_article_id'] ?? null;
$article = null;
if ($articleId) {
$article = Article::find($articleId);
unset($_SESSION['edit_article_id']);
}
$this->view('submission/form', [
'page_title' => $article ? 'Artikel Bewerken' : 'Artikel Indienen',
'show_nav' => true,
'users' => $users,
'oldInput' => $oldInput,
'errors' => $errors,
'article' => $article
]);
}
public function store() {
// Check if passphrase is verified OR if we're coming from article edit flow (which already verified)
$isEditFlow = isset($_POST['article_id']) && $_POST['article_id'];
if (!$isEditFlow && (!isset($_SESSION['passphrase_verified']) || !$_SESSION['passphrase_verified'])) {
$_SESSION['error'] = 'Je moet eerst de beveiligingspaszin invoeren.';
$this->redirect('/submit');
}
$users = User::all();
$userMap = [];
foreach ($users as $user) {
$userMap[$user['id']] = $user['display_name'];
}
$validationRules = [
'title' => 'required|min:5|max:255',
'content' => 'required|min:10',
'writer' => 'required',
'participants' => 'required',
];
$errors = $this->validate($_POST, $validationRules);
// Check if image was uploaded
$articleImage = null;
if (isset($_FILES['image'])) {
if ($_FILES['image']['error'] === UPLOAD_ERR_OK) {
// Use configured upload path for articles
$uploadDir = $this->config['upload_paths']['articles'] ?? __DIR__ . '/../../public/images/articles/';
$filename = uniqid() . '_' . basename($_FILES['image']['name']);
$targetPath = $uploadDir . '/' . $filename;
// Make sure the directory exists and is writable
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0777, true);
}
// Ensure directory has proper permissions
if (!is_writable($uploadDir)) {
chmod($uploadDir, 0777);
}
if (move_uploaded_file($_FILES['image']['tmp_name'], $targetPath)) {
$articleImage = 'images/articles/' . $filename;
// Store in session to show preview on redirect
$_SESSION['uploaded_image'] = $articleImage;
} else {
$error = error_get_last();
$errors['image'] = 'Fout bij het uploaden van de afbeelding: ' . ($error ? $error['message'] : 'Onbekende fout');
}
} else {
// Image upload error - no file selected
$errors['image'] = 'Selecteer een afbeelding om te uploaden.';
}
} else {
// No image file was sent
$errors['image'] = 'Een afbeelding is verplicht.';
}
if (!empty($errors)) {
$_SESSION['errors'] = $errors;
$_SESSION['old_input'] = $_POST;
$this->redirect('/submit/form');
}
// Get the writer's user ID
$writerId = $_POST['writer'];
$writerName = $userMap[$writerId] ?? 'Onbekend';
// Process participants
$participantIds = $_POST['participants'] ?? [];
if (!is_array($participantIds)) {
$participantIds = [$participantIds];
}
// Ensure writer is in participants
if (!in_array($writerId, $participantIds)) {
array_unshift($participantIds, $writerId);
}
$articleData = [
'title' => $_POST['title'],
'content' => $_POST['content'],
'author_id' => $writerId,
'participants' => implode(',', $participantIds),
'event_date' => $_POST['event_date'] ?? date('Y-m-d'),
'article_image' => $articleImage,
'status' => 'draft',
'created_at' => date('Y-m-d H:i:s'),
];
// Check if we're editing or creating new
$articleId = $_POST['article_id'] ?? null;
if ($articleId) {
// For existing articles, preserve the status
$existingArticle = Article::find($articleId);
if ($existingArticle) {
$articleData['status'] = $existingArticle['status'];
}
Article::update($articleId, $articleData);
$_SESSION['success'] = 'Artikel succesvol bijgewerkt!';
} else {
$articleId = Article::create($articleData);
$_SESSION['success'] = 'Artikel succesvol ingediend!';
}
// Clear passphrase verification for security
unset($_SESSION['passphrase_verified']);
unset($_SESSION['passphrase_timestamp']);
// Store article ID for confirmation
$_SESSION['submitted_article_id'] = $articleId;
$this->redirect('/submit/confirmation');
}
public function showConfirmation() {
// Check if there's a submitted article
$articleId = $_SESSION['submitted_article_id'] ?? null;
unset($_SESSION['submitted_article_id']);
$article = null;
if ($articleId) {
$article = Article::find($articleId);
}
$this->view('submission/confirmation', [
'page_title' => 'Bevestiging',
'show_nav' => true,
'article' => $article
]);
}
public function showEditList() {
// Check if passphrase is verified
if (!isset($_SESSION['passphrase_verified']) || !$_SESSION['passphrase_verified']) {
$this->redirect('/submit/edit');
}
$articles = Article::all('draft');
$users = User::all();
$usersById = [];
foreach ($users as $user) {
$usersById[$user['id']] = $user['display_name'];
}
$this->view('submission/edit_list', [
'page_title' => 'Artikelen Bewerken',
'show_nav' => true,
'articles' => $articles,
'usersById' => $usersById
]);
}
public function verifyEditPassphrase() {
$passphrase = $_POST['passphrase'] ?? '';
$universalPassword = $this->config['universal_password'] ?? 'ProostenKrant2024!';
if ($passphrase === $universalPassword) {
$_SESSION['passphrase_verified'] = true;
$_SESSION['passphrase_timestamp'] = time();
$this->redirect('/submit/edit/list');
} else {
$_SESSION['error'] = 'Onjuiste paszin. Probeer het opnieuw.';
$this->redirect('/submit/edit');
}
}
public function showEditPassphrase() {
$this->view('submission/edit_passphrase', [
'page_title' => 'Artikelen Bewerken - Beveiliging',
'show_nav' => true
]);
}
public function showWriterVerification($articleId) {
$article = Article::find($articleId);
if (!$article) {
$_SESSION['error'] = 'Artikel niet gevonden.';
$this->redirect('/submit/edit/list');
}
$users = User::all();
$this->view('submission/verify_writer', [
'page_title' => 'Verifieer Schrijver',
'show_nav' => true,
'article' => $article,
'users' => $users
]);
}
public function verifyWriter() {
$articleId = $_POST['article_id'] ?? null;
$writerId = $_POST['writer_id'] ?? null;
if (!$articleId || !$writerId) {
$_SESSION['error'] = 'Ongeldige verificatie.';
$this->redirect('/submit/edit/list');
}
$article = Article::find($articleId);
if (!$article) {
$_SESSION['error'] = 'Artikel niet gevonden.';
$this->redirect('/submit/edit/list');
}
if ($article['author_id'] != $writerId) {
$_SESSION['error'] = 'Alleen de schrijver van het artikel kan deze bewerken.';
$this->redirect('/submit/edit/list');
}
// Store article ID in session for editing
$_SESSION['edit_article_id'] = $articleId;
$this->redirect('/submit/form');
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Controllers;
use App\Models\User;
class UploadController extends Controller {
public function upload() {
$this->requireAuth();
if (!isset($_FILES['file'])) {
echo json_encode(['error' => 'No file uploaded']);
exit();
}
$file = $_FILES['file'];
if ($file['error'] !== UPLOAD_ERR_OK) {
echo json_encode(['error' => 'Upload error: ' . $file['error']]);
exit();
}
$type = $_POST['type'] ?? 'articles';
$validTypes = ['articles', 'profiles'];
if (!in_array($type, $validTypes)) {
echo json_encode(['error' => 'Invalid upload type']);
exit();
}
$uploadDir = $this->config['upload_paths'][$type];
// Create directory if it doesn't exist
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// Generate unique filename
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = uniqid() . '.' . $ext;
$targetPath = $uploadDir . $filename;
// Validate image
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!in_array(strtolower($ext), $allowedExtensions)) {
echo json_encode(['error' => 'Only images are allowed']);
exit();
}
$imageInfo = getimagesize($file['tmp_name']);
if ($imageInfo === false) {
echo json_encode(['error' => 'Invalid image file']);
exit();
}
// Resize large images to prevent issues with LaTeX
if ($imageInfo[0] > 4000 || $imageInfo[1] > 4000) {
$this->resizeImage($file['tmp_name'], $targetPath, 2000, 2000);
} else {
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
echo json_encode(['error' => 'Failed to move uploaded file']);
exit();
}
}
echo json_encode([
'success' => true,
'filename' => $filename,
'path' => 'images/' . $type . '/' . $filename,
'url' => '/images/' . $type . '/' . $filename
]);
}
private function resizeImage($source, $target, $maxWidth, $maxHeight) {
$imageInfo = getimagesize($source);
$width = $imageInfo[0];
$height = $imageInfo[1];
$mime = $imageInfo['mime'];
$ratio = min($maxWidth / $width, $maxHeight / $height);
$newWidth = (int)($width * $ratio);
$newHeight = (int)($height * $ratio);
$sourceImage = null;
switch ($mime) {
case 'image/jpeg':
$sourceImage = imagecreatefromjpeg($source);
break;
case 'image/png':
$sourceImage = imagecreatefrompng($source);
break;
case 'image/gif':
$sourceImage = imagecreatefromgif($source);
break;
case 'image/webp':
$sourceImage = imagecreatefromwebp($source);
break;
default:
move_uploaded_file($source, $target);
return;
}
if (!$sourceImage) {
move_uploaded_file($source, $target);
return;
}
$destinationImage = imagecreatetruecolor($newWidth, $newHeight);
// Preserve transparency for PNG and GIF
if ($mime === 'image/png' || $mime === 'image/gif') {
imagealphablending($destinationImage, false);
imagesavealpha($destinationImage, true);
$transparent = imagecolorallocatealpha($destinationImage, 255, 255, 255, 127);
imagefilledrectangle($destinationImage, 0, 0, $newWidth, $newHeight, $transparent);
}
imagecopyresampled($destinationImage, $sourceImage, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
switch ($mime) {
case 'image/jpeg':
imagejpeg($destinationImage, $target, 90);
break;
case 'image/png':
imagepng($destinationImage, $target, 9);
break;
case 'image/gif':
imagegif($destinationImage, $target);
break;
case 'image/webp':
imagewebp($destinationImage, $target, 90);
break;
}
imagedestroy($sourceImage);
imagedestroy($destinationImage);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Helpers;
class AuthHelper {
public static function check() {
return isset($_SESSION['is_logged_in']) && $_SESSION['is_logged_in'] === true;
}
public static function user() {
if (!self::check()) {
return null;
}
return [
'id' => $_SESSION['user_id'] ?? null,
'username' => $_SESSION['username'] ?? null,
'display_name' => $_SESSION['display_name'] ?? null,
];
}
public static function requireAuth() {
if (!self::check()) {
$_SESSION['redirect_after_login'] = $_SERVER['REQUEST_URI'] ?? '/';
header('Location: /login');
exit();
}
}
public static function redirectIfAuthenticated() {
if (self::check()) {
header('Location: /dashboard');
exit();
}
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace App\Helpers;
class ImageHelper {
public static function resizeImage($source, $target, $maxWidth, $maxHeight, $quality = 90) {
if (!file_exists($source)) {
return false;
}
$imageInfo = getimagesize($source);
if ($imageInfo === false) {
return false;
}
list($width, $height, $type) = $imageInfo;
// If image is smaller than max dimensions, just copy it
if ($width <= $maxWidth && $height <= $maxHeight) {
return copy($source, $target);
}
// Calculate new dimensions maintaining aspect ratio
$ratio = min($maxWidth / $width, $maxHeight / $height);
$newWidth = (int)($width * $ratio);
$newHeight = (int)($height * $ratio);
// Create source image resource
$sourceImage = null;
switch ($type) {
case IMAGETYPE_JPEG:
$sourceImage = imagecreatefromjpeg($source);
break;
case IMAGETYPE_PNG:
$sourceImage = imagecreatefrompng($source);
break;
case IMAGETYPE_GIF:
$sourceImage = imagecreatefromgif($source);
break;
case IMAGETYPE_WEBP:
$sourceImage = imagecreatefromwebp($source);
break;
default:
return copy($source, $target);
}
if (!$sourceImage) {
return false;
}
// Create destination image
$destinationImage = imagecreatetruecolor($newWidth, $newHeight);
// Preserve transparency for PNG and GIF
if ($type === IMAGETYPE_PNG || $type === IMAGETYPE_GIF) {
imagealphablending($destinationImage, false);
imagesavealpha($destinationImage, true);
$transparent = imagecolorallocatealpha($destinationImage, 255, 255, 255, 127);
imagefilledrectangle($destinationImage, 0, 0, $newWidth, $newHeight, $transparent);
}
// Resample image
imagecopyresampled($destinationImage, $sourceImage, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
// Save image
switch ($type) {
case IMAGETYPE_JPEG:
return imagejpeg($destinationImage, $target, $quality);
case IMAGETYPE_PNG:
return imagepng($destinationImage, $target, 9);
case IMAGETYPE_GIF:
return imagegif($destinationImage, $target);
case IMAGETYPE_WEBP:
return imagewebp($destinationImage, $target, $quality);
default:
return false;
}
}
public static function isImagePortrait($imagePath) {
if (!file_exists($imagePath)) {
return false;
}
$imageInfo = getimagesize($imagePath);
if ($imageInfo === false) {
return false;
}
list($width, $height) = $imageInfo;
return $height > $width;
}
public static function isImageLandscape($imagePath) {
if (!file_exists($imagePath)) {
return false;
}
$imageInfo = getimagesize($imagePath);
if ($imageInfo === false) {
return false;
}
list($width, $height) = $imageInfo;
return $width > $height;
}
public static function getImageOrientation($imagePath) {
if (!file_exists($imagePath)) {
return 'unknown';
}
$imageInfo = getimagesize($imagePath);
if ($imageInfo === false) {
return 'unknown';
}
list($width, $height) = $imageInfo;
if ($width > $height) {
return 'landscape';
} elseif ($height > $width) {
return 'portrait';
} else {
return 'square';
}
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Models;
class Article {
private static $table = 'articles';
public static function all($status = 'published') {
$db = Database::getInstance();
return $db->fetchAll(
"SELECT a.*, u.display_name as author_name, s.name as season_name " .
"FROM " . self::$table . " a " .
"LEFT JOIN users u ON a.author_id = u.id " .
"LEFT JOIN seasons s ON a.season_id = s.id " .
"WHERE a.status = ? ORDER BY a.event_date DESC, a.created_at DESC",
[$status]
);
}
public static function find($id) {
$db = Database::getInstance();
return $db->fetch(
"SELECT a.*, u.display_name as author_name, s.name as season_name " .
"FROM " . self::$table . " a " .
"LEFT JOIN users u ON a.author_id = u.id " .
"LEFT JOIN seasons s ON a.season_id = s.id " .
"WHERE a.id = ?",
[$id]
);
}
public static function create($data) {
$db = Database::getInstance();
// Process participants
if (isset($data['participants']) && is_array($data['participants'])) {
$data['participants'] = implode(',', $data['participants']);
}
return $db->insert(self::$table, $data);
}
public static function update($id, $data) {
$db = Database::getInstance();
if (isset($data['participants']) && is_array($data['participants'])) {
$data['participants'] = implode(',', $data['participants']);
}
return $db->update(self::$table, $data, ['id' => $id]);
}
public static function delete($id) {
$db = Database::getInstance();
return $db->delete(self::$table, ['id' => $id]);
}
public static function getBySeason($seasonId) {
$db = Database::getInstance();
return $db->fetchAll(
"SELECT a.*, u.display_name as author_name " .
"FROM " . self::$table . " a " .
"LEFT JOIN users u ON a.author_id = u.id " .
"WHERE a.season_id = ? AND a.status = 'published' " .
"ORDER BY a.event_date DESC",
[$seasonId]
);
}
public static function getByUser($userId) {
$db = Database::getInstance();
return $db->fetchAll(
"SELECT a.*, u.display_name as author_name " .
"FROM " . self::$table . " a " .
"LEFT JOIN users u ON a.author_id = u.id " .
"WHERE a.author_id = ? OR a.participants LIKE ? " .
"ORDER BY a.event_date DESC",
[$userId, "%,{$userId},%"]
);
}
public static function getCurrentSeasonId() {
$db = Database::getInstance();
$result = $db->fetch(
"SELECT id FROM seasons " .
"WHERE start_date <= CURDATE() AND end_date >= CURDATE() " .
"ORDER BY start_date DESC LIMIT 1"
);
return $result ? $result['id'] : null;
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Models;
use PDO;
use PDOException;
class Database {
private static $instance = null;
private $connection;
private function __construct() {
$config = include __DIR__ . '/../../config/database.php';
$dsn = "mysql:host={$config['host']};dbname={$config['database']};charset={$config['charset']}";
try {
$this->connection = new PDO(
$dsn,
$config['username'],
$config['password'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
} catch (PDOException $e) {
die("Database connection failed: " . $e->getMessage());
}
}
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getConnection() {
return $this->connection;
}
public function query($sql, $params = []) {
$stmt = $this->connection->prepare($sql);
$stmt->execute($params);
return $stmt;
}
public function fetchAll($sql, $params = []) {
return $this->query($sql, $params)->fetchAll();
}
public function fetch($sql, $params = []) {
return $this->query($sql, $params)->fetch();
}
public function insert($table, $data) {
$columns = implode(', ', array_keys($data));
$placeholders = ':' . implode(', :', array_keys($data));
$sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})";
$stmt = $this->connection->prepare($sql);
$stmt->execute($data);
return $this->connection->lastInsertId();
}
public function update($table, $data, $where) {
$setParts = [];
foreach ($data as $key => $value) {
$setParts[] = "{$key} = :{$key}";
}
$whereParts = [];
foreach ($where as $key => $value) {
$whereParts[] = "{$key} = :where_{$key}";
$data["where_{$key}"] = $value;
}
$sql = "UPDATE {$table} SET " . implode(', ', $setParts) .
" WHERE " . implode(' AND ', $whereParts);
$stmt = $this->connection->prepare($sql);
return $stmt->execute($data);
}
public function delete($table, $where) {
$whereParts = [];
$params = [];
foreach ($where as $key => $value) {
$whereParts[] = "{$key} = :{$key}";
$params[$key] = $value;
}
$sql = "DELETE FROM {$table} WHERE " . implode(' AND ', $whereParts);
return $this->query($sql, $params)->rowCount();
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
class Season {
private static $table = 'seasons';
public static function all() {
$db = Database::getInstance();
return $db->fetchAll("SELECT * FROM " . self::$table . " ORDER BY start_date");
}
public static function find($id) {
$db = Database::getInstance();
return $db->fetch("SELECT * FROM " . self::$table . " WHERE id = ?", [$id]);
}
public static function getActive() {
$db = Database::getInstance();
return $db->fetchAll(
"SELECT * FROM " . self::$table . " WHERE is_active = 1 ORDER BY start_date"
);
}
public static function getCurrent() {
$db = Database::getInstance();
return $db->fetch(
"SELECT * FROM " . self::$table . " " .
"WHERE start_date <= CURDATE() AND end_date >= CURDATE() " .
"ORDER BY start_date DESC LIMIT 1"
);
}
public static function getNext() {
$db = Database::getInstance();
return $db->fetch(
"SELECT * FROM " . self::$table . " " .
"WHERE start_date > CURDATE() " .
"ORDER BY start_date ASC LIMIT 1"
);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
class Setting {
private static $table = 'settings';
public static function get($key, $default = null) {
$db = Database::getInstance();
$result = $db->fetch("SELECT value FROM " . self::$table . " WHERE key_name = ?", [$key]);
return $result ? $result['value'] : $default;
}
public static function set($key, $value) {
$db = Database::getInstance();
$existing = $db->fetch("SELECT id FROM " . self::$table . " WHERE key_name = ?", [$key]);
if ($existing) {
return $db->update(self::$table, ['value' => $value], ['key_name' => $key]);
} else {
return $db->insert(self::$table, ['key_name' => $key, 'value' => $value]);
}
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
class User {
private static $table = 'users';
public static function all() {
$db = Database::getInstance();
return $db->fetchAll("SELECT * FROM " . self::$table . " WHERE is_active = 1 ORDER BY display_name");
}
public static function find($id) {
$db = Database::getInstance();
return $db->fetch("SELECT * FROM " . self::$table . " WHERE id = ?", [$id]);
}
public static function findByUsername($username) {
$db = Database::getInstance();
return $db->fetch("SELECT * FROM " . self::$table . " WHERE username = ? AND is_active = 1", [$username]);
}
public static function getByIds($ids) {
if (empty($ids)) return [];
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$db = Database::getInstance();
return $db->fetchAll("SELECT * FROM " . self::$table . " WHERE id IN ({$placeholders}) AND is_active = 1", $ids);
}
public static function updateProfileImage($userId, $imagePath) {
$db = Database::getInstance();
return $db->update(self::$table, ['profile_image' => $imagePath], ['id' => $userId]);
}
}
+5
View File
@@ -0,0 +1,5 @@
<?php
require 'controllers/Controller.php';
require 'controllers/TestController.php';
$controller = new \App\Controllers\TestController();
$controller->index();
+52
View File
@@ -0,0 +1,52 @@
<?php
$page_title = 'Artikelen';
ob_start();
?>
<div class="articles-list">
<div class="header-actions">
<h2>Alle Artikelen</h2>
<a href="/articles/new" class="btn">Nieuw Artikel</a>
</div>
<?php if (!empty($articles)): ?>
<table class="articles-table">
<thead>
<tr>
<th>Titel</th>
<th>Auteur</th>
<th>Datum</th>
<th>Seizoen</th>
<th>Status</th>
<th>Acties</th>
</tr>
</thead>
<tbody>
<?php foreach ($articles as $article): ?>
<tr>
<td><?= htmlspecialchars($article['title']) ?></td>
<td><?= htmlspecialchars($article['author_name'] ?? 'Onbekend') ?></td>
<td><?= date('d-m-Y', strtotime($article['event_date'])) ?></td>
<td><?= htmlspecialchars($article['season_name'] ?? 'N/A') ?></td>
<td>
<span class="status-badge <?= strtolower($article['status']) ?>">
<?= htmlspecialchars($article['status']) ?>
</span>
</td>
<td>
<a href="/articles/<?= $article['id'] ?>" class="btn-small">Bekijken</a>
<a href="/articles/<?= $article['id'] ?>/verify-writer" class="edit-icon" title="Bewerken">✏️</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<p>Er zijn nog geen artikelen.</p>
<?php endif; ?>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layouts/main.php';
?>
+128
View File
@@ -0,0 +1,128 @@
<?php
$page_title = 'Nieuw Artikel';
ob_start();
// Get old input if validation failed
$oldInput = $_SESSION['old_input'] ?? [];
unset($_SESSION['old_input']);
$errors = $_SESSION['errors'] ?? [];
unset($_SESSION['errors']);
$users = \App\Models\User::all();
$seasons = \App\Models\Season::all();
$currentSeason = \App\Models\Season::getCurrent();
?>
<div class="article-form">
<h2>Nieuw Artikel Toevoegen</h2>
<form action="/articles" method="post" enctype="multipart/form-data">
<div class="form-row">
<div class="form-group">
<label for="title">Titel *</label>
<input type="text" name="title" id="title"
value="<?= htmlspecialchars($oldInput['title'] ?? '') ?>"
required class="form-control <?= isset($errors['title']) ? 'error' : '' ?>">
<?php if (isset($errors['title'])): ?>
<span class="error-message"><?= htmlspecialchars($errors['title']) ?></span>
<?php endif; ?>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="content">Inhoud *</label>
<textarea name="content" id="content" rows="10"
required class="form-control <?= isset($errors['content']) ? 'error' : '' ?>"><?= htmlspecialchars($oldInput['content'] ?? '') ?></textarea>
<?php if (isset($errors['content'])): ?>
<span class="error-message"><?= htmlspecialchars($errors['content']) ?></span>
<?php endif; ?>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="event_date">Datum van het evenement *</label>
<input type="date" name="event_date" id="event_date"
value="<?= htmlspecialchars($oldInput['event_date'] ?? date('Y-m-d')) ?>"
required class="form-control <?= isset($errors['event_date']) ? 'error' : '' ?>">
<?php if (isset($errors['event_date'])): ?>
<span class="error-message"><?= htmlspecialchars($errors['event_date']) ?></span>
<?php endif; ?>
</div>
<div class="form-group">
<label for="author_id">Auteur *</label>
<select name="author_id" id="author_id" required
class="form-control <?= isset($errors['author_id']) ? 'error' : '' ?>">
<option value="">-- Selecteer auteur --</option>
<?php foreach ($users as $user): ?>
<option value="<?= $user['id'] ?>"
<?= (($oldInput['author_id'] ?? '') == $user['id'] || (isset($_SESSION['user_id']) && $_SESSION['user_id'] == $user['id'])) ? 'selected' : '' ?>>
<?= htmlspecialchars($user['display_name']) ?>
</option>
<?php endforeach; ?>
</select>
<?php if (isset($errors['author_id'])): ?>
<span class="error-message"><?= htmlspecialchars($errors['author_id']) ?></span>
<?php endif; ?>
</div>
</div>
<div class="form-row">
<div class="form-group full-width">
<label>Deelnemers</label>
<div class="checkbox-group">
<?php foreach ($users as $user): ?>
<label class="checkbox-label">
<input type="checkbox" name="participants[]"
value="<?= $user['id'] ?>"
<?= (isset($oldInput['participants']) && in_array($user['id'], $oldInput['participants'])) ? 'checked' : '' ?>>
<?= htmlspecialchars($user['display_name']) ?>
</label>
<?php endforeach; ?>
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="season_id">Seizoen *</label>
<select name="season_id" id="season_id" required
class="form-control <?= isset($errors['season_id']) ? 'error' : '' ?>">
<option value="">-- Selecteer seizoen --</option>
<?php foreach ($seasons as $season): ?>
<option value="<?= $season['id'] ?>"
<?= (($oldInput['season_id'] ?? '') == $season['id'] || ($currentSeason && $currentSeason['id'] == $season['id'])) ? 'selected' : '' ?>>
<?= htmlspecialchars($season['name']) ?>
(<?= date('d-m-Y', strtotime($season['start_date'])) ?> -
<?= date('d-m-Y', strtotime($season['end_date'])) ?>)
</option>
<?php endforeach; ?>
</select>
<?php if (isset($errors['season_id'])): ?>
<span class="error-message"><?= htmlspecialchars($errors['season_id']) ?></span>
<?php endif; ?>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="article_image">Afbeelding (optioneel)</label>
<input type="file" name="article_image" id="article_image"
accept="image/*" class="form-control">
<p class="help-text">Upload een foto bij het artikel. tantoon landscape als portret foto's worden ondersteund.</p>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Artikel Opslaan</button>
<a href="/articles" class="btn btn-secondary">Annuleren</a>
</div>
</form>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layouts/main.php';
?>
+57
View File
@@ -0,0 +1,57 @@
<?php
$page_title = htmlspecialchars($article['title'] ?? 'Artikel');
ob_start();
$participantNames = [];
foreach ($participants as $participant) {
$participantNames[] = htmlspecialchars($participant['display_name']);
}
$participantsString = implode(', ', $participantNames);
?>
<div class="article-detail">
<h2><?= htmlspecialchars($article['title']) ?></h2>
<div class="article-meta">
<span class="meta-item">
<strong>Auteur:</strong> <?= htmlspecialchars($article['author_name'] ?? 'Onbekend') ?>
</span>
<span class="meta-item">
<strong>Datum:</strong> <?= date('d-m-Y', strtotime($article['event_date'])) ?>
</span>
<span class="meta-item">
<strong>Seizoen:</strong> <?= htmlspecialchars($article['season_name'] ?? 'N/A') ?>
</span>
<span class="meta-item">
<strong>Deelnemers:</strong> <?= $participantsString ?>
</span>
<span class="meta-item">
<strong>Status:</strong>
<span class="status-badge <?= strtolower($article['status']) ?>">
<?= htmlspecialchars($article['status']) ?>
</span>
</span>
</div>
<?php if ($article['article_image']): ?>
<div class="article-image-container">
<img src="/<?= htmlspecialchars($article['article_image']) ?>"
alt="<?= htmlspecialchars($article['title']) ?>"
class="article-image">
</div>
<?php endif; ?>
<div class="article-content">
<?= nl2br(htmlspecialchars($article['content'])) ?>
</div>
<div class="article-actions">
<a href="/articles" class="btn btn-secondary">Terug naar Overzicht</a>
<a href="/articles/<?= $article['id'] ?>/edit" class="btn">Bewerken</a>
</div>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layouts/main.php';
?>
@@ -0,0 +1,50 @@
<?php
$page_title = 'Beveiligingscontrole - Artikel Bewerken';
ob_start();
?>
<div class="passphrase-container">
<h2>Beveiligingscontrole</h2>
<?php if ($article): ?>
<div class="card mb-30">
<h4><?= htmlspecialchars($article['title']) ?></h4>
<p class="text-muted">
Voer het algemene passphrase in om dit artikel te bewerken.
</p>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['error'])): ?>
<div class="alert alert-error">
<?= htmlspecialchars($_SESSION['error']) ?>
<?php unset($_SESSION['error']); ?>
</div>
<?php endif; ?>
<form action="/articles/verify-passphrase" method="post">
<div class="form-group">
<label for="passphrase">Algemeen Passphrase</label>
<input type="password"
name="passphrase"
id="passphrase"
class="form-control"
required
autocomplete="off"
placeholder="Voer het passphrase in">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Verifieer en Bewerk Artikel
</button>
<a href="/articles" class="btn btn-secondary">Annuleren</a>
</div>
</form>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layouts/main.php';
?>
+58
View File
@@ -0,0 +1,58 @@
<?php
$page_title = 'Verifieer Schrijver';
ob_start();
?>
<div class="writer-verification">
<h2>Verifieer Schrijver</h2>
<?php if ($article): ?>
<div class="card mb-30">
<h4><?= htmlspecialchars($article['title']) ?></h4>
<p class="text-muted">
Om dit artikel te bewerken, bevestig aub wie de schrijver is.
</p>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['error'])): ?>
<div class="alert alert-error">
<?= htmlspecialchars($_SESSION['error']) ?>
<?php unset($_SESSION['error']); ?>
</div>
<?php endif; ?>
<form action="/articles/verify-writer" method="post">
<input type="hidden" name="article_id" value="<?= htmlspecialchars($article['id'] ?? '') ?>">
<div class="form-group">
<label for="writer_id">Selecteer de Schrijver *</label>
<select name="writer_id"
id="writer_id"
required
class="form-control">
<option value="">-- Selecteer de schrijver --</option>
<?php foreach ($users as $user): ?>
<option value="<?= $user['id'] ?>"
<?= ($article && $article['author_id'] == $user['id']) ? 'selected' : '' ?>>
<?= htmlspecialchars($user['display_name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Verifieer en Ga Verder
</button>
<a href="/articles" class="btn btn-secondary">
Terug naar Artikelen
</a>
</div>
</form>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layouts/main.php';
?>
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($config['app_name'] ?? 'ProostenKrant') ?> - <?= htmlspecialchars($this->data['page_title'] ?? '') ?></title>
<link rel="stylesheet" href="/css/douwco-style.css">
<script src="/js/app.js" defer></script>
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<div class="header-content">
<h1 class="logo">
<a href="/"><?= htmlspecialchars($config['app_name'] ?? 'ProostenKrant') ?></a>
</h1>
<p class="slogan"><?= htmlspecialchars($config['app_slogan'] ?? 'Een krant van en voor Proosten') ?></p>
</div>
<?php if (isset($this->data['show_nav']) && $this->data['show_nav']): ?>
<nav class="nav">
<?php if (isset($_SESSION['is_logged_in']) && $_SESSION['is_logged_in']): ?>
<a href="/dashboard">Dashboard</a>
<a href="/articles">Artikelen</a>
<a href="/articles/new">Nieuw Artikel</a>
<a href="/seasons">Seizoenen</a>
<a href="/generate-pdf" class="btn">PDF Genereren</a>
<a href="/logout" class="btn-secondary">Uitloggen</a>
<?php endif; ?>
</nav>
<?php endif; ?>
</header>
<!-- Main Content -->
<main class="main">
<?php if (isset($_SESSION['success'])): ?>
<div class="alert alert-success">
<span><?= htmlspecialchars($_SESSION['success']) ?></span>
<button class="close" onclick="this.parentElement.style.display='none'">×</button>
<?php unset($_SESSION['success']); ?>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['error'])): ?>
<div class="alert alert-error">
<span><?= htmlspecialchars($_SESSION['error']) ?></span>
<button class="close" onclick="this.parentElement.style.display='none'">×</button>
<?php unset($_SESSION['error']); ?>
</div>
<?php endif; ?>
<?= $content ?? '' ?>
</main>
<!-- Footer -->
<footer class="footer">
<div class="footer-content">
<span class="copyright">Copyright © <?= date('Y') ?></span>
<span class="hosted">Made and hosted in <span class="eu-flag">🇪🇺</span></span>
</div>
</footer>
</div>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<?php
$page_title = 'Dashboard';
?>
<div class="dashboard">
<h2>Welkom, <?= htmlspecialchars($_SESSION['display_name'] ?? '') ?>!</h2>
<?php if ($currentSeason): ?>
<div class="season-info" style="background-color: <?= $config['seasons'][$currentSeason['id']]['color'] ?? '#f0f0f0' ?>">
<h3>Huidig Seizoen: <?= htmlspecialchars($currentSeason['name']) ?></h3>
<p><?= htmlspecialchars($currentSeason['description'] ?? '') ?></p>
</div>
<?php endif; ?>
<div class="dashboard-grid">
<div class="dashboard-card">
<h3>Artikelen Toevoegen</h3>
<p>Voeg nieuwe artikelen toe voor de volgende editie.</p>
<a href="/articles/new" class="btn">Nieuw Artikel</a>
</div>
<div class="dashboard-card">
<h3>Mijn Artikelen</h3>
<p>Beheer uw eigen artikelen.</p>
<a href="/articles" class="btn">Bekijk Artikelen</a>
</div>
<div class="dashboard-card">
<h3>Seizoenen</h3>
<p>Bekijk alle seizoensedities.</p>
<a href="/seasons" class="btn">Bekijk Seizoenen</a>
</div>
<div class="dashboard-card">
<h3>PDF Genereren</h3>
<p>Genereer een PDF van de huidige editie.</p>
<a href="/generate-pdf" class="btn">Genereer PDF</a>
</div>
</div>
<h3>Sneltoetsen</h3>
<div class="quick-links">
<a href="/articles/new">+ Nieuw Artikel</a>
<a href="/articles">Artikelen Lijst</a>
<a href="/seasons">Seizoenen</a>
<a href="/generate-pdf">PDF</a>
</div>
</div>
<?php
require __DIR__ . '/../layouts/main.php';
?>
+38
View File
@@ -0,0 +1,38 @@
<?php
?>
<div class="login-container">
<div class="login-card">
<h2>Inloggen</h2>
<p>Voer uw gebruikersnaam en het universele wachtwoord in om toegang te krijgen.</p>
<form action="/login" method="post" class="login-form">
<div class="form-group">
<label for="username">Gebruikersnaam:</label>
<select name="username" id="username" required class="form-control">
<option value="">-- Selecteer een gebruiker --</option>
<?php
$users = \App\Models\User::all();
foreach ($users as $user):
?>
<option value="<?= htmlspecialchars($user['username']) ?>"
<?= (isset($_POST['username']) && $_POST['username'] === $user['username']) ? 'selected' : '' ?>>
<?= htmlspecialchars($user['display_name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="password">Wachtwoord:</label>
<input type="password" name="password" id="password" required class="form-control">
</div>
<button type="submit" class="btn btn-primary">Inloggen</button>
</form>
</div>
</div>
<?php
require __DIR__ . '/../layouts/main.php';
?>
+46
View File
@@ -0,0 +1,46 @@
<?php
$page_title = 'Archief';
ob_start();
?>
<div class="archive-container">
<div class="section-header">
<h2>Archief van ProostenKrant Edities</h2>
</div>
<?php if (!empty($pdfFiles)): ?>
<div class="pdf-list">
<?php foreach ($pdfFiles as $pdf): ?>
<div class="pdf-item slide-up">
<div class="pdf-info">
<h4><?= htmlspecialchars($pdf['edition_name']) ?></h4>
<p>
<span class="text-muted">Datum:</span>
<?= date('d-m-Y', strtotime($pdf['date'])) ?>
</p>
</div>
<div class="pdf-actions">
<a href="<?= htmlspecialchars($pdf['path']) ?>"
class="pdf-icon"
target="_blank"
title="Bekijken/Download">
<span>📄</span>
</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="card text-center">
<p class="text-muted">Er zijn nog geen PDF edities beschikbaar in het archief.</p>
<p class="text-muted mt-20">
<a href="/" class="btn btn-secondary">Terug naar Home</a>
</p>
</div>
<?php endif; ?>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layouts/main.php';
?>
+121
View File
@@ -0,0 +1,121 @@
<?php
$page_title = 'Home';
ob_start();
?>
<div class="home-sections">
<!-- Section 1: Article Submission -->
<section class="submit-section slide-up">
<h2>Artikel Indienen</h2>
<p>Heb je een leuk verhaal, een bijeenkomst of een bijzondere gebeurtenis meegemaakt?
Deel het met de hele familie door een artikel in te dienen voor de ProostenKrant!</p>
<a href="/submit" class="btn btn-primary">
<span class="icon">+</span> Artikel Indienen
</a>
</section>
<!-- Section 2: WIP Newspaper Preview -->
<section class="wip-section slide-up">
<div class="section-header">
<h2>Huidige Editie (Werk in Uitvoering)</h2>
<div class="section-actions">
<?php if (!empty($draftArticles)): ?>
<span class="status-badge draft">
<?= count($draftArticles) ?> artikelen in voorbereiding
</span>
<?php else: ?>
<span class="text-muted">Geen artikelen in voorbereiding</span>
<?php endif; ?>
</div>
</div>
<?php if (!empty($draftArticles)): ?>
<div class="wip-articles">
<?php foreach ($draftArticles as $article): ?>
<div class="wip-article">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<h4 style="margin-right: 10px;"><?= htmlspecialchars($article['title']) ?></h4>
<?php if (isset($_SESSION['user_id'])): ?>
<a href="/articles/<?= $article['id'] ?>/verify-writer"
class="edit-icon"
title="Bewerken"
style="margin-top: -10px;">
<span>✏️</span>
</a>
<?php endif; ?>
</div>
<p class="wip-content">
<span class="text-muted">Deelnemers:</span>
<strong>
<?php
$participantIds = explode(',', $article['participants']);
$participantNames = [];
foreach ($participantIds as $id) {
if (!empty($id) && isset($usersById[$id])) {
$participantNames[] = htmlspecialchars($usersById[$id]);
}
}
echo implode(', ', $participantNames);
?>
</strong>
<?php if (!empty($article['event_date'])): ?>
<br>
<span class="text-muted">Datum:</span>
<strong><?= date('d-m-Y', strtotime($article['event_date'])) ?></strong>
<?php endif; ?>
</p>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="card text-center">
<p class="text-muted">Er zijn momenteel geen artikelen in voorbereiding.</p>
<p class="text-muted">Wees de eerste om een artikel in te dienen!</p>
</div>
<?php endif; ?>
</section>
<!-- Section 3: Archive -->
<section class="archive-section slide-up">
<div class="section-header">
<h2>Archief</h2>
<a href="/archive" class="btn btn-secondary btn-small">
Alles Bekijken
<span class="icon">→</span>
</a>
</div>
<?php if (!empty($pdfFiles)): ?>
<div class="pdf-list">
<?php foreach (array_slice($pdfFiles, 0, 5) as $pdf): ?>
<div class="pdf-item">
<div class="pdf-info">
<h4><?= htmlspecialchars(basename($pdf['filename'], '.pdf')) ?></h4>
<p>
<span class="text-muted">Gepubliceerd op:</span>
<?= date('d-m-Y', strtotime($pdf['date'])) ?>
</p>
</div>
<div class="pdf-actions">
<a href="<?= htmlspecialchars($pdf['path']) ?>"
class="pdf-icon"
target="_blank"
title="Bekijken/Download">
<span>📄</span>
</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="card text-center">
<p class="text-muted">Er zijn nog geen eerdere edities beschikbaar.</p>
</div>
<?php endif; ?>
</section>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layouts/main.php';
?>
+44
View File
@@ -0,0 +1,44 @@
<?php
$page_title = 'Seizoenen';
ob_start();
?>
<div class="seasons-list">
<h2>Seizoensedities</h2>
<p>Hier vindt u een overzicht van alle seizoensedities.</p>
<?php if ($currentSeason): ?>
<div class="current-season">
<h3>Huidig Seizoen</h3>
<div class="season-card" style="background-color: <?= $config['seasons'][$currentSeason['id']]['color'] ?? '#f0f0f0' ?>">
<h4><?= htmlspecialchars($currentSeason['name']) ?></h4>
<p><?= htmlspecialchars($currentSeason['description'] ?? '') ?></p>
<p>Van <?= date('d-m-Y', strtotime($currentSeason['start_date'])) ?>
tot <?= date('d-m-Y', strtotime($currentSeason['end_date'])) ?></p>
<p>Aantal artikelen: <?= $currentSeason['article_count'] ?? 0 ?></p>
<a href="/generate-pdf?season_id=<?= $currentSeason['id'] ?>" class="btn">PDF Genereren</a>
</div>
</div>
<?php endif; ?>
<h3>Alle Seizoenen</h3>
<div class="seasons-grid">
<?php foreach ($seasons as $season): ?>
<div class="season-card" style="background-color: <?= $config['seasons'][$season['id']]['color'] ?? '#f0f0f0' ?>">
<h4><?= htmlspecialchars($season['name']) ?></h4>
<p><?= htmlspecialchars($season['description'] ?? '') ?></p>
<p>Van <?= date('d-m-Y', strtotime($season['start_date'])) ?>
tot <?= date('d-m-Y', strtotime($season['end_date'])) ?></p>
<p>Aantal artikelen: <?= $season['article_count'] ?? 0 ?></p>
<a href="/generate-pdf?season_id=<?= $season['id'] ?>" class="btn-small">PDF</a>
<a href="/seasons/<?= $season['id'] ?>" class="btn-small">Bekijken</a>
</div>
<?php endforeach; ?>
</div>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layouts/main.php';
?>
+57
View File
@@ -0,0 +1,57 @@
<?php
$page_title = htmlspecialchars($season['name'] ?? 'Seizoen');
ob_start();
?>
<div class="season-detail">
<h2><?= htmlspecialchars($season['name']) ?></h2>
<div class="season-info">
<p><strong>Beschrijving:</strong> <?= htmlspecialchars($season['description'] ?? '') ?></p>
<p><strong>Periode:</strong> Van <?= date('d-m-Y', strtotime($season['start_date'])) ?>
tot <?= date('d-m-Y', strtotime($season['end_date'])) ?></p>
</div>
<h3>Artikelen in deze editie</h3>
<?php if (!empty($articles)): ?>
<div class="articles-grid">
<?php foreach ($articles as $article): ?>
<div class="article-card">
<h4><?= htmlspecialchars($article['title']) ?></h4>
<p class="article-meta">
Door: <?= htmlspecialchars($article['author_name']) ?> |
<?= date('d-m-Y', strtotime($article['event_date'])) ?>
</p>
<p><?= htmlspecialchars(substr($article['content'], 0, 150)) ?>...</p>
<?php if ($article['article_image']): ?>
<img src="/<?= htmlspecialchars($article['article_image']) ?>"
alt="<?= htmlspecialchars($article['title']) ?>"
class="article-image">
<?php endif; ?>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<a href="/articles/<?= $article['id'] ?>" class="btn-small">Lees meer</a>
<?php if (isset($_SESSION['user_id'])): ?>
<a href="/articles/<?= $article['id'] ?>/verify-writer" class="edit-icon" title="Bewerken">✏️</a>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="season-actions">
<a href="/generate-pdf?season_id=<?= $season['id'] ?>" class="btn">PDF Genereren voor deze editie</a>
</div>
<?php else: ?>
<p>Er zijn nog geen artikelen voor deze editie.</p>
<?php endif; ?>
<div class="back-link">
<a href="/seasons">Terug naar alle seizoenen</a>
</div>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layouts/main.php';
?>
+37
View File
@@ -0,0 +1,37 @@
<?php
$page_title = 'Bevestiging';
ob_start();
?>
<div class="confirmation-container slide-up">
<div class="icon">✓</div>
<h2 class="title">Artikel Succesvol Ingediend!</h2>
<?php if ($article): ?>
<div class="card mt-30">
<h4 class="card-title"><?= htmlspecialchars($article['title']) ?></h4>
<div class="article-meta">
<span><strong>Datum:</strong> <?= date('d-m-Y', strtotime($article['event_date'])) ?></span>
</div>
<p class="card-content mt-20">
Je artikel is opgeslagen en zal zichtbaar zijn in de volgende editie van de ProostenKrant.
</p>
</div>
<?php else: ?>
<p>Je artikel is succesvol ingediend en zal zichtbaar zijn in de volgende editie.</p>
<?php endif; ?>
<div class="form-actions mt-30">
<a href="/" class="btn btn-primary">
<span class="icon">←</span> Terug naar Home
</a>
<a href="/submit" class="btn btn-secondary">
<span class="icon">+</span> Nog een Artikel Indienen
</a>
</div>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layouts/main.php';
?>
+57
View File
@@ -0,0 +1,57 @@
<?php
$page_title = 'Artikelen Bewerken';
ob_start();
?>
<div class="edit-list">
<div class="section-header">
<h2>Artikelen om te Bewerken</h2>
<a href="/" class="btn btn-secondary btn-small">
<span class="icon">←</span> Terug
</a>
</div>
<?php if (!empty($articles)): ?>
<div class="cards-grid">
<?php foreach ($articles as $article): ?>
<div class="article-card">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<h4 class="article-title"><?= htmlspecialchars($article['title']) ?></h4>
<a href="/submit/edit/<?= $article['id'] ?>"
class="edit-icon"
title="Bewerken">
<span>✏️</span>
</a>
</div>
<div class="article-meta">
<span><strong>Deelnemers:</strong>
<?php
$participantIds = explode(',', $article['participants']);
$participantNames = [];
foreach ($participantIds as $id) {
if (!empty($id) && isset($usersById[$id])) {
$participantNames[] = htmlspecialchars($usersById[$id]);
}
}
echo implode(', ', $participantNames);
?>
</span>
<span><strong>Datum:</strong> <?= date('d-m-Y', strtotime($article['event_date'])) ?></span>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="card text-center">
<p class="text-muted">Er zijn geen artikelen om te bewerken.</p>
<p class="mt-20">
<a href="/submit" class="btn btn-primary">Nieuw Artikel Indienen</a>
</p>
</div>
<?php endif; ?>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layouts/main.php';
?>
+34
View File
@@ -0,0 +1,34 @@
<?php
$page_title = 'Artikelen Bewerken - Beveiliging';
ob_start();
?>
<div class="passphrase-container slide-up">
<h2 class="title">Artikelen Bewerken</h2>
<p>Voer de familypaszin in om artikelen te kunnen bewerken:</p>
<form action="/submit/edit/verify" method="post">
<div class="form-group">
<label for="passphrase">Familie Paszin</label>
<input type="password"
name="passphrase"
id="passphrase"
class="form-control"
required
autocomplete="off"
placeholder="Voer de paszin in">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<span class="icon">→</span> Doorgaan
</button>
<a href="/" class="btn btn-secondary">Annuleren</a>
</div>
</form>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layouts/main.php';
?>
+192
View File
@@ -0,0 +1,192 @@
<?php
$page_title = 'Nieuw Artikel Indienen';
ob_start();
$users = $users ?? [];
$oldInput = $oldInput ?? [];
$errors = $errors ?? [];
$article = $article ?? null;
// If editing, populate old input from article
if ($article && empty($oldInput)) {
$oldInput = [
'title' => $article['title'],
'content' => $article['content'],
'writer' => $article['author_id'],
'event_date' => $article['event_date'],
'article_id' => $article['id']
];
// Parse participants
if (!empty($article['participants'])) {
$participantIds = explode(',', $article['participants']);
} else {
$participantIds = [];
}
}
// Get selected participants from old input
$selectedParticipants = [];
if (!empty($oldInput['participants'])) {
if (is_array($oldInput['participants'])) {
$selectedParticipants = $oldInput['participants'];
} else {
$selectedParticipants = [$oldInput['participants']];
}
} elseif (isset($participantIds)) {
$selectedParticipants = $participantIds;
}
// Check if we have an uploaded image in session or from old input
$uploadedImage = null;
if (isset($_SESSION['uploaded_image'])) {
$uploadedImage = $_SESSION['uploaded_image'];
unset($_SESSION['uploaded_image']);
} elseif ($article && $article['article_image']) {
$uploadedImage = $article['article_image'];
}
?>
<div class="article-form">
<div class="section-header">
<h2><?= $article ? 'Artikel Bewerken' : 'Nieuw Artikel Indienen' ?></h2>
<a href="/" class="btn btn-secondary btn-small">
<span class="icon">×</span> Annuleren
</a>
</div>
<form action="/submit/store" method="post" enctype="multipart/form-data">
<?php if ($article): ?>
<input type="hidden" name="article_id" value="<?= $article['id'] ?>">
<?php endif; ?>
<div class="form-row">
<div class="form-group">
<label for="title">Titel *</label>
<input type="text"
name="title"
id="title"
value="<?= htmlspecialchars($oldInput['title'] ?? '') ?>"
required
class="form-control <?= isset($errors['title']) ? 'error' : '' ?>"
placeholder="Voer de titel in">
<?php if (isset($errors['title'])): ?>
<span class="error-message"><?= htmlspecialchars($errors['title']) ?></span>
<?php endif; ?>
</div>
</div>
<div class="form-row">
<div class="form-group full-width">
<label for="content">Tekst *</label>
<textarea name="content"
id="content"
rows="8"
required
class="form-control <?= isset($errors['content']) ? 'error' : '' ?>"
placeholder="Schrijf hier de inhoud van je artikel..."><?= htmlspecialchars($oldInput['content'] ?? '') ?></textarea>
<?php if (isset($errors['content'])): ?>
<span class="error-message"><?= htmlspecialchars($errors['content']) ?></span>
<?php endif; ?>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="writer">Schrijver *</label>
<select name="writer"
id="writer"
required
class="form-control <?= isset($errors['writer']) ? 'error' : '' ?>">
<option value="">-- Selecteer schrijver --</option>
<?php foreach ($users as $user): ?>
<option value="<?= $user['id'] ?>"
<?= (($oldInput['writer'] ?? '') == $user['id']) ? 'selected' : '' ?>>
<?= htmlspecialchars($user['display_name']) ?>
</option>
<?php endforeach; ?>
</select>
<?php if (isset($errors['writer'])): ?>
<span class="error-message"><?= htmlspecialchars($errors['writer']) ?></span>
<?php endif; ?>
</div>
<div class="form-group">
<label for="event_date">Datum van het evenement *</label>
<input type="date"
name="event_date"
id="event_date"
value="<?= htmlspecialchars($oldInput['event_date'] ?? date('Y-m-d')) ?>"
required
class="form-control">
</div>
</div>
<div class="form-row">
<div class="form-group full-width">
<label>Deelnemers *</label>
<p class="help-text">Selecteer alle personages die bij dit verhaal betrokken zijn.</p>
<div class="checkbox-group">
<?php foreach ($users as $user): ?>
<label class="checkbox-label">
<input type="checkbox"
name="participants[]"
value="<?= $user['id'] ?>"
<?= (in_array($user['id'], $selectedParticipants)) ? 'checked' : '' ?>>
<?= htmlspecialchars($user['display_name']) ?>
</label>
<?php endforeach; ?>
</div>
<p class="help-text">Selecteer minstens één deelnemer</p>
<?php if (isset($errors['participants'])): ?>
<span class="error-message"><?= htmlspecialchars($errors['participants']) ?></span>
<?php endif; ?>
</div>
</div>
<div class="form-row">
<div class="form-group full-width">
<label for="image">Afbeelding *</label>
<div class="file-upload-wrapper">
<input type="file"
name="image"
id="image"
accept="image/*"
required>
<label for="image" class="file-upload-label">
<span class="icon">📷</span> Kies een afbeelding
</label>
</div>
<p class="help-text file-upload-info">
Upload een foto bij het artikel. Zowel landscape als portret foto's worden ondersteund.
</p>
<?php if ($uploadedImage): ?>
<div class="image-preview mt-20">
<div class="image-preview-container">
<img src="/<?= htmlspecialchars($uploadedImage) ?>" alt="Geselecteerde afbeelding">
</div>
<p class="text-muted mt-10">Afbeelding succesvol geupload: <?= htmlspecialchars(basename($uploadedImage)) ?></p>
</div>
<?php endif; ?>
<?php if (isset($errors['image'])): ?>
<span class="error-message"><?= htmlspecialchars($errors['image']) ?></span>
<?php endif; ?>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<span class="icon"><?= $article ? '✓' : '+' ?></span>
<?= $article ? 'Artikel Bijwerken' : 'Artikel Indienen' ?>
</button>
<a href="/" class="btn btn-secondary">Annuleren</a>
</div>
</form>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layouts/main.php';
?>
+34
View File
@@ -0,0 +1,34 @@
<?php
$page_title = 'Artikel Indienen - Beveiliging';
ob_start();
?>
<div class="passphrase-container slide-up">
<h2 class="title">Beveiligingscontrole</h2>
<p>Voer de familypaszin in om een artikel in te dienen:</p>
<form action="/submit/verify" method="post">
<div class="form-group">
<label for="passphrase">Familie Paszin</label>
<input type="password"
name="passphrase"
id="passphrase"
class="form-control"
required
autocomplete="off"
placeholder="Voer de paszin in">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<span class="icon">→</span> Doorgaan
</button>
<a href="/" class="btn btn-secondary">Annuleren</a>
</div>
</form>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layouts/main.php';
?>
+51
View File
@@ -0,0 +1,51 @@
<?php
$page_title = 'Verifieer Schrijver';
ob_start();
?>
<div class="writer-verification slide-up">
<h2 class="title">Verifieer Schrijver</h2>
<?php if ($article): ?>
<div class="card mb-30">
<h4 class="card-title"><?= htmlspecialchars($article['title']) ?></h4>
<p class="text-muted">
Om dit artikel te bewerken, bevestig aub wie de schrijver is.
</p>
</div>
<?php endif; ?>
<form action="/submit/edit/verify-writer" method="post">
<input type="hidden" name="article_id" value="<?= htmlspecialchars($article['id'] ?? '') ?>">
<div class="form-group">
<label for="writer_id">Selecteer de Schrijver *</label>
<select name="writer_id"
id="writer_id"
required
class="form-control">
<option value="">-- Selecteer de schrijver --</option>
<?php foreach ($users as $user): ?>
<option value="<?= $user['id'] ?>"
<?= ($article && $article['author_id'] == $user['id']) ? 'selected' : '' ?>>
<?= htmlspecialchars($user['display_name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<span class="icon">→</span> Verifieer en Bewerk
</button>
<a href="/submit/edit/list" class="btn btn-secondary">
<span class="icon">←</span> Terug naar Lijst
</a>
</div>
</form>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layouts/main.php';
?>
View File
+54
View File
@@ -0,0 +1,54 @@
\documentclass{article}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage{microtype}
\usepackage{newspaper}
\usepackage[dutch]{babel}
\date{\today}
\currentvolume{1}
\currentissue{1}
%% The banner headline on the first page
%% (The colon after s: is to get a more
%% modern majuscule s in this font instead of
%% the medieval tall s.
\SetPaperName{Proo{s:}tenKrant}
%% The name used in the running header after
%% the first page
\SetHeaderName{ProostenKrant}
%% and also...
\SetPaperLocation{Regio Kempen}
\SetPaperSlogan{``Een krant van en voor Proosten.''}
\SetPaperPrice{NUL EURO}
\usepackage{graphicx}
\usepackage{tikz}
\usepackage{multicol}
\usepackage{geometry}
\usepackage{array}
\usepackage{wrapfig}
\usepackage{lipsum}
\usepackage{newtxtext,newtxmath}
\usepackage{etoolbox}
% Custom styling for better image handling
\input{newspaper-mod}
%%%%%%%%% Front matter %%%%%%%%%%
\begin{document}
\maketitle
\begin{multicols}{3}
% Articles will be inserted here by the PHP generator
\newpage
\end{multicols}
\end{document}
+119
View File
@@ -0,0 +1,119 @@
% Improved newspaper-mod.sty for ProostenKrant
% Better image handling for different formats and sizes
\newcommand{\headlinestyle}{\itshape\huge}
\newcommand{\bylinestyle}{\scshape\Large}
\patchcmd{\headline}{#1}{\headlinestyle #1}{}{}
\patchcmd{\byline}{#1}{\bylinestyle #1}{}{}
% Improved image commands with better handling of different aspect ratios
% Show inline image with caption (automatically handles landscape and portrait)
\newcommand{\showInlineImage}[2]
{
\\\n \hspace*{0pt}% Ensure proper spacing
\vspace*{10pt}% Add space before image
\begin{center}
\begin{minipage}{0.95\columnwidth}
\centering
% Use max dimensions that fit in column
\setlength{\fboxsep}{5pt}
\framebox{
% Check image dimensions and scale appropriately
\includegraphics[max width=\linewidth, max height=0.6\textheight, keepaspectratio]{#1}
}
\vspace*{5pt}
{\small #2}
\end{minipage}
\end{center}
\vspace*{10pt}% Add space after image
\par
}
% Show single writer with circular profile image
\newcommand{\showOneWriter}[2]
{
\\\n \hfill
\begin{minipage}[t]{0.4\columnwidth}
\centering
\begin{tikzpicture}
% Circular clip for profile image
\clip (0,0) circle (1.3cm);
% Use square aspect ratio and scale to fit
\node[anchor=center] at (0,0) {
\includegraphics[width=2.8cm, height=2.8cm, keepaspectratio]{#1}
};
\end{tikzpicture}
\vspace*{5pt}
{\small #2}
\end{minipage}
\hfill
\\\n}
% Show two writers with circular profile images
\newcommand{\showTwoWriters}[4]
{
\\\n \begin{center}
\begin{minipage}[t]{0.8\columnwidth}
\centering
\begin{tikzpicture}
\clip (0,0) circle (1.1cm);
\node[anchor=west] at (0,0) {
\includegraphics[width=2.4cm, height=2.4cm, keepaspectratio]{#1}
};
\end{tikzpicture}
\hspace*{15pt}
\begin{tikzpicture}
\clip (0,0) circle (1.1cm);
\node[anchor=east] at (0,0) {
\includegraphics[width=2.4cm, height=2.4cm, keepaspectratio]{#3}
};
\end{tikzpicture}
\vspace*{8pt}
{\small #2 \\quad #4}
\end{minipage}
\end{center}
\\\n}
% Alternative commands for different layouts
% For portrait images - maintains aspect ratio
\newcommand{\showPortraitImage}[2]
{
\\\n \begin{minipage}[t]{0.45\columnwidth}
\centering
\begin{tikzpicture}
% For portrait images, use vertical layout
\clip (0,-1.5cm) rectangle (2cm,1.5cm);
\node[anchor=center] at (1cm,0) {
\includegraphics[height=3cm, keepaspectratio]{#1}
};
\end{tikzpicture}
\vspace*{5pt}
{\small #2}
\end{minipage}
\\\n}
% For landscape images - maintains aspect ratio
\newcommand{\showLandscapeImage}[2]
{
\\\n \begin{minipage}[t]{\columnwidth}
\centering
% For landscape images, use full width
\includegraphics[width=\linewidth, height=0.4\textheight, keepaspectratio]{#1}
\vspace*{5pt}
{\small #2}
\end{minipage}
\\\n}
% Flexible image wrapper that automatically handles different orientations
\newcommand{\showFlexibleImage}[2]
{
% First, we need to check the image dimensions
% This requires the epstopdf package and shell-escape
\IfFileExists{#1}{
% Image exists, try to determine orientation
% For now, we'll use a balanced approach that works for both
\showInlineImage{#1}{#2}
}{}
}
+58
View File
@@ -0,0 +1,58 @@
# ProostenKrant — Blazor Server stack
# Gebruik: docker compose up -d --build
# App: http://localhost:8080
name: proostenkrant
services:
app:
build:
context: .
dockerfile: deploy/Dockerfile
container_name: proostenkrant-app
ports:
- "8080:8080"
environment:
DB_HOST: mysql
DB_NAME: ${DB_NAME:-proostenkrant}
DB_USER: ${DB_USER:-proostenkrant}
DB_PASSWORD: ${DB_PASSWORD:-proostenkrant}
UNIVERSAL_PASSWORD: ${UNIVERSAL_PASSWORD:-ProostenKrant2024!}
HOOGTES_BEWAREN: ${HOOGTES_BEWAREN:-0}
volumes:
# Bestaande artikel- en profielfoto's hergebruiken
- ./app/public/images/articles:/app/wwwroot/uploads/articles
- ./app/public/images/profiles:/app/wwwroot/uploads/profiles
# Gedrukte kranten (PDF's) op de host bewaren
- ./app/storage/latex:/app/storage/latex
# Permanente archief-edities (oude kranten)
- ./resources/archive:/app/archive
# Profielfoto's uit de resources-map (o.a. nieuwe familieleden)
- ./resources/images:/app/wwwroot/resources-images
depends_on:
mysql:
condition: service_healthy
restart: unless-stopped
mysql:
image: mysql:8.0
container_name: proostenkrant-mysql
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: ${DB_NAME:-proostenkrant}
MYSQL_USER: ${DB_USER:-proostenkrant}
MYSQL_PASSWORD: ${DB_PASSWORD:-proostenkrant}
volumes:
- mysql-data:/var/lib/mysql
- ./deploy/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-prootpassword"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
volumes:
mysql-data:
+46
View File
@@ -0,0 +1,46 @@
# ProostenKrant — Blazor Server + LaTeX
# De texlive-installatie staat in een eigen, vroege laag van de runtime-image,
# zodat app-wijzigingen de (trage) LaTeX-installatie niet opnieuw triggeren.
# ---------- Build ----------
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY src/ProostenKrant/ProostenKrant.csproj ./ProostenKrant/
RUN dotnet restore ProostenKrant/ProostenKrant.csproj
COPY src/ProostenKrant/ ./ProostenKrant/
RUN dotnet publish ProostenKrant/ProostenKrant.csproj -c Release -o /app/publish
# ---------- Runtime ----------
FROM mcr.microsoft.com/dotnet/aspnet:10.0
# LaTeX (eenmalig geinstalleerd, daarna door Docker-laagcache stabiel)
RUN apt-get update && apt-get install -y --no-install-recommends \
texlive-latex-base \
texlive-latex-recommended \
texlive-latex-extra \
texlive-fonts-recommended \
texlive-fonts-extra \
texlive-publishers \
texlive-pictures \
texlive-lang-european \
ghostscript \
&& rm -rf /var/lib/apt/lists/*
# binhex.tex (nodig voor newtx) zit in texlive-plain-generic.
# Aparte laag: wijzigingen hierboven herinstalleren de grote texlive-laag niet.
RUN apt-get update && apt-get install -y --no-install-recommends \
texlive-plain-generic \
&& rm -rf /var/lib/apt/lists/*
ENV ASPNETCORE_URLS=http://+:8080 \
ASPNETCORE_ENVIRONMENT=Production
WORKDIR /app
COPY --from=build /app/publish .
RUN mkdir -p /app/wwwroot/uploads/articles /app/wwwroot/uploads/profiles /app/storage/latex
EXPOSE 8080
ENTRYPOINT ["dotnet", "ProostenKrant.dll"]
+17
View File
@@ -0,0 +1,17 @@
FROM texlive/texlive:latest
RUN apt-get update && apt-get install -y \
curl \
wget \
ghostscript \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /var/www
# LaTeX is already installed in the texlive image
# No need to install additional packages
EXPOSE 80
CMD ["/bin/bash"]
+9
View File
@@ -0,0 +1,9 @@
FROM nginx:alpine
RUN apk add --no-cache curl
COPY docker/nginx/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+18
View File
@@ -0,0 +1,18 @@
server {
listen 80;
server_name localhost;
root /var/www/html/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
}
+42
View File
@@ -0,0 +1,42 @@
FROM php:8.2-fpm-alpine
# Install dependencies for GD extension
RUN apk add --no-cache --virtual .build-deps \
freetype-dev \
libjpeg-turbo-dev \
libpng-dev \
libwebp-dev \
zlib-dev
# Install and enable GD extension with all image format support
# Note: --with-png and --with-jpeg are now built-in, only need freetype and webp
RUN docker-php-ext-configure gd \
--with-freetype \
--with-webp
# Install PHP extensions
RUN docker-php-ext-install -j$(nproc) \
gd \
mysqli \
pdo \
pdo_mysql
# Clean up build dependencies
RUN apk del .build-deps
# Also install runtime libraries for GD
RUN apk add --no-cache \
freetype \
libjpeg-turbo \
libpng \
libwebp \
zlib
# Copy PHP configuration
COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini
WORKDIR /var/www/html
EXPOSE 9000
CMD ["php-fpm"]
+7
View File
@@ -0,0 +1,7 @@
upload_max_filesize = 50M
post_max_size = 52M
memory_limit = 512M
max_execution_time = 300
display_errors = On
error_reporting = E_ALL
date.timezone = Europe/Brussels
+21
View File
@@ -0,0 +1,21 @@
Herfstwandeling in het Hallerbos|Zaterdagochtend trokken Douwe en Pauline eropuit voor een lange herfstwandeling in het Hallerbos. Het bos kleurde prachtig goudgeel en de paden lagen bezaaid met kastanjes. Onderweg picknikten ze bij de oude jachthut, waar Pauline verrassend goede thermoskoffie bleek te hebben meegenomen. Na ruim drie uur kwamen ze voldaan weer thuis. Volgende maand willen ze het bos nog eens bezoeken, dan met de kleine kinderen mee.|Douwe|Douwe,Pauline|2026-10-06
Cedric loopt zijn eerste halve marathon|Het is gelukt: Cedric heeft zondag zijn allereerste halve marathon uitgelopen in Kasterlee. Na maandenlange training in de vroege ochtenduren finishte hij in twee uur en acht minuten, wat niemand had verwacht. Langs de finish stonden Geert en Oma Dien hem luidkeels aan te moedigen. Ter afsluiting was er natuurlijk een stevige portie pasta bij de familie thuis.|Geert|Cedric,Geert|2026-10-12
De pompoensoep van Oma Dien is niet te evenaren|Met de eerste koele dagen van het najaar verschijnt hij weer op tafel: de beroemde pompoensoep van Oma Dien. Dit jaar gebruikte ze pompoenen uit eigen tuin, die volgens kenners de beste van de Broekstraat zijn. Het geheime recept gaat van generatie op generatie, maar wie denkt dat het alleen om de pompoen gaat, heeft het mis. De kruidnagel en een scheutje room maken het verschil.|Dien|Dien|2026-10-03
Weekend in de Ardennen met de neven|Gunter en Daan trokken een lang weekend naar de Ardennen voor hun jaarlijkse broertjesuitje. De wandeltocht langs de Ourthe was zwaar maar prachtig, en de avonden bij de open haard in de gite maakten alles goed. Op zondag bezochten ze nog de grotten van Han, waar Daan zoals altijd de gids verbeterde. Volgend jaar hopen ze de Vogelberg te beklimmen.|Gunter|Gunter,Daan|2026-10-17
Luc haalt zijn duikbrevet|Groot nieuws uit de duikschool: Luc heeft met lof zijn duikbrevet gehaald. Na maanden oefenen in het binnenbad van Herentals was de proefduik in de Nete een groot succes. De zichtbaarheid onder water was verrassend goed, al bleef het koud volgens de beginnende duiker. Ter beloning ging de hele bende eten bij de frituur, wat volgens Luc het beste deel van de dag was.|Mathilde|Mathilde,Luc|2026-10-05
Appelpluk bij boer Janssens|Zondag werd er massaal appels geplukt bij boer Janssens. Chris en Geert kregen hulp van de hele bende en brachten ruim veertig kilo appels naar huis. Volgens Chris was het de beste oogst in jaren, dankzij de zachte zomer. In de komende weken staat er dus appelmoes, appeltaart en vers geperst sap op het menu. De kinderen mochten in de hooiberg spelen.|Chris|Chris,Geert|2026-09-27
Kerstmarkt in Keulen met de trein|Pauline en Douwe zijn traditiegetrouw per trein naar de kerstmarkt in Keulen gereisd. De Dom stak prachtig af tegen de sneeuw die zaterdag viel, en de gluhwein was zoals elk jaar weer stevig lekker. Douwe kocht een onnodig maar magnifiek houten klokje op de handwerkmarkt. Na een lange dag kwamen ze laat maar voldaan terug in het Kempenland.|Pauline|Pauline,Douwe|2026-11-21
Mathilde op kamers in Leuven|Een nieuw hoofdstuk voor Mathilde: ze is begonnen met haar studies in Leuven en woont nu op een studentenkamer boven de Oude Markt. De verhuizing verliep vlot, al moest er flink geboend worden voordat het gezellig werd. In het weekend komt ze geregeld naar huis om bij te slapen en de was te doen, netjes volgens de familiegebruiken.|Mathilde|Mathilde|2026-09-28
De renovatie van de Broekstraat is eindelijk af|Na zes maanden stof, steigers en geduld is de renovatie van de Broekstraat officieel afgerond. Kevin coördineerde het hele traject en toont zich trots op het resultaat. De nieuwe gevels zijn terug in de originele kleuren geschilderd en de straatklinkers zijn compleet vernieuwd. De buren organiseerden een gezellige straatfeest, waar tot laat in de avond werd nagepraat.|Kevin|Kevin,Chris|2026-10-09
Sinterklaas bij de Proostens|Ook dit jaar wist Sinterklaas de Broekstraat weer feilloos te vinden. De kinderen zongen luidkeels hun liederen en de pepernoten vlogen in het rond. Kyala en Michel hadden de zolder weer omgetoverd tot een ware surpriseschuur. Voor Tilde was er een echte racebaan, en de volwassenen kregen zoals elk jaar gedichten die niemand ongestraft overleeft.|Kyala|Kyala,Michel,Tilde|2026-12-05
Pieter en de oude tractor|Pieter heeft dit najaar een ware schat gerestaureerd: een aftandse oldtimer tractor uit 1962, gevonden op een veiling in Peer. Maandenlang prutste hij elke zaterdag in de schuur, tot de motor onlangs weer aansloeg. De tractor trekt inmiddels hout aan voor de open haard van de hele familie. Volgende lente wil hij ermee naar de trekkermeeting in Bree.|Pieter|Pieter|2026-10-11
Dagje Düsseldorf met de trein|Dries pakte de vroege trein voor een dagje shoppen in Düsseldorf. De Konigsallee was een walhalla, al bleef zijn portemonnee verrassend gesloten. Op de terugweg viel hij in slaap en reed hij bijna zijn halte voorbij, wat zijn mede-reizigers deed grijnzen. Een geslaagd dagje volgens de vaste normen van de familie.|Dries|Dries,Kyala|2026-10-18
Boomhut timmeren in de achtertuin|Kevin en Luc zijn een weekend bezig geweest met de bouw van een echte boomhut in de grote eik achteraan de tuin. Het plan werd zorgvuldig getekend op een pak cornflakes. Na wat geschuifel met planken en vele koppen limonade staat er nu een fort om trots op te zijn. De opening was gisteren, met linten en een feestelijke ontvangst voor de buurtkinderen.|Kevin|Kevin,Luc|2026-10-25
De tuin winterklaar maken|Zondag werd de laatste hand aan de wintervoorbereiding gelegd: de tuin is officieel winterklaar. Dien en Chris sneden de hagen, ruimden het onkruid en de vijver kreeg een winterdek. De laatste chrysanten staan nog in bloei en kleuren de moestuin verrassend feestelijk. Volgens insiders wordt het een strenge winter, dus de kachelhoutvoorraad is aanzienlijk.|Dien|Dien,Chris|2026-11-08
Opa wordt tachtig, en het hele dier komt feesten|Het was een dag om in te lijsten: Oma Dien en Opa werden verrast met een knalfeest ter gelegenheid van zijn tachtigste verjaardag. De garage was omgebouwd tot feestzaal en de gasten stonden tot op straat. Er was muziek van de fanfare, een zelfgebakken taart van drie verdiepingen en speechen die het ochtendjournaal haalden. Opa hield het droog, al kwamen de zakdoeken wel tevoorschijn.|Gunter|Gunter,Daan,Kyala,Michel|2026-10-30
Cedric pakt brons op de judocompetitie|Het judoseizoen is goed begonnen: Cedric pakte zondag brons op de interclubcompetitie in Turnhout. Na twee gewonnen gevechten volgde een spannende beslissingswedstrijd die net niet werd gehaald. Trainer Geert noemde het de beste techniek van de dag. Volgende maand staat de beker van Kempen op het programma, waar de familie uiteraard weer massaal aanwezig zal zijn.|Geert|Cedric,Geert|2026-11-15
Kerstboom kopen in de eerste sneeuw|Met de eerste echte sneeuw van het seizoen erbij was de kerstboommarkt een sprookje. Douwe en Pauline kozen dit jaar weer een echte Nordman, ditmaal eentje van ruim tweeeneenhalf meter, wat een heuse discussie gaf over de auto. Thuis versierd met de oude ballen van Bomma en nieuwe lichtjes uit de kraam van de scouting. De katten zijn dolblij met de nieuwe klimboom.|Douwe|Douwe,Pauline|2026-12-12
Kyala start als teamleider bij de bank|Goed nieuws uit het bankwezen: Kyala is per dit najaar bevorderd tot teamleider van het kantoor in Retie. De familie was niet verbaasd; wie Kyala kent weet dat ze overal de leiding neemt. De afdeling werd alvast omgetoverd met planten en een echt koffiezetapparaat. Over een promotiebonus wordt gefluisterd, maar daarover is nog niets bekendgemaakt.|Kyala|Kyala|2026-10-02
Zondagsrit naar de kust|Michel en Mathilde pakten de fiets voor een lange zondagsrit naar de kust. Via de kanalen en over de veer bij Mariekerke bereikten ze Oostende in drie uur. Er werd vis gegeten aan de grote kaai, en het ijsje op de zeedijk was niet aan de familie te besteden. Op de terugweg deden ze nog de steenweg van Blankenberge aan.|Michel|Michel,Mathilde|2026-10-19
Voorbereidingen voor de nieuwjaarsduik|Het is weer bijna zover: de jaarlijkse nieuwjaarsduik in het kanaal. Daan en Gunter trainen al weken, in badpak en met een ernstig gezicht. Er wordt gewed op wie dit jaar het langste in het ijskoude water durft te blijven. De wafels en de jenever waren alvast veiliggesteld bij de plaatselijke bakker.|Daan|Daan,Gunter|2026-12-13
Tilde blitst de oudekoektrommel op|Tilde heeft met man en macht de oude koektrommel van Bomma teruggevonden en geschonken laten herstellen. De koperen rand glimt weer als een spiegel en volgens de kenners is dit het mooiste stuk van de hele collectie. Bij de onthulling werd er natuurlijk gevulde koek geserveerd. Wie de trommel ooit weer leeg zag, deed een gooi naar het verhaal van de kerst van 1994.|Tilde|Tilde,Dien|2026-11-01
1 Herfstwandeling in het Hallerbos|Zaterdagochtend trokken Douwe en Pauline eropuit voor een lange herfstwandeling in het Hallerbos. Het bos kleurde prachtig goudgeel en de paden lagen bezaaid met kastanjes. Onderweg picknikten ze bij de oude jachthut, waar Pauline verrassend goede thermoskoffie bleek te hebben meegenomen. Na ruim drie uur kwamen ze voldaan weer thuis. Volgende maand willen ze het bos nog eens bezoeken, dan met de kleine kinderen mee.|Douwe|Douwe,Pauline|2026-10-06
2 Cedric loopt zijn eerste halve marathon|Het is gelukt: Cedric heeft zondag zijn allereerste halve marathon uitgelopen in Kasterlee. Na maandenlange training in de vroege ochtenduren finishte hij in twee uur en acht minuten, wat niemand had verwacht. Langs de finish stonden Geert en Oma Dien hem luidkeels aan te moedigen. Ter afsluiting was er natuurlijk een stevige portie pasta bij de familie thuis.|Geert|Cedric,Geert|2026-10-12
3 De pompoensoep van Oma Dien is niet te evenaren|Met de eerste koele dagen van het najaar verschijnt hij weer op tafel: de beroemde pompoensoep van Oma Dien. Dit jaar gebruikte ze pompoenen uit eigen tuin, die volgens kenners de beste van de Broekstraat zijn. Het geheime recept gaat van generatie op generatie, maar wie denkt dat het alleen om de pompoen gaat, heeft het mis. De kruidnagel en een scheutje room maken het verschil.|Dien|Dien|2026-10-03
4 Weekend in de Ardennen met de neven|Gunter en Daan trokken een lang weekend naar de Ardennen voor hun jaarlijkse broertjesuitje. De wandeltocht langs de Ourthe was zwaar maar prachtig, en de avonden bij de open haard in de gite maakten alles goed. Op zondag bezochten ze nog de grotten van Han, waar Daan zoals altijd de gids verbeterde. Volgend jaar hopen ze de Vogelberg te beklimmen.|Gunter|Gunter,Daan|2026-10-17
5 Luc haalt zijn duikbrevet|Groot nieuws uit de duikschool: Luc heeft met lof zijn duikbrevet gehaald. Na maanden oefenen in het binnenbad van Herentals was de proefduik in de Nete een groot succes. De zichtbaarheid onder water was verrassend goed, al bleef het koud volgens de beginnende duiker. Ter beloning ging de hele bende eten bij de frituur, wat volgens Luc het beste deel van de dag was.|Mathilde|Mathilde,Luc|2026-10-05
6 Appelpluk bij boer Janssens|Zondag werd er massaal appels geplukt bij boer Janssens. Chris en Geert kregen hulp van de hele bende en brachten ruim veertig kilo appels naar huis. Volgens Chris was het de beste oogst in jaren, dankzij de zachte zomer. In de komende weken staat er dus appelmoes, appeltaart en vers geperst sap op het menu. De kinderen mochten in de hooiberg spelen.|Chris|Chris,Geert|2026-09-27
7 Kerstmarkt in Keulen met de trein|Pauline en Douwe zijn traditiegetrouw per trein naar de kerstmarkt in Keulen gereisd. De Dom stak prachtig af tegen de sneeuw die zaterdag viel, en de gluhwein was zoals elk jaar weer stevig lekker. Douwe kocht een onnodig maar magnifiek houten klokje op de handwerkmarkt. Na een lange dag kwamen ze laat maar voldaan terug in het Kempenland.|Pauline|Pauline,Douwe|2026-11-21
8 Mathilde op kamers in Leuven|Een nieuw hoofdstuk voor Mathilde: ze is begonnen met haar studies in Leuven en woont nu op een studentenkamer boven de Oude Markt. De verhuizing verliep vlot, al moest er flink geboend worden voordat het gezellig werd. In het weekend komt ze geregeld naar huis om bij te slapen en de was te doen, netjes volgens de familiegebruiken.|Mathilde|Mathilde|2026-09-28
9 De renovatie van de Broekstraat is eindelijk af|Na zes maanden stof, steigers en geduld is de renovatie van de Broekstraat officieel afgerond. Kevin coördineerde het hele traject en toont zich trots op het resultaat. De nieuwe gevels zijn terug in de originele kleuren geschilderd en de straatklinkers zijn compleet vernieuwd. De buren organiseerden een gezellige straatfeest, waar tot laat in de avond werd nagepraat.|Kevin|Kevin,Chris|2026-10-09
10 Sinterklaas bij de Proostens|Ook dit jaar wist Sinterklaas de Broekstraat weer feilloos te vinden. De kinderen zongen luidkeels hun liederen en de pepernoten vlogen in het rond. Kyala en Michel hadden de zolder weer omgetoverd tot een ware surpriseschuur. Voor Tilde was er een echte racebaan, en de volwassenen kregen zoals elk jaar gedichten die niemand ongestraft overleeft.|Kyala|Kyala,Michel,Tilde|2026-12-05
11 Pieter en de oude tractor|Pieter heeft dit najaar een ware schat gerestaureerd: een aftandse oldtimer tractor uit 1962, gevonden op een veiling in Peer. Maandenlang prutste hij elke zaterdag in de schuur, tot de motor onlangs weer aansloeg. De tractor trekt inmiddels hout aan voor de open haard van de hele familie. Volgende lente wil hij ermee naar de trekkermeeting in Bree.|Pieter|Pieter|2026-10-11
12 Dagje Düsseldorf met de trein|Dries pakte de vroege trein voor een dagje shoppen in Düsseldorf. De Konigsallee was een walhalla, al bleef zijn portemonnee verrassend gesloten. Op de terugweg viel hij in slaap en reed hij bijna zijn halte voorbij, wat zijn mede-reizigers deed grijnzen. Een geslaagd dagje volgens de vaste normen van de familie.|Dries|Dries,Kyala|2026-10-18
13 Boomhut timmeren in de achtertuin|Kevin en Luc zijn een weekend bezig geweest met de bouw van een echte boomhut in de grote eik achteraan de tuin. Het plan werd zorgvuldig getekend op een pak cornflakes. Na wat geschuifel met planken en vele koppen limonade staat er nu een fort om trots op te zijn. De opening was gisteren, met linten en een feestelijke ontvangst voor de buurtkinderen.|Kevin|Kevin,Luc|2026-10-25
14 De tuin winterklaar maken|Zondag werd de laatste hand aan de wintervoorbereiding gelegd: de tuin is officieel winterklaar. Dien en Chris sneden de hagen, ruimden het onkruid en de vijver kreeg een winterdek. De laatste chrysanten staan nog in bloei en kleuren de moestuin verrassend feestelijk. Volgens insiders wordt het een strenge winter, dus de kachelhoutvoorraad is aanzienlijk.|Dien|Dien,Chris|2026-11-08
15 Opa wordt tachtig, en het hele dier komt feesten|Het was een dag om in te lijsten: Oma Dien en Opa werden verrast met een knalfeest ter gelegenheid van zijn tachtigste verjaardag. De garage was omgebouwd tot feestzaal en de gasten stonden tot op straat. Er was muziek van de fanfare, een zelfgebakken taart van drie verdiepingen en speechen die het ochtendjournaal haalden. Opa hield het droog, al kwamen de zakdoeken wel tevoorschijn.|Gunter|Gunter,Daan,Kyala,Michel|2026-10-30
16 Cedric pakt brons op de judocompetitie|Het judoseizoen is goed begonnen: Cedric pakte zondag brons op de interclubcompetitie in Turnhout. Na twee gewonnen gevechten volgde een spannende beslissingswedstrijd die net niet werd gehaald. Trainer Geert noemde het de beste techniek van de dag. Volgende maand staat de beker van Kempen op het programma, waar de familie uiteraard weer massaal aanwezig zal zijn.|Geert|Cedric,Geert|2026-11-15
17 Kerstboom kopen in de eerste sneeuw|Met de eerste echte sneeuw van het seizoen erbij was de kerstboommarkt een sprookje. Douwe en Pauline kozen dit jaar weer een echte Nordman, ditmaal eentje van ruim tweeeneenhalf meter, wat een heuse discussie gaf over de auto. Thuis versierd met de oude ballen van Bomma en nieuwe lichtjes uit de kraam van de scouting. De katten zijn dolblij met de nieuwe klimboom.|Douwe|Douwe,Pauline|2026-12-12
18 Kyala start als teamleider bij de bank|Goed nieuws uit het bankwezen: Kyala is per dit najaar bevorderd tot teamleider van het kantoor in Retie. De familie was niet verbaasd; wie Kyala kent weet dat ze overal de leiding neemt. De afdeling werd alvast omgetoverd met planten en een echt koffiezetapparaat. Over een promotiebonus wordt gefluisterd, maar daarover is nog niets bekendgemaakt.|Kyala|Kyala|2026-10-02
19 Zondagsrit naar de kust|Michel en Mathilde pakten de fiets voor een lange zondagsrit naar de kust. Via de kanalen en over de veer bij Mariekerke bereikten ze Oostende in drie uur. Er werd vis gegeten aan de grote kaai, en het ijsje op de zeedijk was niet aan de familie te besteden. Op de terugweg deden ze nog de steenweg van Blankenberge aan.|Michel|Michel,Mathilde|2026-10-19
20 Voorbereidingen voor de nieuwjaarsduik|Het is weer bijna zover: de jaarlijkse nieuwjaarsduik in het kanaal. Daan en Gunter trainen al weken, in badpak en met een ernstig gezicht. Er wordt gewed op wie dit jaar het langste in het ijskoude water durft te blijven. De wafels en de jenever waren alvast veiliggesteld bij de plaatselijke bakker.|Daan|Daan,Gunter|2026-12-13
21 Tilde blitst de oudekoektrommel op|Tilde heeft met man en macht de oude koektrommel van Bomma teruggevonden en geschonken laten herstellen. De koperen rand glimt weer als een spiegel en volgens de kenners is dit het mooiste stuk van de hele collectie. Bij de onthulling werd er natuurlijk gevulde koek geserveerd. Wie de trommel ooit weer leeg zag, deed een gooi naar het verhaal van de kerst van 1994.|Tilde|Tilde,Dien|2026-11-01
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# Simuleert 20 familiezendingen via de echte website-flow (paszin -> formulier),
# publiceert ze als redactie en drukt de Herfst-editie.
set -u
BASE=http://localhost:8080
PASS="ProostenKrant2024!"
JAR=/tmp/sim.cookies
IMG1="/home/douwe/Projects/proostenkrant.net/app/public/images/articles/930c7e6f_ScreenshotFrom20260806141156.png"
IMG2="/home/douwe/Projects/proostenkrant.net/app/public/images/articles/3b901c4e_ScreenshotFrom20260212232013.png"
get_token() {
curl -s -b "$JAR" -c "$JAR" "$BASE$1" -o "$2"
TOKEN=$(grep -o 'name="__RequestVerificationToken"[^>]*value="[^"]*"' "$2" | head -1 | sed 's/.*value="//;s/"$//')
HANDLER=$(grep -o 'name="_handler" value="[^"]*"' "$2" | head -1 | sed 's/.*value="//;s/"$//')
}
echo "=== Opruimen: testartikelen van eerder ==="
docker exec proostenkrant-mysql mysql -u proostenkrant -pproostenkrant proostenkrant -e "TRUNCATE TABLE articles;" 2>/dev/null
echo "=== 20 artikelen indienen via /indienen ==="
n=0
declare -A UID_MAP
while IFS=$'\t' read -r naam id; do
UID_MAP["$naam"]="$id"
done < <(docker exec proostenkrant-mysql mysql -N -u proostenkrant -pproostenkrant proostenkrant -e "SELECT display_name, id FROM users;" 2>/dev/null | tr -d ' ')
id_van() { echo "${UID_MAP[$1]:-0}"; }
while IFS='|' read -r TITEL TEKST SCHRIJVER DEELNEMERS DATUM; do
[ -z "$TITEL" ] && continue
n=$((n+1))
# paszin-flow per artikel (cookie wordt na elke indiening gewist)
rm -f "$JAR"
get_token "/indienen" /tmp/sim_pas.html
curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/indienen" \
--data-urlencode "_handler=$HANDLER" \
--data-urlencode "Input.Paszin=$PASS" \
--data-urlencode "__RequestVerificationToken=$TOKEN" \
-o /dev/null
get_token "/indienen/formulier" /tmp/sim_form.html
# wissel de twee dummy-afbeeldingen af
if [ $((n % 2)) -eq 1 ]; then IMG="$IMG1"; else IMG="$IMG2"; fi
IFS=',' read -ra NAMEN <<< "$DEELNEMERS"
ARGS=()
for d in "${NAMEN[@]}"; do ARGS+=(-F "Participants=$(id_van "$d")"); done
code=$(curl -s -b "$JAR" -X POST "$BASE/indienen/formulier" \
-F "_handler=$HANDLER" \
-F "Input.Title=$TITEL" \
-F "Input.Content=$TEKST" \
-F "Input.WriterId=$(id_van "$SCHRIJVER")" \
-F "Input.EventDate=$DATUM" \
"${ARGS[@]}" \
-F "Afbeelding=@$IMG" \
-F "__RequestVerificationToken=$TOKEN" \
-o /dev/null -w "%{http_code}")
echo "artikel $n: $code - $TITEL"
done < /home/douwe/Projects/proostenkrant.net/scripts/demo-articles.tsv
echo "=== Inloggen als redactie en publiceren ==="
rm -f "$JAR"
get_token "/login" /tmp/sim_login.html
curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/login" \
--data-urlencode "_handler=$HANDLER" \
--data-urlencode "Input.Username=douwe" \
--data-urlencode "Input.Password=$PASS" \
--data-urlencode "__RequestVerificationToken=$TOKEN" \
-o /dev/null
IDS=$(docker exec proostenkrant-mysql mysql -N -u proostenkrant -pproostenkrant proostenkrant -e "SELECT id FROM articles WHERE status='draft' ORDER BY id;" 2>/dev/null)
for id in $IDS; do
get_token "/artikelen/$id" /tmp/sim_art.html
code=$(curl -s -b "$JAR" -X POST "$BASE/artikelen/$id" \
--data-urlencode "_handler=$HANDLER" \
--data-urlencode "__RequestVerificationToken=$TOKEN" \
-o /dev/null -w "%{http_code}")
echo "publiceer $id: $code"
done
echo "=== Krant drukken (Herfst) ==="
get_token "/seizoenen" /tmp/sim_seiz.html
curl -s -b "$JAR" -X POST "$BASE/api/genereer-pdf" \
-F "SeizoenId=3" \
-F "__RequestVerificationToken=$TOKEN" \
-o /dev/null -w "drukken: %{http_code} -> %{redirect_url}\n" --max-time 240
ls -la /home/douwe/Projects/proostenkrant.net/app/storage/latex/*.pdf
+265
View File
@@ -0,0 +1,265 @@
#!/bin/bash
# ProostenKrant end-to-end regressietest (draait tegen een draaiende stack op localhost:8080).
# Gebruik: docker compose up -d && scripts/e2e-test.sh
# Let op: de test maakt artikelen aan in de database en drukt een proefeditie.
set -u
# ProostenKrant end-to-end test
set -u
BASE=http://localhost:8080
JAR=/tmp/krant.cookies
PASS="ProostenKrant2024!"
PASS_FAILED=0
ok() { echo "PASS: $1"; }
fail() { echo "FAIL: $1"; PASS_FAILED=1; }
check() { # desc, expected, actual
if [ "$2" = "$3" ]; then ok "$1"; else fail "$1 (expected [$2], got [$3])"; fi
}
contains() { # desc, file, pattern
if grep -q "$3" "$2"; then ok "$1"; else fail "$1 (pattern '$3' not found in $2)"; fi
}
notcontains() {
if ! grep -q "$3" "$2"; then ok "$1"; else fail "$1 (unexpected pattern '$3' in $2)"; fi
}
get_token() { # url, outfile -> sets TOKEN
curl -s -b "$JAR" -c "$JAR" "$BASE$1" -o "$2"
TOKEN=$(grep -o 'name="__RequestVerificationToken"[^>]*value="[^"]*"' "$2" | head -1 | sed 's/.*value="//;s/"$//')
HANDLER=$(grep -o 'name="_handler" value="[^"]*"' "$2" | head -1 | sed 's/.*value="//;s/"$//')
}
echo "=== 1. Publieke pagina's (anoniem) ==="
for p in "/" "/archief" "/indienen" "/login"; do
code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE$p")
check "GET $p" 200 "$code"
done
code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/dashboard")
check "GET /dashboard anoniem -> 302" 302 "$code"
echo "=== 2. Inloggen ==="
rm -f "$JAR"
get_token "/login" /tmp/t_login.html
curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/login" \
--data-urlencode "_handler=$HANDLER" \
--data-urlencode "Input.Username=douwe" \
--data-urlencode "Input.Password=$PASS" \
--data-urlencode "__RequestVerificationToken=$TOKEN" \
-o /dev/null -w ""
code=$(curl -s -b "$JAR" -o /tmp/t_dash.html -w "%{http_code}" "$BASE/dashboard")
check "GET /dashboard ingelogd" 200 "$code"
contains "dashboard toont welkom" /tmp/t_dash.html "Welkom, Douwe"
contains "dashboard toont stats" /tmp/t_dash.html "Kladartikelen"
echo "=== 2b. Fout wachtwoord ==="
rm -f "$JAR"
get_token "/login" /tmp/t_login.html
resp=$(curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/login" \
--data-urlencode "_handler=$HANDLER" \
--data-urlencode "Input.Username=douwe" \
--data-urlencode "Input.Password=fout" \
--data-urlencode "__RequestVerificationToken=$TOKEN" -o /tmp/t_loginbad.html -w "%{http_code}")
check "POST login met fout wachtwoord -> 200 met foutmelding" 200 "$resp"
contains "foutmelding tonen" /tmp/t_loginbad.html "Ongeldige gebruikersnaam of wachtwoord"
echo "=== 3. Opnieuw inloggen + artikel maken (redactie) ==="
rm -f "$JAR"
get_token "/login" /tmp/t_login.html
curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/login" \
--data-urlencode "_handler=$HANDLER" \
--data-urlencode "Input.Username=douwe" \
--data-urlencode "Input.Password=$PASS" \
--data-urlencode "__RequestVerificationToken=$TOKEN" -o /dev/null
get_token "/artikelen/nieuw" /tmp/t_nieuw.html
code=$(curl -s -b "$JAR" -X POST "$BASE/artikelen/nieuw" \
-F "_handler=$HANDLER" \
-F "Input.Title=Eerste artikel in de Blazor-krant" \
-F "Input.Content=Dit is de eerste testcase. De ProostenKrant is nu een Blazor-applicatie en dit artikel moet netjes in de database belanden." \
-F "Input.WriterId=1" \
-F "Input.EventDate=2026-10-01" \
-F "Input.SeasonId=3" \
-F "Input.Status=published" \
-F "Participants=1" -F "Participants=2" \
-F "Afbeelding=@/home/douwe/Projects/proostenkrant.net/app/public/images/articles/Marathon.jpg" \
-F "__RequestVerificationToken=$TOKEN" \
-o /dev/null -w "%{http_code}")
check "POST nieuw artikel -> 302" 302 "$code"
curl -s -b "$JAR" "$BASE/artikelen" -o /tmp/t_lijst.html
contains "artikel staat in de lijst" /tmp/t_lijst.html "Eerste artikel in de Blazor-krant"
echo "=== 4. Artikeldetail (publiek voor published) ==="
NIEUWID=$(grep -o 'href="/artikelen/[0-9]*">Eerste artikel in de Blazor-krant' /tmp/t_lijst.html | grep -o '[0-9]*' | head -1)
code=$(curl -s -o /tmp/t_art.html -w "%{http_code}" "$BASE/artikelen/$NIEUWID")
check "GET /artikelen/$NIEUWID anoniem" 200 "$code"
contains "artikelkop" /tmp/t_art.html "Eerste artikel in de Blazor-krant"
contains "deelnemers avatars" /tmp/t_art.html "participant-avatar"
if grep -q "uploads/articles" /tmp/t_art.html; then ok "artikelafbeelding geupload"; else fail "artikelafbeelding geupload"; fi
echo "=== 5. Seizoenen + PDF genereren ==="
curl -s -b "$JAR" "$BASE/seizoenen" -o /tmp/t_seizoenen.html
contains "seizoenenoverzicht" /tmp/t_seizoenen.html "Druk deze editie"
get_token "/seizoenen" /tmp/t_seizoenen.html
code=$(curl -s -b "$JAR" -X POST "$BASE/api/genereer-pdf" \
-F "SeizoenId=3" \
-F "__RequestVerificationToken=$TOKEN" \
-o /dev/null -w "%{http_code}" --max-time 240)
check "POST druk Herfst-editie -> 302" 302 "$code"
PDF=$(ls -t /home/douwe/Projects/proostenkrant.net/app/storage/latex/*.pdf 2>/dev/null | head -1)
if [ -n "$PDF" ] && [ -f "$PDF" ]; then ok "PDF gegenereerd: $(basename "$PDF")"; else fail "PDF gegenereerd"; PDF=""; fi
if [ -n "$PDF" ]; then
head -c 4 "$PDF" | grep -q "%PDF" && ok "PDF header geldig" || fail "PDF header geldig"
size=$(stat -c%s "$PDF")
[ "$size" -gt 20000 ] && ok "PDF grootte redelijk ($size b)" || fail "PDF grootte ($size b)"
fi
echo "=== 6. Editie downloaden en archief ==="
if [ -n "$PDF" ]; then
code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/edities/$(basename "$PDF")")
check "GET /edities/PDF" 200 "$code"
fi
curl -s "$BASE/archief" -o /tmp/t_archief.html
contains "archief toont editie" /tmp/t_archief.html "ProostenKrant"
contains "archief toont gepubliceerd artikel" /tmp/t_archief.html "Eerste artikel in de Blazor-krant"
echo ""
echo "=== RESULTAAT DEEL 1: $([ $PASS_FAILED = 0 ] && echo ALLES GESLAAGD || echo FOUTEN GEVONDEN) ==="
echo ""
rm -f /tmp/krant.cookies2
# ProostenKrant end-to-end test — deel 2: inzendflow, bewerkflow, uitloggen
set -u
BASE=http://localhost:8080
JAR=/tmp/krant.cookies2
PASS="ProostenKrant2024!"
PASS_FAILED=0
ok() { echo "PASS: $1"; }
fail() { echo "FAIL: $1"; PASS_FAILED=1; }
check() { if [ "$2" = "$3" ]; then ok "$1"; else fail "$1 (expected [$2], got [$3])"; fi; }
contains() { if grep -q "$3" "$2"; then ok "$1"; else fail "$1 (pattern '$3' niet in $2)"; fi; }
get_token() {
curl -s -b "$JAR" -c "$JAR" "$BASE$1" -o "$2"
TOKEN=$(grep -o 'name="__RequestVerificationToken"[^>]*value="[^"]*"' "$2" | head -1 | sed 's/.*value="//;s/"$//')
HANDLER=$(grep -o 'name="_handler" value="[^"]*"' "$2" | head -1 | sed 's/.*value="//;s/"$//')
}
echo "=== 7. Inzendflow: paszin ==="
rm -f "$JAR"
code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/indienen/formulier")
check "formulier zonder paszin -> redirect (302)" 302 "$code"
get_token "/indienen" /tmp/u_ind.html
curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/indienen" \
--data-urlencode "_handler=$HANDLER" \
--data-urlencode "Input.Paszin=foutezin" \
--data-urlencode "__RequestVerificationToken=$TOKEN" \
-o /tmp/u_ind_bad.html
contains "foute paszin -> foutmelding" /tmp/u_ind_bad.html "Onjuiste paszin"
get_token "/indienen" /tmp/u_ind.html
curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/indienen" \
--data-urlencode "_handler=$HANDLER" \
--data-urlencode "Input.Paszin=$PASS" \
--data-urlencode "__RequestVerificationToken=$TOKEN" \
-o /dev/null -w "" -D /tmp/u_ind_ok.headers
contains "juiste paszin -> cookie" /tmp/u_ind_ok.headers "ProostenKrant="
code=$(curl -s -b "$JAR" -o /tmp/u_form.html -w "%{http_code}" "$BASE/indienen/formulier")
check "formulier met paszin -> 200" 200 "$code"
contains "formulier toont titelveld" /tmp/u_form.html "Titel"
echo "=== 8. Inzendflow: artikel indienen met foto ==="
get_token "/indienen/formulier" /tmp/u_form.html
curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/indienen/formulier" \
-F "_handler=$HANDLER" \
-F "Input.Title=Een dagje aan zee" \
-F "Input.Content=We gingen met de hele familie naar Blankenberge. Het was prachtig weer en de zee was kalm. Iedereen heeft genoten van de dag." \
-F "Input.WriterId=2" \
-F "Input.EventDate=2026-10-05" \
-F "Participants=2" -F "Participants=3" \
-F "Afbeelding=@/tmp/blankenberge.jpg" \
-F "__RequestVerificationToken=$TOKEN" \
-o /dev/null -D /tmp/u_post.h -w ""
loc=$(grep -i "^location" /tmp/u_post.h | tr -d '\r' | awk '{print $2}')
echo "redirect: $loc"
contains "redirect naar bevestiging" /tmp/u_post.h "indienen/bevestiging"
curl -s "$loc" -o /tmp/u_bev.html
contains "bevestiging toont titel" /tmp/u_bev.html "Een dagje aan zee"
ARTID=$(echo "$loc" | grep -o "artikel=[0-9]*" | cut -d= -f2)
echo "nieuw artikel-id: $ARTID"
echo "=== 9. Cookie verlopen na indienen ==="
code=$(curl -s -b "$JAR" -o /dev/null -w "%{http_code}" "$BASE/indienen/formulier")
check "formulier opnieuw -> redirect (cookie weg)" 302 "$code"
echo "=== 10. Bewerkflow: paszin -> lijst -> schrijver verificatie ==="
rm -f "$JAR"
get_token "/indienen/bewerken" /tmp/u_bp.html
curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/indienen/bewerken" \
--data-urlencode "_handler=$HANDLER" \
--data-urlencode "Input.Paszin=$PASS" \
--data-urlencode "__RequestVerificationToken=$TOKEN" \
-o /dev/null
curl -s -b "$JAR" "$BASE/indienen/bewerken/lijst" -o /tmp/u_lijst.html
contains "lijst toont kladartikel" /tmp/u_lijst.html "Een dagje aan zee"
echo "--- foute schrijver (id 1, echte schrijver is 2) ---"
get_token "/indienen/bewerken/$ARTID" /tmp/u_ver.html
curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/indienen/bewerken/$ARTID" \
-F "_handler=$HANDLER" \
-F "Input.WriterId=1" \
-F "__RequestVerificationToken=$TOKEN" \
-o /tmp/u_ver_fout.html -w "foute POST status: %{http_code}\n"
contains "verkeerde schrijver geweigerd" /tmp/u_ver_fout.html "Alleen de schrijver"
echo "--- juiste schrijver (id 2) ---"
get_token "/indienen/bewerken/$ARTID" /tmp/u_ver.html
curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/indienen/bewerken/$ARTID" \
-F "_handler=$HANDLER" \
-F "Input.WriterId=2" \
-F "__RequestVerificationToken=$TOKEN" \
-o /dev/null -D /tmp/u_ver_ok.h -w "juiste POST status: %{http_code}\n"
loc=$(grep -i "^location" /tmp/u_ver_ok.h | tr -d '\r' | awk '{print $2}')
echo "redirect: $loc"
curl -s -b "$JAR" "$loc" -o /tmp/u_edit.html
contains "bewerkformulier toont bestaande titel" /tmp/u_edit.html "Een dagje aan zee"
contains "bewerkformulier staat in bewerkmodus" /tmp/u_edit.html "Artikel bewerken"
echo "--- wijzig opslaan ---"
get_token "/indienen/formulier" /tmp/u_edit.html
curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/indienen/formulier" \
-F "_handler=$HANDLER" \
-F "Input.Title=Een dagje aan zee (ge-edit)" \
-F "Input.Content=We gingen met de hele familie naar Blankenberge. Het was prachtig weer en de zee was kalm. Iedereen heeft genoten van de dag. Update: het artikel is bewerkt." \
-F "Input.WriterId=2" \
-F "Input.EventDate=2026-10-05" \
-F "Participants=2" \
-F "__RequestVerificationToken=$TOKEN" \
-o /dev/null -D /tmp/u_edit_post.h
contains "wijziging doorgestuurd" /tmp/u_edit_post.h "indienen/bevestiging"
echo "=== 11. Redactie ziet nieuwe kladartikelen ==="
rm -f "$JAR"
get_token "/login" /tmp/u_login.html
curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/login" \
--data-urlencode "_handler=$HANDLER" \
--data-urlencode "Input.Username=douwe" \
--data-urlencode "Input.Password=$PASS" \
--data-urlencode "__RequestVerificationToken=$TOKEN" -o /dev/null
curl -s -b "$JAR" "$BASE/artikelen?status=draft" -o /tmp/u_drafts.html
contains "klad-overzicht toont bewerkte titel" /tmp/u_drafts.html "Een dagje aan zee (ge-edit)"
echo "=== 12. Uitloggen ==="
get_token "/uitloggen" /tmp/u_uit.html
curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/uitloggen" \
--data-urlencode "_handler=$HANDLER" \
--data-urlencode "__RequestVerificationToken=$TOKEN" \
-o /dev/null -w "" -D /tmp/u_uit.h
code=$(curl -s -b "$JAR" -o /dev/null -w "%{http_code}" "$BASE/dashboard")
check "dashboard na uitloggen -> 302" 302 "$code"
echo ""
echo "=== RESULTAAT DEEL 2: $([ $PASS_FAILED = 0 ] && echo ALLES GESLAAGD || echo FOUTEN GEVONDEN) ==="
exit $PASS_FAILED
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="icon" href="images/logo.jpg" type="image/jpeg" />
<link rel="stylesheet" href="css/site.css" />
<HeadOutlet />
</head>
<body>
<Routes />
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@@ -0,0 +1,55 @@
@inherits LayoutComponentBase
@inject AuthenticationStateProvider AuthStateProvider
<header class="masthead">
<div class="masthead-meta">
<span>@Krant.Location</span>
<span class="masthead-date">@Krant.DutchDate(DateTime.Now)</span>
<span>@(_isEditor ? "Redactie: " + _editorName : "NUL EURO")</span>
</div>
<p class="masthead-title"><a href="/">Proo<span class="long-s">s</span>tenKrant</a></p>
<p class="masthead-slogan">&bdquo;@Krant.Slogan&ldquo;</p>
<nav class="main-nav">
<NavLink href="/" Match="NavLinkMatch.All">Voorpagina</NavLink>
<NavLink href="/indienen">Krant indienen</NavLink>
<NavLink href="/archief">Archief</NavLink>
<AuthorizeView>
<Authorized>
<NavLink href="/dashboard">Dashboard</NavLink>
<NavLink href="/artikelen">Artikelen</NavLink>
<NavLink href="/seizoenen">Seizoenen</NavLink>
<NavLink href="/uitloggen" class="nav-uitloggen">Uitloggen</NavLink>
</Authorized>
<NotAuthorized>
<NavLink href="/login">Inloggen</NavLink>
</NotAuthorized>
</AuthorizeView>
</nav>
</header>
<main class="sheet">
@Body
</main>
<footer class="footer">
<div class="footer-inner">
<span>&copy; @DateTime.Now.Year @Krant.AppName &mdash; Alle rechten voorbehouden</span>
<span class="footer-eu">
Made and hosted in
<img src="images/european-flag.svg" class="eu-flag" alt="Europese vlag" width="24" height="16" />
Europe
</span>
</div>
</footer>
@code {
private bool _isEditor;
private string? _editorName;
protected override async Task OnInitializedAsync()
{
var state = await AuthStateProvider.GetAuthenticationStateAsync();
_isEditor = KrantAuth.IsEditor(state.User);
_editorName = KrantAuth.EditorName(state.User);
}
}
@@ -0,0 +1,90 @@
@page "/archief"
@inject LatexService Latex
@inject ArticleService Artikelen
@inject SeasonService Seizoenen
<PageTitle>Archief — ProostenKrant</PageTitle>
<h1 class="page-heading">Archief</h1>
<p class="page-intro">Alle gedrukte edities van de ProostenKrant en de gepubliceerde artikelen per seizoen.</p>
<h2 class="section-heading">Gedrukte edities</h2>
@if (_edities.Count > 0)
{
<table class="krant-tabel">
<thead>
<tr>
<th>Editie</th>
<th>Datum</th>
<th>Grootte</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var editie in _edities)
{
<tr>
<td>@editie.DisplayName</td>
<td>@Krant.DutchShortDate(editie.Date)</td>
<td>@(editie.SizeBytes / 1024) kB</td>
<td><a class="btn" href="/edities/@editie.FileName">Download PDF</a></td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="muted">Er zijn nog geen gedrukte edities beschikbaar.</p>
}
<h2 class="section-heading">Gepubliceerde artikelen</h2>
@foreach (var seizoen in _seizoenenMetArtikelen)
{
<section class="season-archive">
<h3 class="season-heading seizoen-@seizoen.Season?.CssKey">
@(seizoen.Season?.Name ?? "Zonder seizoen")
@if (seizoen.Season is not null && seizoen.Season.StartDate <= DateTime.Now && seizoen.Season.EndDate >= DateTime.Now)
{
<span class="tag">huidige editie</span>
}
</h3>
@if (seizoen.Articles.Count == 0)
{
<p class="muted">Nog geen gepubliceerde artikelen.</p>
}
else
{
<ul class="archive-article-list">
@foreach (var artikel in seizoen.Articles)
{
<li>
<a href="/artikelen/@artikel.Id" class="archive-article-link">@artikel.Title</a>
<span class="byline-inline">— @artikel.AuthorName, @Krant.DutchShortDate(artikel.EventDate)</span>
</li>
}
</ul>
}
</section>
}
@code {
private List<Edition> _edities = new();
private List<(Season? Season, List<Article> Articles)> _seizoenenMetArtikelen = new();
protected override async Task OnInitializedAsync()
{
_edities = Latex.ListEditions();
var seasons = await Seizoenen.GetAllAsync();
var published = await Artikelen.GetAllAsync("published");
_seizoenenMetArtikelen = seasons
.Select(s => (s, published.Where(a => a.SeasonId == s.Id).ToList()))
.Select(x => (Season: (Season?)x.Item1, Articles: x.Item2))
.ToList();
var overige = published.Where(a => a.SeasonId is null || seasons.All(s => s.Id != a.SeasonId)).ToList();
if (overige.Count > 0)
{
_seizoenenMetArtikelen.Add((null, overige));
}
}
}
@@ -0,0 +1,143 @@
@page "/artikelen/{Id:int}"
@inject ArticleService Artikelen
@inject UserService Gebruikers
@inject AuthenticationStateProvider AuthStateProvider
@inject IWebHostEnvironment Env
<PageTitle>@(_artikel?.Title ?? "Artikel") — ProostenKrant</PageTitle>
@if (_artikel is null)
{
<section class="card">
<h1 class="headline">Artikel niet gevonden</h1>
<p>Dit artikel bestaat niet (meer). <a href="/artikelen">Naar het overzicht</a>.</p>
</section>
return;
}
<article class="article-page">
<span class="kicker">@(string.IsNullOrEmpty(_artikel.SeasonName) ? "ProostenKrant" : _artikel.SeasonName)</span>
<h1 class="article-headline">@_artikel.Title</h1>
<p class="byline">
Door @_artikel.AuthorName
@if (_artikel.EventDate is not null)
{
<text>&mdash; @Krant.DutchShortDate(_artikel.EventDate)</text>
}
@if (_isEditor)
{
<span class="status status-@_artikel.Status">@(_artikel.Status == "published" ? "gepubliceerd" : "klad")</span>
}
</p>
@if (_deelnemers.Count > 0)
{
<div class="participant-row">
@foreach (var deelnemer in _deelnemers)
{
<span class="participant">
<img class="participant-avatar" src="@ProfileWebPath(deelnemer)" alt="@deelnemer.DisplayName" />
<span>@deelnemer.DisplayName</span>
</span>
}
</div>
}
@if (!string.IsNullOrEmpty(_artikel.WebImage))
{
<figure class="article-figure">
<img src="@_artikel.WebImage" alt="@_artikel.Title" />
</figure>
}
<div class="article-body">
@foreach (var alinea in _artikel.Content.Split("\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
<p>@alinea</p>
}
</div>
@if (_isEditor)
{
<div class="editor-actions">
@if (_artikel.Status == "draft")
{
<EditForm FormName="publiceer" Method="post" Model="PubliceerInput" OnValidSubmit="Publiceer" class="inline-form">
<button type="submit" class="btn btn-primary">Publiceren</button>
</EditForm>
}
else
{
<EditForm FormName="depubliceer" Method="post" Model="DepubliceerInput" OnValidSubmit="Depubliceer" class="inline-form">
<button type="submit" class="btn">Terug naar klad</button>
</EditForm>
}
<a class="btn" href="/indienen/bewerken/@_artikel.Id">Bewerken</a>
<EditForm FormName="verwijder" Method="post" Model="VerwijderInput" OnValidSubmit="Verwijder" class="inline-form">
<button type="submit" class="btn btn-danger">Verwijderen</button>
</EditForm>
</div>
}
</article>
@code {
[Parameter]
public int Id { get; set; }
private Article? _artikel;
private List<User> _deelnemers = new();
private bool _isEditor;
[SupplyParameterFromForm(FormName = "publiceer")]
private ActieInput PubliceerInput { get; set; } = new();
[SupplyParameterFromForm(FormName = "depubliceer")]
private ActieInput DepubliceerInput { get; set; } = new();
[SupplyParameterFromForm(FormName = "verwijder")]
private ActieInput VerwijderInput { get; set; } = new();
private sealed class ActieInput { }
protected override async Task OnInitializedAsync()
{
PubliceerInput ??= new();
DepubliceerInput ??= new();
VerwijderInput ??= new();
_artikel = await Artikelen.FindAsync(Id);
var state = await AuthStateProvider.GetAuthenticationStateAsync();
_isEditor = KrantAuth.IsEditor(state.User);
if (_artikel is not null && (_artikel.Status == "published" || _isEditor))
{
_deelnemers = await Gebruikers.GetByIdsAsync(_artikel.ParticipantIds);
}
}
private string ProfileWebPath(User user) => Gebruikers.GetProfileWebPath(user);
private async Task Publiceer()
{
if (_artikel is not null)
{
await Artikelen.SetStatusAsync(_artikel.Id, "published");
Nav.NavigateTo($"/artikelen/{Id}", forceLoad: true);
}
}
private async Task Depubliceer()
{
if (_artikel is not null)
{
await Artikelen.SetStatusAsync(_artikel.Id, "draft");
Nav.NavigateTo($"/artikelen/{Id}", forceLoad: true);
}
}
private async Task Verwijder()
{
if (_artikel is not null)
{
await Artikelen.DeleteAsync(_artikel.Id);
Nav.NavigateTo("/artikelen", forceLoad: true);
}
}
}
@@ -0,0 +1,190 @@
@page "/artikelen/nieuw"
@attribute [Authorize(Policy = "Editor")]
@using System.ComponentModel.DataAnnotations
@inject ArticleService Artikelen
@inject SeasonService Seizoenen
@inject UserService Gebruikers
@inject UploadService Uploads
<PageTitle>Nieuw artikel — ProostenKrant</PageTitle>
<section class="card">
<h1 class="headline">Nieuw artikel</h1>
<p class="muted">Artikel voor de redactie aanmaken en eventueel direct publiceren.</p>
@if (_fout is not null)
{
<p class="alert alert-error">@_fout</p>
}
<EditForm FormName="artikel-nieuw" Method="post" Model="Input" OnValidSubmit="Opslaan"
class="krant-form" enctype="multipart/form-data">
<div class="form-group">
<label for="title">Titel</label>
<InputText @bind-Value="Input.Title" id="title" class="form-control" placeholder="De kop boven het artikel" />
<ValidationMessage For="() => Input.Title" />
</div>
<div class="form-group">
<label for="content">Tekst</label>
<InputTextArea @bind-Value="Input.Content" id="content" rows="10" class="form-control" placeholder="Schrijf hier de inhoud van je artikel…" />
<ValidationMessage For="() => Input.Content" />
</div>
<div class="form-row">
<div class="form-group">
<label for="writer">Schrijver</label>
<InputSelect @bind-Value="Input.WriterId" id="writer" class="form-control">
<option value="0">— Kies schrijver —</option>
@foreach (var gebruiker in _gebruikers)
{
<option value="@gebruiker.Id">@gebruiker.DisplayName</option>
}
</InputSelect>
<ValidationMessage For="() => Input.WriterId" />
</div>
<div class="form-group">
<label for="eventdate">Datum van de gebeurtenis</label>
<InputDate @bind-Value="Input.EventDate" id="eventdate" class="form-control" />
<ValidationMessage For="() => Input.EventDate" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="season">Seizoen</label>
<InputSelect @bind-Value="Input.SeasonId" id="season" class="form-control">
<option value="0">— Kies seizoen —</option>
@foreach (var seizoen in _seizoenen)
{
<option value="@seizoen.Id">@seizoen.Name</option>
}
</InputSelect>
<ValidationMessage For="() => Input.SeasonId" />
</div>
<div class="form-group">
<label for="status">Status</label>
<InputSelect @bind-Value="Input.Status" id="status" class="form-control">
<option value="draft">Klad</option>
<option value="published">Gepubliceerd</option>
</InputSelect>
</div>
</div>
<div class="form-group">
<label>Deelnemers</label>
<div class="checkbox-group">
@foreach (var gebruiker in _gebruikers)
{
<label class="checkbox-label">
<input type="checkbox" name="Participants" value="@gebruiker.Id"
checked="@(_geselecteerd.Contains(gebruiker.Id))" />
<span>@gebruiker.DisplayName</span>
</label>
}
</div>
@if (_participantsFout is not null)
{
<span class="error-message">@_participantsFout</span>
}
</div>
<div class="form-group">
<label for="image">Afbeelding (optioneel)</label>
<input type="file" name="Afbeelding" id="image" class="form-control" accept="image/*" />
<span class="help-text">JPG, PNG, GIF of WEBP, maximaal 25 MB.</span>
@if (_afbeeldingFout is not null)
{
<span class="error-message">@_afbeeldingFout</span>
}
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Artikel opslaan</button>
<a class="btn" href="/artikelen">Annuleren</a>
</div>
</EditForm>
</section>
@code {
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm(FormName = "artikel-nieuw")]
private ArtikelInput Input { get; set; } = new();
private List<User> _gebruikers = new();
private List<Season> _seizoenen = new();
private List<int> _geselecteerd = new();
private string? _fout;
private string? _participantsFout;
private string? _afbeeldingFout;
protected override async Task OnInitializedAsync()
{
Input ??= new();
_gebruikers = await Gebruikers.GetAllAsync();
_seizoenen = await Seizoenen.GetAllAsync();
var huidig = await Seizoenen.GetCurrentAsync();
Input.SeasonId ??= huidig?.Id ?? (_seizoenen.FirstOrDefault()?.Id ?? 0);
Input.EventDate ??= DateTime.Today;
Input.Status ??= "published";
}
private async Task Opslaan()
{
Input ??= new();
var form = HttpContext.Request.Form;
_geselecteerd = form["Participants"].SelectMany(v => (v ?? "").Split(','))
.Select(s => int.TryParse(s, out var id) ? id : 0).Where(id => id > 0).ToList();
if (Input.WriterId <= 0)
{
_fout = "Kies een schrijver.";
return;
}
if (!_geselecteerd.Contains(Input.WriterId))
{
_geselecteerd.Insert(0, Input.WriterId);
}
if (_geselecteerd.Count == 0)
{
_participantsFout = "Selecteer minstens één deelnemer.";
return;
}
var (webPad, fout) = await Uploads.SaveArticleImageAsync(HttpContext.Request.Form.Files["Afbeelding"]);
if (fout is not null)
{
_afbeeldingFout = fout;
return;
}
var artikel = new Article
{
Title = Input.Title,
Content = Input.Content,
AuthorId = Input.WriterId,
Participants = string.Join(",", _geselecteerd),
EventDate = Input.EventDate,
SeasonId = Input.SeasonId,
ArticleImage = webPad,
Status = Input.Status == "published" ? "published" : "draft",
};
await Artikelen.CreateAsync(artikel);
Nav.NavigateTo("/artikelen?melding=" + Uri.EscapeDataString("Artikel succesvol opgeslagen."), forceLoad: true);
}
private sealed class ArtikelInput
{
[Required(ErrorMessage = "Geef het artikel een titel (minstens 5 tekens).")]
[MinLength(5, ErrorMessage = "De titel moet minstens 5 tekens bevatten.")]
[MaxLength(255)]
public string Title { get; set; } = "";
[Required(ErrorMessage = "Schrijf de tekst van het artikel (minstens 10 tekens).")]
[MinLength(10, ErrorMessage = "De tekst moet minstens 10 tekens bevatten.")]
public string Content { get; set; } = "";
public int WriterId { get; set; }
public DateTime? EventDate { get; set; }
public int? SeasonId { get; set; }
public string? Status { get; set; }
}
}
@@ -0,0 +1,63 @@
@page "/artikelen"
@attribute [Authorize(Policy = "Editor")]
@inject ArticleService ArtikelSvc
<PageTitle>Artikelen — ProostenKrant</PageTitle>
<h1 class="page-heading">Artikelen</h1>
<nav class="filter-bar">
<a href="/artikelen" class="btn @(SelectedStatus is null ? "btn-active" : "")">Alle</a>
<a href="/artikelen?status=draft" class="btn @(_status == "draft" ? "btn-active" : "")">Klad</a>
<a href="/artikelen?status=published" class="btn @(_status == "published" ? "btn-active" : "")">Gepubliceerd</a>
<a href="/artikelen/nieuw" class="btn btn-primary">Nieuw artikel</a>
</nav>
@if (_artikelen.Count == 0)
{
<p class="muted">Geen artikelen gevonden.</p>
}
else
{
<table class="krant-tabel">
<thead>
<tr>
<th>Titel</th>
<th>Schrijver</th>
<th>Gebeurtenis</th>
<th>Seizoen</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var artikel in _artikelen)
{
<tr>
<td><a href="/artikelen/@artikel.Id">@artikel.Title</a></td>
<td>@artikel.AuthorName</td>
<td>@Krant.DutchShortDate(artikel.EventDate)</td>
<td>@(artikel.SeasonName ?? "—")</td>
<td><span class="status status-@artikel.Status">@(artikel.Status == "published" ? "gepubliceerd" : "klad")</span></td>
<td><a class="btn" href="/artikelen/@artikel.Id">Openen</a></td>
</tr>
}
</tbody>
</table>
}
@code {
[SupplyParameterFromQuery(Name = "status")]
public string? Status { get; set; }
private string? _status;
private List<Article> _artikelen = new();
protected override async Task OnInitializedAsync()
{
_status = Status;
_artikelen = await ArtikelSvc.GetAllAsync(string.IsNullOrEmpty(Status) ? null : Status);
}
private string? SelectedStatus => _status;
}
@@ -0,0 +1,97 @@
@page "/indienen/bewerken/{Id:int}"
@using System.ComponentModel.DataAnnotations
@inject ArticleService Artikelen
@inject UserService Gebruikers
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>Verifieer schrijver — ProostenKrant</PageTitle>
@if (_artikel is null)
{
<section class="card">
<h1 class="headline">Artikel niet gevonden</h1>
<p><a href="/indienen/bewerken/lijst">Terug naar de lijst</a>.</p>
</section>
return;
}
<section class="card auth-card">
<span class="kicker">Beveiliging</span>
<h1 class="headline">Wie schreef dit artikel?</h1>
<p class="muted">
Om &bdquo;@_artikel.Title&ldquo; te bewerken, bevestig je eerst dat jij de schrijver bent.
</p>
@if (_fout is not null)
{
<p class="alert alert-error">@_fout</p>
}
<EditForm FormName="verifieer-schrijver" Method="post" Model="Input" OnValidSubmit="Verifieer" class="krant-form">
<div class="form-group">
<label for="writer">Schrijver</label>
<InputSelect @bind-Value="Input.WriterId" id="writer" class="form-control">
<option value="0">— Kies schrijver —</option>
@foreach (var gebruiker in _gebruikers)
{
<option value="@gebruiker.Id">@gebruiker.DisplayName</option>
}
</InputSelect>
<ValidationMessage For="() => Input.WriterId" />
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Doorgaan naar bewerken</button>
<a class="btn" href="/indienen/bewerken/lijst">Annuleren</a>
</div>
</EditForm>
</section>
@code {
[Parameter]
public int Id { get; set; }
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm(FormName = "verifieer-schrijver")]
private VerificatieInput Input { get; set; } = new();
private Article? _artikel;
private List<User> _gebruikers = new();
private string? _fout;
private sealed class VerificatieInput
{
[Required(ErrorMessage = "Kies de schrijver.")]
public int WriterId { get; set; }
}
protected override async Task OnInitializedAsync()
{
Input ??= new();
var state = await AuthStateProvider.GetAuthenticationStateAsync();
if (!KrantAuth.HasValidPassphrase(state.User) && !KrantAuth.IsEditor(state.User))
{
Nav.NavigateTo("/indienen/bewerken", forceLoad: true);
return;
}
_artikel = await Artikelen.FindAsync(Id);
_gebruikers = await Gebruikers.GetAllAsync();
}
private async Task Verifieer()
{
if (_artikel is null)
{
return;
}
if (Input.WriterId != _artikel.AuthorId)
{
_fout = "Alleen de schrijver van het artikel kan het bewerken.";
return;
}
await HttpContext.SignInAsync(KrantAuth.Scheme,
KrantAuth.PassphrasePrincipal(editArticleId: _artikel.Id));
Nav.NavigateTo("/indienen/formulier", forceLoad: true);
}
}
@@ -0,0 +1,47 @@
@page "/indienen/bewerken/lijst"
@inject ArticleService Artikelen
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>Kladartikelen — ProostenKrant</PageTitle>
<h1 class="page-heading">Kladartikelen bewerken</h1>
<p class="page-intro">Kies het artikel dat je wilt aanpassen. Alleen de schrijver van het artikel kan het bewerken.</p>
@if (_klad.Count == 0)
{
<p class="muted">Er zijn momenteel geen kladartikelen.</p>
}
else
{
<table class="krant-tabel">
<thead>
<tr><th>Titel</th><th>Schrijver</th><th>Gebeurtenis</th><th></th></tr>
</thead>
<tbody>
@foreach (var artikel in _klad)
{
<tr>
<td>@artikel.Title</td>
<td>@artikel.AuthorName</td>
<td>@Krant.DutchShortDate(artikel.EventDate)</td>
<td><a class="btn" href="/indienen/bewerken/@artikel.Id">Bewerken</a></td>
</tr>
}
</tbody>
</table>
}
@code {
private List<Article> _klad = new();
protected override async Task OnInitializedAsync()
{
var state = await AuthStateProvider.GetAuthenticationStateAsync();
if (!KrantAuth.HasValidPassphrase(state.User) && !KrantAuth.IsEditor(state.User))
{
Nav.NavigateTo("/indienen/bewerken", forceLoad: true);
return;
}
_klad = await Artikelen.GetAllAsync("draft");
}
}
@@ -0,0 +1,60 @@
@page "/indienen/bewerken"
@using System.ComponentModel.DataAnnotations
<PageTitle>Artikel bewerken — ProostenKrant</PageTitle>
<section class="card auth-card">
<span class="kicker">Beveiliging</span>
<h1 class="headline">Artikel bewerken</h1>
<p class="muted">Voer de paszin in om de lijst met kladartikelen te openen.</p>
@if (_fout is not null)
{
<p class="alert alert-error">@_fout</p>
}
<EditForm FormName="paszin-bewerken" Method="post" Model="Input" OnValidSubmit="Verifieer" class="krant-form">
<div class="form-group">
<label for="passphrase">Paszin</label>
<InputText type="password" @bind-Value="Input.Paszin" id="passphrase" class="form-control" autocomplete="off" />
<ValidationMessage For="() => Input.Paszin" />
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Verder</button>
<a class="btn" href="/">Annuleren</a>
</div>
</EditForm>
</section>
@code {
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm(FormName = "paszin-bewerken")]
private PaszinInput Input { get; set; } = new();
private string? _fout;
protected override Task OnInitializedAsync()
{
Input ??= new();
return Task.CompletedTask;
}
private sealed class PaszinInput
{
[Required(ErrorMessage = "Voer de paszin in.")]
public string Paszin { get; set; } = "";
}
private async Task Verifieer()
{
if (Input.Paszin != Krant.UniversalPassword)
{
_fout = "Onjuiste paszin. Probeer het opnieuw.";
return;
}
await HttpContext.SignInAsync(KrantAuth.Scheme, KrantAuth.PassphrasePrincipal());
Nav.NavigateTo("/indienen/bewerken/lijst", forceLoad: true);
}
}
@@ -0,0 +1,166 @@
@page "/dashboard"
@attribute [Authorize(Policy = "Editor")]
@inject AuthenticationStateProvider AuthStateProvider
@inject ArticleService Artikelen
@inject SeasonService Seizoenen
@inject LatexService Latex
@inject UserService Gebruikers
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Af
<PageTitle>Dashboard — ProostenKrant</PageTitle>
<h1 class="page-heading">Dashboard redactie</h1>
<p class="page-intro">Welkom, @_editorNaam. Overzicht van de krant en sneltoetsen voor de redactie.</p>
@if (_melding is not null)
{
<p class="alert alert-success">@_melding</p>
}
@if (_fout is not null)
{
<div class="alert alert-error">
<p>@_fout</p>
</div>
}
<div class="stats-grid">
<div class="stat-card">
<span class="stat-number">@_drafts</span>
<span class="stat-label">Kladartikelen</span>
<a href="/artikelen?status=draft" class="stat-link">Bekijken &rarr;</a>
</div>
<div class="stat-card">
<span class="stat-number">@_gepubliceerd</span>
<span class="stat-label">Gepubliceerd</span>
<a href="/artikelen?status=published" class="stat-link">Bekijken &rarr;</a>
</div>
<div class="stat-card">
<span class="stat-number">@_gebruikers.Count</span>
<span class="stat-label">Familieleden</span>
</div>
<div class="stat-card">
<span class="stat-number">@_edities.Count</span>
<span class="stat-label">Gedrukte edities</span>
<a href="/archief" class="stat-link">Archief &rarr;</a>
</div>
</div>
<div class="dashboard-columns">
<section class="dashboard-main">
<h2 class="section-heading">Huidige editie</h2>
@if (_huidigSeizoen is not null)
{
<div class="season-card seizoen-@_huidigSeizoen.CssKey">
<h3>@_huidigSeizoen.Name @DateTime.Now.Year</h3>
<p class="muted">
Van @Krant.DutchShortDate(_huidigSeizoen.StartDate) t/m @Krant.DutchShortDate(_huidigSeizoen.EndDate).
@(_seizoenDrafts.Count) klad, @(_seizoenPublished.Count) gepubliceerd.
</p>
<div class="form-actions">
<form method="post" action="/api/genereer-pdf" class="inline-form">
<input type="hidden" name="SeizoenId" value="@_huidigSeizoen.Id" />
<input type="hidden" name="__RequestVerificationToken" value="@_afToken" />
<button type="submit" class="btn btn-primary">Druk de @_huidigSeizoen.Name-editie</button>
</form>
</div>
</div>
}
else
{
<p class="muted">Er is geen lopend seizoen gedefinieerd.</p>
}
<h2 class="section-heading">Laatste artikelen</h2>
<table class="krant-tabel">
<thead>
<tr>
<th>Titel</th>
<th>Schrijver</th>
<th>Seizoen</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var artikel in _recent.Take(8))
{
<tr>
<td><a href="/artikelen/@artikel.Id">@artikel.Title</a></td>
<td>@artikel.AuthorName</td>
<td>@(artikel.SeasonName ?? "—")</td>
<td>
<span class="status status-@artikel.Status">@(artikel.Status == "published" ? "gepubliceerd" : "klad")</span>
</td>
</tr>
}
</tbody>
</table>
</section>
<aside class="dashboard-side">
<h2 class="section-heading">Sneltoetsen</h2>
<ul class="quick-links">
<li><a class="btn" href="/artikelen/nieuw">Nieuw artikel</a></li>
<li><a class="btn" href="/seizoenen">Seizoenen &amp; drukwerk</a></li>
<li><a class="btn" href="/artikelen?status=draft">Kladartikelen publiceren</a></li>
</ul>
<h2 class="section-heading">Laatste edities</h2>
<ul class="edition-list">
@foreach (var editie in _edities.Take(5))
{
<li>
<a href="/edities/@editie.FileName" class="edition-link">
<span class="edition-name">@editie.DisplayName</span>
<span class="edition-size">@(editie.SizeBytes / 1024) kB</span>
</a>
</li>
}
</ul>
</aside>
</div>
@code {
private string? _editorNaam;
private Season? _huidigSeizoen;
private List<Article> _recent = new();
private List<Article> _seizoenDrafts = new();
private List<Article> _seizoenPublished = new();
private List<User> _gebruikers = new();
private List<Edition> _edities = new();
private int _drafts;
private int _gepubliceerd;
private string? _melding;
private string? _fout;
private string? _afToken;
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
_afToken = Af.GetAndStoreTokens(HttpContext).RequestToken;
var state = await AuthStateProvider.GetAuthenticationStateAsync();
_editorNaam = KrantAuth.EditorName(state.User) ?? "redacteur";
_huidigSeizoen = await Seizoenen.GetCurrentAsync();
_recent = await Artikelen.GetAllAsync();
_gebruikers = await Gebruikers.GetAllAsync();
_edities = Latex.ListEditions();
_drafts = _recent.Count(a => a.Status == "draft");
_gepubliceerd = _recent.Count(a => a.Status == "published");
if (_huidigSeizoen is not null)
{
_seizoenDrafts = _recent.Where(a => a.SeasonId == _huidigSeizoen.Id && a.Status == "draft").ToList();
_seizoenPublished = await Artikelen.GetBySeasonAsync(_huidigSeizoen.Id);
}
LeesMelding();
}
private void LeesMelding()
{
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(new Uri(Nav.Uri).Query);
query.TryGetValue("melding", out var melding);
query.TryGetValue("fout", out var fout);
_melding = melding;
_fout = fout;
}
}
@@ -0,0 +1,12 @@
@page "/fout"
<PageTitle>Fout — ProostenKrant</PageTitle>
<section class="card">
<span class="kicker">Persstop</span>
<h1 class="headline">Er ging iets mis</h1>
<p>De redactie kon deze pagina niet drukken. Probeer het opnieuw of ga terug naar de voorpagina.</p>
<div class="form-actions">
<a class="btn btn-primary" href="/">Naar de voorpagina</a>
</div>
</section>
@@ -0,0 +1,169 @@
@page "/"
@inject ArticleService Artikelen
@inject SeasonService Seizoenen
@inject LatexService Latex
<PageTitle>ProostenKrant — Voorpagina</PageTitle>
<section class="edition-banner">
<div class="edition-banner-left">
@if (_huidigSeizoen is not null)
{
<span class="kicker seizoen-@_huidigSeizoen.CssKey">Editie @_huidigSeizoen.Name @DateTime.Now.Year</span>
<h2 class="edition-title">In wording</h2>
<p class="edition-sub">
@if (_drafts.Count == 0)
{
<text>Er liggen nog geen artikelen klaar voor de komende editie.</text>
}
else
{
<text>Er liggen <strong>@(_drafts.Count == 1 ? "één artikel" : $"{_drafts.Count} artikelen")</strong> klaar voor de komende editie.</text>
}
Sluit @Krant.DutchShortDate(_huidigSeizoen.EndDate).
</p>
}
else
{
<h2 class="edition-title">ProostenKrant</h2>
<p class="edition-sub">Een krant van en voor Proosten.</p>
}
</div>
<div class="edition-banner-right">
<a class="btn btn-primary" href="/indienen">Lever zelf een artikel in</a>
</div>
</section>
@if (_lead is not null)
{
<article class="lead-article">
<span class="kicker">Voorpagina nieuws</span>
<h2 class="lead-headline">@_lead.Title</h2>
<p class="byline">
Door @_lead.AuthorName
@if (_lead.EventDate is not null)
{
<text>&mdash; @Krant.DutchShortDate(_lead.EventDate)</text>
}
</p>
@if (!string.IsNullOrEmpty(_lead.WebImage))
{
<figure class="lead-figure">
<img src="@_lead.WebImage" alt="@_lead.Title" loading="lazy" />
</figure>
}
<p class="lead-text">@Teaser(_lead, 400)</p>
</article>
}
@if (_teasers.Count > 0)
{
<h3 class="section-heading">Ook in deze editie</h3>
<div class="teaser-columns">
@foreach (var artikel in _teasers)
{
<article class="teaser">
<h4 class="teaser-headline">@artikel.Title</h4>
<p class="byline">
Door @artikel.AuthorName
@if (artikel.SeasonName is not null)
{
<text>&mdash; @artikel.SeasonName</text>
}
</p>
<p class="teaser-text">@Teaser(artikel, 220)</p>
</article>
}
</div>
}
else if (_lead is null && _gepubliceerd.Count == 0)
{
<section class="card empty-state">
<h2 class="headline">De eerste editie wacht op jou</h2>
<p>Er zijn nog geen artikelen ingediend. De ProostenKrant wordt pas gedrukt als de familie schrijft.</p>
<p><a class="btn btn-primary" href="/indienen">Schrijf het eerste artikel</a></p>
</section>
}
@if (_gepubliceerd.Count > 0)
{
<h3 class="section-heading">Verschenen in de @_huidigSeizoen?.Name-editie</h3>
<div class="teaser-columns">
@foreach (var artikel in _gepubliceerd.Take(6))
{
<article class="teaser">
<h4 class="teaser-headline"><a href="/artikelen/@artikel.Id">@artikel.Title</a></h4>
<p class="byline">
Door @artikel.AuthorName
@if (artikel.EventDate is not null)
{
<text>&mdash; @Krant.DutchShortDate(artikel.EventDate)</text>
}
</p>
<p class="teaser-text">@Teaser(artikel, 200)</p>
<p class="teaser-more"><a href="/artikelen/@artikel.Id">Lees verder &rarr;</a></p>
</article>
}
</div>
}
<aside class="editions-strip">
<h3 class="section-heading">Verschenen edities</h3>
@if (_edities.Count > 0)
{
<ul class="edition-list">
@foreach (var editie in _edities.Take(4))
{
<li>
<a href="/edities/@editie.FileName" class="edition-link">
<span class="edition-name">@editie.DisplayName</span>
@if (editie.Date is not null)
{
<span class="edition-date">@Krant.DutchShortDate(editie.Date)</span>
}
<span class="edition-size">@Kb(editie.SizeBytes) kB &mdash; PDF</span>
</a>
</li>
}
</ul>
<p class="edition-more"><a href="/archief">Bekijk het volledige archief &rarr;</a></p>
}
else
{
<p class="muted">Er zijn nog geen gedrukte edities. Zodra de redactie de krant genereert, verschijnen hier de PDF&rsquo;s.</p>
}
</aside>
@code {
private Season? _huidigSeizoen;
private List<Article> _drafts = new();
private List<Article> _teasers = new();
private List<Article> _gepubliceerd = new();
private Article? _lead;
private List<Edition> _edities = new();
protected override async Task OnInitializedAsync()
{
_huidigSeizoen = await Seizoenen.GetCurrentAsync();
_drafts = await Artikelen.GetAllAsync("draft");
_lead = _drafts.FirstOrDefault();
_teasers = _drafts.Skip(1).ToList();
_gepubliceerd = _huidigSeizoen is not null
? await Artikelen.GetBySeasonAsync(_huidigSeizoen.Id)
: await Artikelen.GetAllAsync("published");
_edities = Latex.ListEditions();
}
private static string Teaser(Article a, int length)
{
var text = a.Content.Replace("\r", " ").Replace("\n", " ").Trim();
if (text.Length <= length)
{
return text;
}
var cut = text.LastIndexOf(' ', length);
return text[..(cut > 0 ? cut : length)] + "…";
}
private static string Kb(long bytes) => Math.Max(1, bytes / 1024).ToString("N0");
}
@@ -0,0 +1,64 @@
@page "/indienen"
@using System.ComponentModel.DataAnnotations
<PageTitle>Artikel indienen — ProostenKrant</PageTitle>
<section class="card auth-card">
<span class="kicker">Medewerking gevraagd</span>
<h1 class="headline">Lever een artikel in</h1>
<p class="muted">
De ProostenKrant bestaat door bijdragen van de familie. Voer de paszin van de familie in
om een artikel te schrijven (of een eerder artikel te bewerken).
</p>
@if (_fout is not null)
{
<p class="alert alert-error">@_fout</p>
}
<EditForm FormName="paszin" Method="post" Model="Input" OnValidSubmit="Verifieer" class="krant-form">
<div class="form-group">
<label for="passphrase">Paszin</label>
<InputText type="password" @bind-Value="Input.Paszin" id="passphrase" class="form-control"
placeholder="De geheime paszin van de familie" autocomplete="off" />
<ValidationMessage For="() => Input.Paszin" />
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Verder naar het formulier</button>
</div>
</EditForm>
</section>
@code {
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm(FormName = "paszin")]
private PaszinInput Input { get; set; } = new();
private string? _fout;
protected override Task OnInitializedAsync()
{
Input ??= new();
return Task.CompletedTask;
}
private sealed class PaszinInput
{
[Required(ErrorMessage = "Voer de paszin in.")]
public string Paszin { get; set; } = "";
}
private async Task Verifieer()
{
if (Input.Paszin != Krant.UniversalPassword)
{
_fout = "Onjuiste paszin. Probeer het opnieuw.";
return;
}
var principal = KrantAuth.PassphrasePrincipal();
await HttpContext.SignInAsync(KrantAuth.Scheme, principal);
Nav.NavigateTo("/indienen/formulier", forceLoad: true);
}
}
@@ -0,0 +1,49 @@
@page "/indienen/bevestiging"
@inject ArticleService Artikelen
<PageTitle>Bevestiging — ProostenKrant</PageTitle>
<section class="card">
<span class="kicker">Bedankt!</span>
<h1 class="headline">@( _artikel is not null ? "Artikel ingediend" : "Bevestiging")</h1>
@if (_artikel is null)
{
<p>Er is geen recent ingediend artikel gevonden.</p>
}
else
{
<p>Het artikel <strong>@_artikel.Title</strong> is veilig in de brievenbus van de redactie gevallen.</p>
<dl class="bevestiging-lijst">
<dt>Schrijver</dt>
<dd>@_artikel.AuthorName</dd>
<dt>Gebeurtenis</dt>
<dd>@Krant.DutchShortDate(_artikel.EventDate)</dd>
@if (_artikel.SeasonName is not null)
{
<dt>Editie</dt>
<dd>@_artikel.SeasonName</dd>
}
</dl>
<p class="muted">De redactie bekijkt alle inzendingen en publiceert ze in de komende editie.</p>
<div class="form-actions">
<a class="btn btn-primary" href="/indienen">Nog een artikel indienen</a>
<a class="btn" href="/">Naar de voorpagina</a>
</div>
}
</section>
@code {
[SupplyParameterFromQuery(Name = "artikel")]
public int? ArtikelId { get; set; }
private Article? _artikel;
protected override async Task OnInitializedAsync()
{
if (ArtikelId is int id)
{
_artikel = await Artikelen.FindAsync(id);
}
}
}

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