From d166df3a52283c7f5dc6cc3c803e9eee57be0728 Mon Sep 17 00:00:00 2001 From: Kai Niebes Date: Thu, 30 Jul 2026 22:17:15 +0200 Subject: [PATCH 1/2] Feature: News editor for users with specific user flag --- src/app/components/index.ts | 1 + .../news-card/news-card.component.html | 27 +++ .../news-card/news-card.component.scss | 135 ++++++++++++++ .../news-card/news-card.component.ts | 41 ++++ .../news-dialog/news-dialog.component.html | 96 ++++++++++ .../news-dialog/news-dialog.component.scss | 88 +++++++++ .../news-dialog/news-dialog.component.ts | 176 ++++++++++++++++++ src/app/pages/home/home.component.html | 51 +++-- src/app/pages/home/home.component.scss | 70 ++----- src/app/pages/home/home.component.ts | 111 ++++++----- .../compilations/compilations.component.ts | 1 - src/app/services/account.service.ts | 8 + src/app/services/backend.service.ts | 149 +++++++++++++++ src/app/services/dialog-helper.service.ts | 15 ++ 14 files changed, 852 insertions(+), 117 deletions(-) create mode 100644 src/app/components/news-card/news-card.component.html create mode 100644 src/app/components/news-card/news-card.component.scss create mode 100644 src/app/components/news-card/news-card.component.ts create mode 100644 src/app/dialogs/news-dialog/news-dialog.component.html create mode 100644 src/app/dialogs/news-dialog/news-dialog.component.scss create mode 100644 src/app/dialogs/news-dialog/news-dialog.component.ts diff --git a/src/app/components/index.ts b/src/app/components/index.ts index 962b1ddf..db8b19ee 100644 --- a/src/app/components/index.ts +++ b/src/app/components/index.ts @@ -12,3 +12,4 @@ export { SidenavListComponent } from './navigation/sidenav-list/sidenav-list.com export { NavbarComponent } from './navigation/navbar/navbar.component'; export { FooterComponent } from './navigation/footer/footer.component'; export { UploadComponent } from './upload/upload.component'; +export { NewsCardComponent } from './news-card/news-card.component'; diff --git a/src/app/components/news-card/news-card.component.html b/src/app/components/news-card/news-card.component.html new file mode 100644 index 00000000..88639937 --- /dev/null +++ b/src/app/components/news-card/news-card.component.html @@ -0,0 +1,27 @@ +@if (newsItem().imageUrl) { +
+ {{ newsItem().title }} + @if (!newsItem().published) { + {{ 'Unpublished' | translate }} + } +
+} +@if (!newsItem().published && !newsItem().imageUrl) { + {{ 'Unpublished' | translate }} +} +

{{ newsItem().title }}

+
+

{{ newsItem().content }}

