style: format frontend, docs and skills with prettier; add .prettierignore

One-time prettier --write so the new format:check CI gate starts green.
.prettierignore excludes generated (api-client.ts, documentation.json),
vendored (public/cibg-huisstijl), and backend (dotnet format owns it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 13:39:31 +02:00
parent 546097434d
commit e82309786d
176 changed files with 5069 additions and 1471 deletions

View File

@@ -5,16 +5,21 @@ import type { DeliveryChannel } from '@shared/upload/upload.machine';
Thin wrapper over the Utrecht/RHC radio CSS. Pure UI: emits the chosen channel. */
@Component({
selector: 'app-delivery-channel-toggle',
styles: [`
.radio-option {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
padding-block: var(--rhc-space-max-sm);
margin: 0;
}
.radio-option .form-check-input { margin: 0; float: none; }
`],
styles: [
`
.radio-option {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
padding-block: var(--rhc-space-max-sm);
margin: 0;
}
.radio-option .form-check-input {
margin: 0;
float: none;
}
`,
],
template: `
<div role="radiogroup">
@for (opt of options; track opt.value) {
@@ -26,7 +31,8 @@ import type { DeliveryChannel } from '@shared/upload/upload.machine';
[value]="opt.value"
[checked]="channel() === opt.value"
[disabled]="disabled()"
(change)="channelChange.emit(opt.value)" />
(change)="channelChange.emit(opt.value)"
/>
{{ opt.label }}
</label>
}

View File

@@ -12,5 +12,7 @@ const meta: Meta<DeliveryChannelToggleComponent> = {
export default meta;
type Story = StoryObj<DeliveryChannelToggleComponent>;
export const Digital: Story = { args: { channel: 'digital', name: 'diploma-channel', disabled: false } };
export const Digital: Story = {
args: { channel: 'digital', name: 'diploma-channel', disabled: false },
};
export const Post: Story = { args: { channel: 'post', name: 'diploma-channel', disabled: false } };

View File

@@ -11,16 +11,37 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
@Component({
selector: 'app-document-category',
imports: [DeliveryChannelToggleComponent, FileInputComponent, SingleUploadComponent],
styles: [`
:host { display: block; }
.label { font-weight: var(--rhc-text-font-weight-semi-bold); margin-block-end: var(--rhc-space-max-sm); }
.req { font-weight: var(--rhc-text-font-weight-regular); color: var(--rhc-color-foreground-subtle); }
.desc { color: var(--rhc-color-foreground-subtle); font-size: var(--rhc-text-font-size-sm); margin-block-end: var(--rhc-space-max-md); }
.file-list { list-style: none; padding: 0; margin-block-start: var(--rhc-space-max-md); }
`],
styles: [
`
:host {
display: block;
}
.label {
font-weight: var(--rhc-text-font-weight-semi-bold);
margin-block-end: var(--rhc-space-max-sm);
}
.req {
font-weight: var(--rhc-text-font-weight-regular);
color: var(--rhc-color-foreground-subtle);
}
.desc {
color: var(--rhc-color-foreground-subtle);
font-size: var(--rhc-text-font-size-sm);
margin-block-end: var(--rhc-space-max-md);
}
.file-list {
list-style: none;
padding: 0;
margin-block-start: var(--rhc-space-max-md);
}
`,
],
template: `
<div class="label">
{{ category().label }}@if (category().required) { <span class="req" i18n="@@upload.category.required">(verplicht)</span> }
{{ category().label }}
@if (category().required) {
<span class="req" i18n="@@upload.category.required">(verplicht)</span>
}
</div>
@if (category().description) {
<div class="desc">{{ category().description }}</div>
@@ -30,7 +51,8 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
<app-delivery-channel-toggle
[channel]="channel()"
[name]="category().categoryId + '-channel'"
(channelChange)="channelChange.emit($event)" />
(channelChange)="channelChange.emit($event)"
/>
}
@if (channel() === 'digital') {
@@ -47,18 +69,21 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
[accept]="category().acceptedTypes"
[maxSizeMb]="category().maxSizeMb"
[multiple]="category().multiple"
(filesSelected)="fileSelected.emit($event)" />
(filesSelected)="fileSelected.emit($event)"
/>
@if (uploads().length) {
<ul class="file-list">
@for (u of uploads(); track u.localId) {
<li app-single-upload
<li
app-single-upload
animate.enter="app-item-enter"
animate.leave="app-item-leave"
[upload]="u"
[previewUrlFor]="previewUrlFor()"
(remove)="onRemove(u)"
(retry)="retryUpload.emit(u.localId)"></li>
(retry)="retryUpload.emit(u.localId)"
></li>
}
</ul>
}
@@ -73,7 +98,10 @@ export class DocumentCategoryComponent {
previewUrlFor = input<(documentId: string) => string | undefined>();
/** Accessible name for the file picker, e.g. "Bestand kiezen voor Diploma". */
protected fileInputLabel = computed(() => $localize`:@@upload.fileInput.labelFor:Bestand kiezen voor ${this.category().label}:category:`);
protected fileInputLabel = computed(
() =>
$localize`:@@upload.fileInput.labelFor:Bestand kiezen voor ${this.category().label}:category:`,
);
fileSelected = output<File[]>();
removeUpload = output<string>();

View File

@@ -40,5 +40,10 @@ export const Digital: Story = {
};
export const WithRejection: Story = {
args: { category, uploads: [], channel: 'digital', rejection: 'Dit bestandstype is niet toegestaan voor deze categorie.' },
args: {
category,
uploads: [],
channel: 'digital',
rejection: 'Dit bestandstype is niet toegestaan voor deze categorie.',
},
};

View File

@@ -19,17 +19,30 @@ const STATUS_LABELS: Record<UploadStatus['type'], string> = {
@Component({
selector: 'app-document-chip',
imports: [UploadStatusIconComponent],
styles: [`
:host { display: contents; }
.file { display: flex; flex-direction: column; gap: var(--rhc-space-max-xs); min-inline-size: 0; }
.file-name { word-break: break-all; }
`],
styles: [
`
:host {
display: contents;
}
.file {
display: flex;
flex-direction: column;
gap: var(--rhc-space-max-xs);
min-inline-size: 0;
}
.file-name {
word-break: break-all;
}
`,
],
template: `
<div class="file">
<span class="d-inline-flex align-items-center gap-2">
<app-upload-status-icon [status]="status().type" />
@if (previewUrl()) {
<a class="file-name" [href]="previewUrl()" target="_blank" rel="noopener">{{ fileName() }}</a>
<a class="file-name" [href]="previewUrl()" target="_blank" rel="noopener">{{
fileName()
}}</a>
} @else {
<span class="file-name">{{ fileName() }}</span>
}

View File

@@ -15,13 +15,26 @@ export default meta;
type Story = StoryObj<DocumentChipComponent>;
export const Complete: Story = {
args: { fileName: 'diploma.pdf', fileSizeMb: 1.2, status: { type: 'complete', documentId: 'doc-1' } as UploadStatus },
args: {
fileName: 'diploma.pdf',
fileSizeMb: 1.2,
status: { type: 'complete', documentId: 'doc-1' } as UploadStatus,
},
};
export const WithPreview: Story = {
args: { fileName: 'diploma.pdf', fileSizeMb: 1.2, status: { type: 'complete', documentId: 'doc-1' } as UploadStatus, previewUrl: '/api/v1/uploads/doc-1/content' },
args: {
fileName: 'diploma.pdf',
fileSizeMb: 1.2,
status: { type: 'complete', documentId: 'doc-1' } as UploadStatus,
previewUrl: '/api/v1/uploads/doc-1/content',
},
};
export const Failed: Story = {
args: { fileName: 'diploma.pdf', fileSizeMb: 1.2, status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus },
args: {
fileName: 'diploma.pdf',
fileSizeMb: 1.2,
status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus,
},
};

View File

@@ -9,9 +9,15 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat
@Component({
selector: 'app-document-upload',
imports: [DocumentCategoryComponent, UploadStatusBannerComponent],
styles: [`
:host { display: flex; flex-direction: column; gap: var(--rhc-space-max-xl); }
`],
styles: [
`
:host {
display: flex;
flex-direction: column;
gap: var(--rhc-space-max-xl);
}
`,
],
template: `
@if (state().categoriesError) {
<app-upload-status-banner type="error" [message]="state().categoriesError!" />
@@ -30,7 +36,8 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat
(removeUpload)="removeUpload.emit($event)"
(retryUpload)="retryUpload.emit($event)"
(deleteUpload)="deleteUpload.emit($event)"
(channelChange)="channelChange.emit({ categoryId: c.categoryId, channel: $event })" />
(channelChange)="channelChange.emit({ categoryId: c.categoryId, channel: $event })"
/>
}
}
`,

View File

@@ -21,21 +21,47 @@ const TYPE_LABELS: Record<string, string> = {
`aria-describedby` (pattern requirement). Pure UI — no validation, no upload. */
@Component({
selector: 'app-file-input',
styles: [`
:host { display: block; }
.file-picker-drop-area { display: flex; flex-direction: column; align-items: flex-start; gap: var(--rhc-space-max-sm); }
/* Default (not subtle) foreground: subtle grey on the grey drop-area fails WCAG AA contrast. */
.upload-instructions { margin: 0; color: var(--rhc-color-foreground-default); font-size: var(--rhc-text-font-size-sm); }
/* Solid CIBG-blue button; the .btn-upload ::before folder glyph is the icon, so
styles: [
`
:host {
display: block;
}
.file-picker-drop-area {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--rhc-space-max-sm);
}
/* Default (not subtle) foreground: subtle grey on the grey drop-area fails WCAG AA contrast. */
.upload-instructions {
margin: 0;
color: var(--rhc-color-foreground-default);
font-size: var(--rhc-text-font-size-sm);
}
/* Solid CIBG-blue button; the .btn-upload ::before folder glyph is the icon, so
drop its redundant centred background-image folder. */
.btn-upload { cursor: pointer; background-image: none; }
.btn-upload.disabled { cursor: not-allowed; }
/* The input is visually hidden but still focusable; surface its focus on the button. */
input:focus-visible + .btn-upload { outline: var(--rhc-border-width-md) solid var(--rhc-color-foreground-link); outline-offset: 2px; }
`],
.btn-upload {
cursor: pointer;
background-image: none;
}
.btn-upload.disabled {
cursor: not-allowed;
}
/* The input is visually hidden but still focusable; surface its focus on the button. */
input:focus-visible + .btn-upload {
outline: var(--rhc-border-width-md) solid var(--rhc-color-foreground-link);
outline-offset: 2px;
}
`,
],
template: `
<div class="file-picker-drop-area" [class.drag-over]="dragging()"
(dragover)="onDragOver($event)" (dragleave)="onDragLeave($event)" (drop)="onDrop($event)">
<div
class="file-picker-drop-area"
[class.drag-over]="dragging()"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
>
@if (instructions()) {
<p class="upload-instructions" [id]="instructionsId">{{ instructions() }}</p>
}
@@ -49,8 +75,13 @@ const TYPE_LABELS: Record<string, string> = {
[accept]="accept().join(',')"
[multiple]="multiple()"
[disabled]="disabled()"
(change)="onChange($event)" />
<label class="btn btn-primary btn-upload" [class.disabled]="disabled()" [attr.for]="inputId()">
(change)="onChange($event)"
/>
<label
class="btn btn-primary btn-upload"
[class.disabled]="disabled()"
[attr.for]="inputId()"
>
{{ buttonText() }}
</label>
</div>
@@ -80,9 +111,12 @@ export class FileInputComponent {
/** Always-visible instruction: allowed file types + max size (CIBG pattern). */
protected instructions = computed(() => {
const types = this.accept().map((m) => TYPE_LABELS[m] ?? m.split('/').pop()?.toUpperCase() ?? m).join(', ');
const types = this.accept()
.map((m) => TYPE_LABELS[m] ?? m.split('/').pop()?.toUpperCase() ?? m)
.join(', ');
const size = this.maxSizeMb();
if (types && size > 0) return $localize`:@@upload.fileInput.instrBoth:Toegestaan: ${types}:types: · max ${size}:size: MB`;
if (types && size > 0)
return $localize`:@@upload.fileInput.instrBoth:Toegestaan: ${types}:types: · max ${size}:size: MB`;
if (types) return $localize`:@@upload.fileInput.instrTypes:Toegestaan: ${types}:types:`;
if (size > 0) return $localize`:@@upload.fileInput.instrSize:Max ${size}:size: MB`;
return '';

View File

@@ -13,13 +13,31 @@ export default meta;
type Story = StoryObj<FileInputComponent>;
export const Default: Story = {
args: { inputId: 'diploma', accept: ['application/pdf', 'image/jpeg'], maxSizeMb: 10, multiple: false, disabled: false },
args: {
inputId: 'diploma',
accept: ['application/pdf', 'image/jpeg'],
maxSizeMb: 10,
multiple: false,
disabled: false,
},
};
export const Multiple: Story = {
args: { inputId: 'cv', accept: ['application/pdf'], maxSizeMb: 5, multiple: true, disabled: false },
args: {
inputId: 'cv',
accept: ['application/pdf'],
maxSizeMb: 5,
multiple: true,
disabled: false,
},
};
export const Disabled: Story = {
args: { inputId: 'diploma-disabled', accept: ['application/pdf'], maxSizeMb: 10, multiple: false, disabled: true },
args: {
inputId: 'diploma-disabled',
accept: ['application/pdf'],
maxSizeMb: 10,
multiple: false,
disabled: true,
},
};

View File

@@ -11,24 +11,55 @@ import { UploadProgressBarComponent } from '../upload-progress-bar/upload-progre
selector: 'li[app-single-upload]',
host: { class: 'file-container' },
imports: [DocumentChipComponent, UploadProgressBarComponent],
styles: [`
:host { flex-wrap: wrap; align-items: center; }
.upload-progress { flex: 1 0 100%; margin-block-start: var(--rhc-space-max-sm); }
.actions .btn { background: none; border: none; cursor: pointer; padding: 0; }
.actions .btn-retry { color: var(--rhc-color-foreground-link); text-decoration: underline; font: inherit; }
`],
styles: [
`
:host {
flex-wrap: wrap;
align-items: center;
}
.upload-progress {
flex: 1 0 100%;
margin-block-start: var(--rhc-space-max-sm);
}
.actions .btn {
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.actions .btn-retry {
color: var(--rhc-color-foreground-link);
text-decoration: underline;
font: inherit;
}
`,
],
template: `
<app-document-chip
[fileName]="upload().fileName"
[status]="upload().status"
[fileSizeMb]="upload().fileSizeMb"
[previewUrl]="previewUrl()" />
[previewUrl]="previewUrl()"
/>
<div class="actions">
@if (upload().status.type === 'failed') {
<button type="button" class="btn btn-retry" [attr.aria-label]="retryLabel" (click)="retry.emit()" i18n="@@upload.chip.retry">Opnieuw</button>
<button
type="button"
class="btn btn-retry"
[attr.aria-label]="retryLabel"
(click)="retry.emit()"
i18n="@@upload.chip.retry"
>
Opnieuw
</button>
}
@if (upload().status.type !== 'deleting') {
<button type="button" class="btn icon-remove" [attr.aria-label]="removeLabel" (click)="remove.emit()"></button>
<button
type="button"
class="btn icon-remove"
[attr.aria-label]="removeLabel"
(click)="remove.emit()"
></button>
}
</div>
@if (progressPct() !== null) {

View File

@@ -4,16 +4,27 @@ import { Component, input } from '@angular/core';
the percentage. */
@Component({
selector: 'app-upload-progress-bar',
styles: [`
:host { display: flex; align-items: center; gap: var(--rhc-space-max-md); }
progress { flex: 1; height: var(--rhc-space-max-md); }
.pct { font-size: var(--rhc-text-font-size-sm); color: var(--rhc-color-foreground-subtle); min-width: 3ch; text-align: end; }
`],
styles: [
`
:host {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
}
progress {
flex: 1;
height: var(--rhc-space-max-md);
}
.pct {
font-size: var(--rhc-text-font-size-sm);
color: var(--rhc-color-foreground-subtle);
min-width: 3ch;
text-align: end;
}
`,
],
template: `
<progress
max="100"
[value]="progressPct()"
[attr.aria-label]="progressLabel"></progress>
<progress max="100" [value]="progressPct()" [attr.aria-label]="progressLabel"></progress>
<span class="pct">{{ progressPct() }}%</span>
`,
})

View File

@@ -19,5 +19,7 @@ export class UploadStatusBannerComponent {
message = input.required<string>();
type = input<BannerType>('info');
protected readonly alertType = computed<AlertType>(() => (this.type() === 'info' ? 'info' : this.type()));
protected readonly alertType = computed<AlertType>(() =>
this.type() === 'info' ? 'info' : this.type(),
);
}

View File

@@ -11,12 +11,21 @@ interface Glyph {
label derive purely from the status type. */
@Component({
selector: 'app-upload-status-icon',
styles: [`
.glyph { font-weight: var(--rhc-text-font-weight-semi-bold); }
`],
styles: [
`
.glyph {
font-weight: var(--rhc-text-font-weight-semi-bold);
}
`,
],
template: `
@if (glyph()) {
<span class="glyph" [style.color]="glyph()!.color" [attr.aria-label]="glyph()!.label" role="img">
<span
class="glyph"
[style.color]="glyph()!.color"
[attr.aria-label]="glyph()!.label"
role="img"
>
{{ glyph()!.char }}
</span>
}
@@ -28,15 +37,35 @@ export class UploadStatusIconComponent {
protected readonly glyph = computed<Glyph | null>(() => {
switch (this.status()) {
case 'queued':
return { char: '…', label: $localize`:@@upload.status.queued:In wachtrij`, color: 'var(--rhc-color-foreground-subtle)' };
return {
char: '…',
label: $localize`:@@upload.status.queued:In wachtrij`,
color: 'var(--rhc-color-foreground-subtle)',
};
case 'uploading':
return { char: '↑', label: $localize`:@@upload.status.uploading:Bezig met uploaden`, color: 'var(--rhc-color-lintblauw-600)' };
return {
char: '↑',
label: $localize`:@@upload.status.uploading:Bezig met uploaden`,
color: 'var(--rhc-color-lintblauw-600)',
};
case 'complete':
return { char: '✓', label: $localize`:@@upload.status.complete:Geüpload`, color: 'var(--rhc-color-groen-500)' };
return {
char: '✓',
label: $localize`:@@upload.status.complete:Geüpload`,
color: 'var(--rhc-color-groen-500)',
};
case 'failed':
return { char: '✕', label: $localize`:@@upload.status.failed:Mislukt`, color: 'var(--rhc-color-rood-500)' };
return {
char: '✕',
label: $localize`:@@upload.status.failed:Mislukt`,
color: 'var(--rhc-color-rood-500)',
};
case 'deleting':
return { char: '⟳', label: $localize`:@@upload.status.deleting:Bezig met verwijderen`, color: 'var(--rhc-color-foreground-subtle)' };
return {
char: '⟳',
label: $localize`:@@upload.status.deleting:Bezig met verwijderen`,
color: 'var(--rhc-color-foreground-subtle)',
};
case 'idle':
case 'deleted':
return null;