-
{{ '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,
+ });
+ }
}