+
+{{ newsItem().date | date: 'mediumDate' }} + +@if (showActions()) { +
+ + +
+} diff --git a/src/app/components/news-card/news-card.component.scss b/src/app/components/news-card/news-card.component.scss new file mode 100644 index 00000000..099b93fd --- /dev/null +++ b/src/app/components/news-card/news-card.component.scss @@ -0,0 +1,135 @@ +:host { + display: block; + + display: flex; + flex-direction: column; + cursor: pointer; + gap: 8px; + align-items: stretch; + padding: 24px; + box-sizing: border-box; + border-radius: 4px; + box-shadow: var(--card-shadow); + overflow: hidden; + height: 420px; + position: relative; + + p, + h3 { + margin: 0; + } + h3 { + margin-bottom: 8px; + color: var(--brand-color); + } + div.content { + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; + height: 144px; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 32px; + background: linear-gradient(to bottom, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1)); + } + } + .image-wrapper { + position: relative; + width: calc(100% + 48px); + height: 164px; + margin-top: -24px; + margin-left: -24px; + margin-right: -24px; + margin-bottom: 8px; + overflow: hidden; + + .unpublished-badge { + position: absolute; + top: 8px; + left: 8px; + background: rgba(0, 0, 0, 0.65); + color: #fff; + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.5px; + z-index: 1; + pointer-events: none; + } + } + + img { + width: calc(100% + 48px); + height: 164px; + object-fit: cover; + margin-top: -24px; + margin-left: -24px; + margin-right: -24px; + margin-bottom: 8px; + border-bottom: solid 2px rgba(0, 0, 0, 0.1); + background: rgba(0, 0, 0, 0.05); + user-select: none; + } + .unpublished-badge.no-image { + display: inline-block; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(4px); + color: #fff; + padding: 4px 10px; + border-radius: 4px; + font-size: 14px; + width: fit-content; + pointer-events: none; + } + + small { + margin-top: auto; + color: var(--small-gray); + text-align: end; + } + div.actions { + position: absolute; + top: 8px; + right: 8px; + display: flex; + align-items: center; + gap: 12px; + opacity: 0; + transition: opacity 0.2s; + background-color: white; + padding: 12px; + border-radius: 24px; + + .mat-mdc-icon-button { + --mdc-icon-button-state-layer-size: 24px; + --mat-icon-button-state-layer-size: 24px; + --mat-icon-button-touch-target-size: 32px; + padding: 0; + .mat-icon { + font-size: 24px; + line-height: 24px; + width: 32px; + height: 32px; + } + } + } + &:hover div.actions { + opacity: 1; + } + + &.is-preview { + cursor: default; + pointer-events: none; + user-select: none; + } +} diff --git a/src/app/components/news-card/news-card.component.ts b/src/app/components/news-card/news-card.component.ts new file mode 100644 index 00000000..9baa47ed --- /dev/null +++ b/src/app/components/news-card/news-card.component.ts @@ -0,0 +1,41 @@ +import { Component, output, input } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslatePipe } from 'src/app/pipes'; +import type { INewsItem } from '@kompakkt/common'; + +@Component({ + selector: 'app-news-card', + templateUrl: './news-card.component.html', + styleUrl: './news-card.component.scss', + imports: [DatePipe, MatIconModule, MatButtonModule, MatTooltipModule, TranslatePipe], + host: { + '[class.is-preview]': 'isPreview()', + '(click)': 'openLink(newsItem().link)', + }, +}) +export class NewsCardComponent { + newsItem = input.required(); + showActions = input(false); + isPreview = input(false); + + edit = output(); + delete = output(); + + openLink(url?: string) { + if (this.isPreview()) return; + if (url) window.open(url, '_blank', 'noopener,noreferrer'); + } + + onEdit(event: Event) { + event.stopPropagation(); + this.edit.emit(this.newsItem()); + } + + onDelete(event: Event) { + event.stopPropagation(); + this.delete.emit(this.newsItem()); + } +} diff --git a/src/app/dialogs/news-dialog/news-dialog.component.html b/src/app/dialogs/news-dialog/news-dialog.component.html new file mode 100644 index 00000000..6a717d40 --- /dev/null +++ b/src/app/dialogs/news-dialog/news-dialog.component.html @@ -0,0 +1,96 @@ +

+ @if (existing) { + {{ 'Edit news item' | translate }} + } @else { + {{ 'Create news item' | translate }} + } + +

+ +
+
+
+ + + @if (newsFormGroup.controls.title.touched && newsFormGroup.controls.title.invalid) { + {{ 'Title is required (min 3 characters)' | translate }} + } +
+ +
+ + + {{ contentLength }} / {{ maxContentLength }} + @if (newsFormGroup.controls.content.touched && newsFormGroup.controls.content.invalid) { + {{ 'Content is required (max 240 characters)' | translate }} + } +
+ +
+ + +
+ +
+ +
+ + + + @if (imagePreviewUrl()) { + + } +
+
+ +
+ + {{ 'Published' | translate }} + +
+
+ +
+

{{ 'Live preview' | translate }}

+ +
+
+ +
+ + +
diff --git a/src/app/dialogs/news-dialog/news-dialog.component.scss b/src/app/dialogs/news-dialog/news-dialog.component.scss new file mode 100644 index 00000000..7c3c960d --- /dev/null +++ b/src/app/dialogs/news-dialog/news-dialog.component.scss @@ -0,0 +1,88 @@ +@use '../../styles/dialog.scss' as *; + +:host { + @include dialog-style(); + min-width: min(720px, 95vw); +} + +.dialog-content { + display: flex; + gap: 24px; + flex-wrap: wrap; +} + +.form-section { + flex: 1 1 320px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.preview-section { + flex: 1 1 300px; + + h3 { + margin: 0 0 12px 0; + font-size: 16px; + color: var(--brand-color); + } + + app-news-card { + display: block; + max-width: 300px; + } +} + +.form-field { + display: flex; + flex-direction: column; + gap: 4px; + + label { + font-size: 14px; + font-weight: 500; + } + + input, + textarea { + padding: 8px 12px; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 4px; + font-size: 14px; + font-family: inherit; + + &:focus { + outline: none; + border-color: var(--brand-color); + } + } + + textarea { + resize: vertical; + } + + .char-counter { + font-size: 12px; + color: var(--small-gray); + text-align: right; + } + + .error { + color: #f44336; + font-size: 12px; + } +} + +.image-input-row { + display: flex; + gap: 8px; + align-items: center; + + input { + flex: 1; + } + + mat-spinner { + display: inline-block; + } +} diff --git a/src/app/dialogs/news-dialog/news-dialog.component.ts b/src/app/dialogs/news-dialog/news-dialog.component.ts new file mode 100644 index 00000000..4f6370f1 --- /dev/null +++ b/src/app/dialogs/news-dialog/news-dialog.component.ts @@ -0,0 +1,176 @@ +import { Component, computed, ElementRef, inject, signal, viewChild } from '@angular/core'; +import { + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslatePipe } from 'src/app/pipes'; +import { BackendService } from 'src/app/services'; +import { NewsCardComponent } from 'src/app/components/news-card/news-card.component'; +import type { INewsItem } from '@kompakkt/common'; + +@Component({ + selector: 'app-news-dialog', + templateUrl: './news-dialog.component.html', + styleUrl: './news-dialog.component.scss', + imports: [ + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatSlideToggleModule, + MatProgressSpinnerModule, + MatTooltipModule, + TranslatePipe, + NewsCardComponent, + ], +}) +export class NewsDialogComponent { + #backend = inject(BackendService); + #dialogRef = inject>(MatDialogRef); + existing = inject(MAT_DIALOG_DATA, { optional: true }); + + isUploading = signal(false); + imagePreviewUrl = signal(''); + + fileInput = viewChild>('fileInput'); + + triggerFileInput() { + console.log('[NewsDialog] triggerFileInput called'); + const input = this.fileInput(); + console.log('[NewsDialog] fileInput ref:', input); + if (input) { + console.log('[NewsDialog] nativeElement:', input.nativeElement); + input.nativeElement.click(); + console.log('[NewsDialog] click() called on file input'); + } else { + console.warn('[NewsDialog] fileInput ref is null — element may not be in DOM'); + } + } + + newsFormGroup = new FormGroup({ + title: new FormControl('', { + validators: [Validators.required, Validators.minLength(3), Validators.maxLength(120)], + nonNullable: true, + }), + content: new FormControl('', { + validators: [Validators.required, Validators.maxLength(240)], + nonNullable: true, + }), + link: new FormControl('', { + validators: [Validators.maxLength(500)], + nonNullable: true, + }), + imageUrl: new FormControl('', { + validators: [Validators.maxLength(500)], + nonNullable: true, + }), + published: new FormControl(false, { nonNullable: true }), + }); + + previewItem = computed(() => { + const form = this.newsFormGroup.getRawValue(); + return { + _id: this.existing?._id ?? '', + title: form.title || 'Preview title', + content: form.content || 'Preview content text...', + link: form.link || '', + imageUrl: form.imageUrl || this.imagePreviewUrl() || '', + author: this.existing?.author ?? 'You', + createdBy: this.existing?.createdBy ?? '', + published: form.published, + date: this.existing?.date ?? new Date().toISOString(), + } as INewsItem; + }); + + get contentLength() { + return this.newsFormGroup.controls.content.value.length; + } + + get maxContentLength() { + return 240; + } + + async onImageSelected(event: Event) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = e => { + this.imagePreviewUrl.set(e.target?.result as string); + }; + reader.readAsDataURL(file); + + this.isUploading.set(true); + try { + const result = await this.#backend.uploadNewsImage(file); + this.newsFormGroup.controls.imageUrl.setValue(result.url); + this.imagePreviewUrl.set(result.url); + } catch (err) { + console.error('Failed to upload news image:', err); + } finally { + this.isUploading.set(false); + } + } + + clearImage() { + this.newsFormGroup.controls.imageUrl.setValue(''); + this.imagePreviewUrl.set(''); + } + + async save() { + if (!this.newsFormGroup.valid) { + this.newsFormGroup.markAllAsTouched(); + return; + } + + const { title, content, link, imageUrl, published } = this.newsFormGroup.getRawValue(); + + try { + if (this.existing) { + const updated = await this.#backend.updateNews(this.existing._id.toString(), { + title, + content, + link: link || undefined, + imageUrl: imageUrl || undefined, + published, + }); + this.#dialogRef.close(updated); + } else { + const created = await this.#backend.createNews({ + title, + content, + link: link || undefined, + imageUrl: imageUrl || undefined, + published, + }); + this.#dialogRef.close(created); + } + } catch (err) { + console.error('Failed to save news item:', err); + } + } + + ngOnInit() { + if (this.existing) { + this.newsFormGroup.patchValue({ + title: this.existing.title, + content: this.existing.content, + link: this.existing.link ?? '', + imageUrl: this.existing.imageUrl ?? '', + published: this.existing.published, + }); + this.imagePreviewUrl.set(this.existing.imageUrl ?? ''); + } + } +} diff --git a/src/app/pages/home/home.component.html b/src/app/pages/home/home.component.html index 4be214bf..13b3948c 100644 --- a/src/app/pages/home/home.component.html +++ b/src/app/pages/home/home.component.html @@ -80,18 +80,47 @@

{{ 'Open source' | translate }}

-

{{ 'Latest updates and blog posts' | translate }}

+

+ {{ 'Latest updates and blog posts' | translate }} +

- @for (newsItem of newsItems(); track $index) { -
- {{ newsItem.title }} -

{{ newsItem.title }}

-
- @for (line of newsItem.lines; track $index) { -

{{ line }}

- } -
- {{ newsItem.date | date: 'mediumDate' }} + @if (canModifyNews()) { +
+ + + @if (showUnpublishedNews()) { + + } @else { + + }
} + + @for (newsItem of visibleNewsItems(); track newsItem._id) { + + }
diff --git a/src/app/pages/home/home.component.scss b/src/app/pages/home/home.component.scss index b5d98f60..e34d643b 100644 --- a/src/app/pages/home/home.component.scss +++ b/src/app/pages/home/home.component.scss @@ -129,6 +129,7 @@ div.features, div.news { display: grid; max-width: calc(var(--relative-unit) * 2.25); + width: 100%; margin: 0 auto; grid-template-columns: repeat(3, 1fr); gap: 92px; @@ -154,66 +155,17 @@ div.news { grid-column: span 3; text-align: center; } - h3 { - color: var(--brand-color); - } - - div.news-item { + div.news-editor-actions { + grid-column: span 3; display: flex; - flex-direction: column; - cursor: pointer; gap: 8px; - align-items: stretch; - padding: 24px; - box-sizing: border-box; - border-radius: 4px; - box-shadow: var(--card-shadow); - overflow: hidden; - height: 420px; - - p, - h3 { - margin: 0; - } - h3 { - margin-bottom: 8px; - } - div.content { - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: 8px; - height: 144px; - overflow: hidden; - text-overflow: ellipsis; - position: relative; - &::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 32px; - background: linear-gradient(to bottom, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1)); - } - } - img { - width: calc(100% + 48px); - height: 164px; - object-fit: cover; - margin-top: -24px; - margin-left: -24px; - margin-right: -24px; - margin-bottom: 8px; - border-bottom: solid 2px rgba(0, 0, 0, 0.1); - background: rgba(0, 0, 0, 0.05); - user-select: none; - } - small { - margin-top: auto; - color: var(--small-gray); - text-align: end; - } + align-items: center; + justify-content: center; + width: 100%; + margin-top: -24px; + } + + app-news-card { + display: block; } } diff --git a/src/app/pages/home/home.component.ts b/src/app/pages/home/home.component.ts index 2bf7bc96..6b37e719 100644 --- a/src/app/pages/home/home.component.ts +++ b/src/app/pages/home/home.component.ts @@ -8,30 +8,21 @@ import { CustomBrandingPlugin } from '@kompakkt/plugins/custom-branding'; import { TranslatePipe } from 'src/app/pipes'; import { getViewerUrl } from 'src/app/util/get-viewer-url'; import { SafePipe } from '../../pipes/safe.pipe'; -import { EventsService } from 'src/app/services'; +import { + AccountService, + BackendService, + DialogHelperService, + EventsService, +} from 'src/app/services'; import { filter, firstValueFrom } from 'rxjs'; -import { DatePipe } from '@angular/common'; - -type NewsItem = { - title: string; - lines: [string] | [string, string]; - link: string; - date: Date; - imageUrl: string; -}; +import { NewsCardComponent } from 'src/app/components/news-card/news-card.component'; +import type { INewsItem } from '@kompakkt/common'; @Component({ selector: 'app-home', templateUrl: './home.component.html', styleUrls: ['./home.component.scss'], - imports: [ - RouterLink, - MatIconModule, - MatButtonModule, - SafePipe, - TranslatePipe, - DatePipe, - ], + imports: [RouterLink, MatIconModule, MatButtonModule, SafePipe, TranslatePipe, NewsCardComponent], }) export class HomeComponent implements AfterViewInit { private metaTitle = 'Kompakkt – '; @@ -68,37 +59,21 @@ export class HomeComponent implements AfterViewInit { return settings?.base64Assets?.explorePageLogo; }); + #account = inject(AccountService); + #backend = inject(BackendService); + #dialogHelper = inject(DialogHelperService); + + newsItems = signal([]); + showUnpublishedNews = signal(false); + visibleNewsItems = computed(() => + this.showUnpublishedNews() ? this.newsItems() : this.newsItems().filter(n => n.published), + ); + canModifyNews = signal(false); + settingsLoadedEvent$ = this.eventsService.windowMessages$.pipe( filter(event => event.data.type === 'settingsLoaded'), ); - newsItems = signal([ - { - title: 'IIIF compatibility', - lines: [ - 'Kompakkt entered a phase of testing close compatibility with the new IIIF 3D API.', - 'Check out demo manifests loading in Kompakkt!', - ], - link: 'https://kompakkt.github.io/Viewer/?locale=en', - imageUrl: '/assets/images/news/kompakkt_loves_iiif.png', - date: new Date('2026-07-27T00:00:00Z'), - }, - { - title: 'A New Explore Page for Kompakkt: Find Faster, Curate better', - lines: ['We have completely redesigned the Explore Page of our 3D viewer to improve the discovery and organization of objects.'], - link: 'https://blog.tib.eu/2026/04/24/neue-explore-page-in-kompakkt-schneller-finden-besser-sammeln/', - imageUrl: 'https://blog.tib.eu/wp-content/uploads/2026/04/Bildschirmfoto-2026-04-15-um-14.54.58-2048x965.png', - date: new Date('2026-04-24T00:00:00Z'), - }, - { - title: 'Beyond Meshes: Support for Point Clouds and Gaussian Splatting', - lines: ['Not every research question can be answered using the same type of 3D data. While meshes are ideal for traditional 3D models, point clouds excel at representing precise measurement data, and Gaussian Splatting enables highly realistic visualizations of complex scenes.'], - link: 'https://blog.tib.eu/2026/07/24/mehr-als-meshes-unterstuetzung-fuer-punktwolken-und-gaussian-splatting/', - imageUrl: 'https://blog.tib.eu/wp-content/uploads/2026/07/pexels-steve-10194138-1-2048x1365.jpg', - date: new Date('2026-07-24T00:00:00Z'), - } - ]); - openExternalLink(url: string) { window.open(url, '_blank', 'noopener,noreferrer'); } @@ -115,6 +90,50 @@ export class HomeComponent implements AfterViewInit { this.viewerLoaded.set(true); }, 100); }); + + this.loadNews(); + + this.#account.flags.canModifyNews$.subscribe(hasFlag => { + this.canModifyNews.set(hasFlag); + }); + } + + private async loadNews() { + try { + const items = await this.#backend.getNews(); + this.newsItems.set(items as unknown as INewsItem[]); + } catch (err) { + console.error('Failed to load news items:', err); + } + } + + openCreateNewsDialog() { + const ref = this.#dialogHelper.openCreateNewsDialog(); + firstValueFrom(ref.afterClosed()).then(result => { + if (result) this.loadNews(); + }); + } + + openEditNewsDialog(item: INewsItem) { + const ref = this.#dialogHelper.openEditNewsDialog(item); + firstValueFrom(ref.afterClosed()).then(result => { + if (result) this.loadNews(); + }); + } + + async deleteNewsItem(item: INewsItem) { + const confirmed = await this.#dialogHelper.confirm( + `Delete news item "${item.title}"?`, + 'Delete news item', + ); + if (!confirmed) return; + + try { + await this.#backend.deleteNews(item._id.toString()); + this.loadNews(); + } catch (err) { + console.error('Failed to delete news item:', err); + } } // Fallback if communication with viewer fails for some reason - show the viewer after a delay @@ -126,7 +145,7 @@ export class HomeComponent implements AfterViewInit { ngAfterViewInit() { this.titleService.setTitle( - this.metaTitle + this.translatePipe.transform('’cause the world is multidimensional.'), + this.metaTitle + this.translatePipe.transform('\u2019cause the world is multidimensional.'), ); this.metaService.addTags(this.metaTags); diff --git a/src/app/pages/profile-page/compilations/compilations.component.ts b/src/app/pages/profile-page/compilations/compilations.component.ts index 0c5d7e4f..f7c4c53b 100644 --- a/src/app/pages/profile-page/compilations/compilations.component.ts +++ b/src/app/pages/profile-page/compilations/compilations.component.ts @@ -42,7 +42,6 @@ import { import { SelectionService } from 'src/app/services/selection.service'; import { Collection, ICompilation, isCompilation } from '@kompakkt/common'; import { MatCheckboxModule } from '@angular/material/checkbox'; -import { IsUserOfRolePipe } from 'src/app/pipes/is-user-of-role.pipe'; import { ExploreFilterOption } from '../../explore/explore-filter-option/explore-filter-option.component'; import { AvailableAnnotationOptions, diff --git a/src/app/services/account.service.ts b/src/app/services/account.service.ts index 87352456..42bb7163 100644 --- a/src/app/services/account.service.ts +++ b/src/app/services/account.service.ts @@ -16,6 +16,7 @@ import { IEntity, ProfileType, UserRank, + UserFlag, IAnnotation, IPublicProfile, IStrippedUserData, @@ -170,6 +171,13 @@ export class AccountService { $: this.user$.pipe(map(user => user?.role ?? 'guest')), ranks: UserRank, }; + flags = { + has$: (flag: UserFlag): Observable => + this.user$.pipe(map(user => user?.flags?.includes(flag) ?? false)), + canModifyNews$: this.user$.pipe( + map(user => user?.flags?.includes(UserFlag.canModifyNews) ?? false), + ), + }; // Finished: finishedEntities$ = this.entities$.pipe(map(arr => arr.filter(e => e.finished))); diff --git a/src/app/services/backend.service.ts b/src/app/services/backend.service.ts index 5cee1813..a53033f1 100644 --- a/src/app/services/backend.service.ts +++ b/src/app/services/backend.service.ts @@ -120,6 +120,16 @@ export class BackendService { return firstValueFrom(this.http.post(`${this.endpoint}${path}`, obj)); } + public async put(path: string, obj: any): Promise { + return firstValueFrom(this.http.put(`${this.endpoint}${path}`, obj)); + } + + public async delete(path: string): Promise { + return firstValueFrom(this.http.delete(`${this.endpoint}${path}`)); + } + + // Helper methods for type-safe API calls based on the OpenAPI spec provided by the backend. + // Helper methods for type-safe API calls based on the OpenAPI spec provided by the backend. private constructPathWithParams( @@ -226,6 +236,72 @@ export class BackendService { return firstValueFrom(this.createPostPromise(path, { body, pathParams, queryParams, options })); } + public createPutPromise( + path: Path, + { + body, + pathParams, + queryParams, + options, + }: { + body: RequestBody>; + pathParams: PathParams>; + queryParams: QueryParams>; + options?: Parameters[2]; + }, + ) { + const compiledPath = this.constructPathWithParams(path as string, { pathParams, queryParams }); + return this.http.put>>(compiledPath, body, options); + } + + public createPut( + path: Path, + { + body, + pathParams, + queryParams, + options, + }: { + body: RequestBody>; + pathParams: PathParams>; + queryParams: QueryParams>; + options?: Parameters[2]; + }, + ) { + return firstValueFrom(this.createPutPromise(path, { body, pathParams, queryParams, options })); + } + + public createDeletePromise( + path: Path, + { + pathParams, + queryParams, + options, + }: { + pathParams: PathParams>; + queryParams: QueryParams>; + options?: Parameters[1]; + }, + ) { + const compiledPath = this.constructPathWithParams(path as string, { pathParams, queryParams }); + return this.http.delete>>(compiledPath, options); + } + + public createDelete( + path: Path, + { + pathParams, + queryParams, + options, + }: { + pathParams: PathParams>; + queryParams: QueryParams>; + options?: Parameters[1]; + }, + ) { + return firstValueFrom(this.createDeletePromise(path, { pathParams, queryParams, options })); + } + // GETs private async getAllOfCollection(collection: Collection) { return this.createGet('/server/api/v1/get/findall/{collection}', { @@ -733,4 +809,77 @@ export class BackendService { queryParams: {}, }); } + + // News + public async getNews() { + return this.createGet('/server/api/v2/news/', { + pathParams: {}, + queryParams: {}, + }); + } + + public async createNews(body: { + title: string; + content: string; + link?: string; + imageUrl?: string; + published?: boolean; + }) { + return this.createPost('/server/api/v2/news/', { + body, + pathParams: {}, + queryParams: {}, + }); + } + + public async updateNews( + id: string, + body: { + title: string; + content: string; + link?: string; + imageUrl?: string; + published?: boolean; + }, + ) { + return this.createPut('/server/api/v2/news/{id}', { + body, + pathParams: { id }, + queryParams: {}, + }); + } + + public async deleteNews(id: string) { + return this.createDelete('/server/api/v2/news/{id}', { + pathParams: { id }, + queryParams: {}, + }); + } + + public async uploadNewsImage(file: File) { + // Convert file to base64 string and send it to the backend + const base64Image = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + resolve(result.split(',')[1]); // Get the base64 part + }; + reader.onerror = error => reject(error); + reader.readAsDataURL(file); + }).catch(error => { + console.error('Error reading file:', error); + return undefined; + }); + if (!base64Image) { + throw new Error('Failed to read file as base64'); + } + + return this.createPost('/server/api/v2/news/upload-image', { + body: { + file: base64Image, + }, + pathParams: {}, + queryParams: {}, + }); + } } diff --git a/src/app/services/dialog-helper.service.ts b/src/app/services/dialog-helper.service.ts index 5d92556d..78cead77 100644 --- a/src/app/services/dialog-helper.service.ts +++ b/src/app/services/dialog-helper.service.ts @@ -39,6 +39,8 @@ import { } from '../dialogs/remove-from-compilation/remove-from-compilation.component'; import { ManageOwnershipComponent } from '../dialogs/manage-ownership/manage-ownership.component'; import { EmbedObjectDialogComponent } from '../dialogs/embed-object-dialog/embed-object-dialog.component'; +import { NewsDialogComponent } from '../dialogs/news-dialog/news-dialog.component'; +import type { INewsItem } from '@kompakkt/common'; @Injectable({ providedIn: 'root', @@ -238,4 +240,17 @@ export class DialogHelperService { const loginData = await this.verifyAuthentication(authText); return loginData; } + + public openCreateNewsDialog() { + return this.#dialog.open(NewsDialogComponent, { + disableClose: true, + }); + } + + public openEditNewsDialog(item: INewsItem) { + return this.#dialog.open(NewsDialogComponent, { + data: item, + disableClose: true, + }); + } } From 72eaec5cf7e13e9a9ebf02f4d52d7f4e544a35c5 Mon Sep 17 00:00:00 2001 From: Kai Niebes Date: Fri, 31 Jul 2026 00:52:58 +0200 Subject: [PATCH 2/2] Fix: Hide "unpublished" badge in preview --- src/app/components/news-card/news-card.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/news-card/news-card.component.html b/src/app/components/news-card/news-card.component.html index 88639937..31c2cc29 100644 --- a/src/app/components/news-card/news-card.component.html +++ b/src/app/components/news-card/news-card.component.html @@ -1,12 +1,12 @@ @if (newsItem().imageUrl) {
{{ newsItem().title }} - @if (!newsItem().published) { + @if (!newsItem().published && !isPreview()) { {{ 'Unpublished' | translate }} }
} -@if (!newsItem().published && !newsItem().imageUrl) { +@if (!newsItem().published && !newsItem().imageUrl && !isPreview()) { {{ 'Unpublished' | translate }} }

{{ newsItem().title }}