() + readonly displayedArticles = linkedSignal(this.articles) + readonly isRefreshing = input(false) + readonly favTagId = input.required() + readonly userTags = input.required() + + $display = this.pageService.$display + + async onArticleClick(article: Article) { + await this.router.navigate(['feed', article.feedId, 'article', article._id]) + } + + markAsRead(article: Article, event: MouseEvent) { + event.stopPropagation() + if (article) { + this.feedService + .changeOneArticle({ + articleId: article._id, + article: { + read: !article.read, + }, + }) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + ) + .subscribe((result) => { + if (result === null) { + return + } + this.displayedArticles.update((prev) => { + if (prev !== null) { + return prev.map((a) => (a._id === result._id ? { ...a, read: !a.read } : a)) + } + return prev + }) + }) + } + } + + tagHandler(article: Article, tagId: Tag['_id'], event: MouseEvent) { + const existingTag = article.tags.find((t) => t === tagId) + const tags = existingTag + ? [...article.tags].filter((t) => t !== tagId) + : [...article.tags, tagId] + + event.stopPropagation() + if (article) { + this.feedService + .changeOneArticle({ + articleId: article._id, + article: { tags }, + }) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + ) + .subscribe((result) => { + if (result === null) { + return + } + this.displayedArticles.update((prev) => + prev.map((a) => (a._id === result._id ? { ...a, tags } : a)), + ) + }) + } + } +} diff --git a/src/app/components/tag-add-form/tag-add-form.css b/src/app/components/bottom-error-sheet/bottom-error-sheet.css similarity index 100% rename from src/app/components/tag-add-form/tag-add-form.css rename to src/app/components/bottom-error-sheet/bottom-error-sheet.css diff --git a/src/app/components/bottom-error-sheet/bottom-error-sheet.html b/src/app/components/bottom-error-sheet/bottom-error-sheet.html new file mode 100644 index 0000000..f312e42 --- /dev/null +++ b/src/app/components/bottom-error-sheet/bottom-error-sheet.html @@ -0,0 +1,2 @@ +Error: +{{ data.error }} diff --git a/src/app/components/bottom-error-sheet/bottom-error-sheet.ts b/src/app/components/bottom-error-sheet/bottom-error-sheet.ts new file mode 100644 index 0000000..5f5d087 --- /dev/null +++ b/src/app/components/bottom-error-sheet/bottom-error-sheet.ts @@ -0,0 +1,13 @@ +import { Component, inject } from '@angular/core' +import { MAT_BOTTOM_SHEET_DATA, MatBottomSheetModule } from '@angular/material/bottom-sheet' +import { MatError } from '@angular/material/form-field' + +@Component({ + selector: 'app-bottom-error-sheet', + imports: [MatBottomSheetModule, MatError], + templateUrl: './bottom-error-sheet.html', + styleUrl: './bottom-error-sheet.css', +}) +export class BottomErrorSheet { + readonly data: { error?: string } = inject(MAT_BOTTOM_SHEET_DATA) +} diff --git a/src/app/components/confirmation-dialog/confirmation-dialog.css b/src/app/components/confirmation-dialog/confirmation-dialog.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/confirmation-dialog/confirmation-dialog.html b/src/app/components/confirmation-dialog/confirmation-dialog.html new file mode 100644 index 0000000..3cd50d0 --- /dev/null +++ b/src/app/components/confirmation-dialog/confirmation-dialog.html @@ -0,0 +1,16 @@ +{{ data?.title || "Please confirm" }} + + {{ data?.message }} + + + {{ data?.confirmButtonText || "Ok" }} + + Cancel + + diff --git a/src/app/components/confirmation-dialog/confirmation-dialog.ts b/src/app/components/confirmation-dialog/confirmation-dialog.ts new file mode 100644 index 0000000..294ffe9 --- /dev/null +++ b/src/app/components/confirmation-dialog/confirmation-dialog.ts @@ -0,0 +1,26 @@ +import { Component, inject } from '@angular/core' +import { + MAT_DIALOG_DATA, + MatDialogActions, + MatDialogClose, + MatDialogContent, + MatDialogRef, + MatDialogTitle, +} from '@angular/material/dialog' +import { MatButton } from '@angular/material/button' + +@Component({ + selector: 'app-confirmation-dialog', + imports: [MatDialogContent, MatDialogActions, MatButton, MatDialogTitle, MatDialogClose], + templateUrl: './confirmation-dialog.html', + styleUrl: './confirmation-dialog.css', +}) +export class ConfirmationDialog { + private readonly dialogRef = inject(MatDialogRef) + readonly data: { title?: string; message?: string; confirmButtonText?: string } | null = + inject(MAT_DIALOG_DATA) + + onAgree() { + this.dialogRef.close(true) + } +} diff --git a/src/app/components/subscription-add-form/subscription-add-form.css b/src/app/components/feed-add-form/feed-add-form.css similarity index 100% rename from src/app/components/subscription-add-form/subscription-add-form.css rename to src/app/components/feed-add-form/feed-add-form.css diff --git a/src/app/components/subscription-add-form/subscription-add-form.html b/src/app/components/feed-add-form/feed-add-form.html similarity index 86% rename from src/app/components/subscription-add-form/subscription-add-form.html rename to src/app/components/feed-add-form/feed-add-form.html index 169659e..8b7e95a 100644 --- a/src/app/components/subscription-add-form/subscription-add-form.html +++ b/src/app/components/feed-add-form/feed-add-form.html @@ -1,4 +1,4 @@ -Add new subscription +Add new feed Add new subscription Title @@ -33,7 +33,6 @@ Add new subscription Enabled - Load full text @if (errorMessage()) { diff --git a/src/app/components/subscription-add-form/subscription-add-form.ts b/src/app/components/feed-add-form/feed-add-form.ts similarity index 64% rename from src/app/components/subscription-add-form/subscription-add-form.ts rename to src/app/components/feed-add-form/feed-add-form.ts index 83f009d..4b09340 100644 --- a/src/app/components/subscription-add-form/subscription-add-form.ts +++ b/src/app/components/feed-add-form/feed-add-form.ts @@ -1,4 +1,4 @@ -import { Component, DestroyRef, inject, signal } from '@angular/core' +import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core' import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms' import { MatFormFieldModule } from '@angular/material/form-field' import { MatInput } from '@angular/material/input' @@ -11,7 +11,7 @@ import { HttpErrorResponse } from '@angular/common/http' import { takeUntilDestroyed } from '@angular/core/rxjs-interop' @Component({ - selector: 'app-subscription-add-form', + selector: 'app-feed-add-form', imports: [ ReactiveFormsModule, MatFormFieldModule, @@ -20,33 +20,33 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop' MatDialogModule, MatButton, ], - templateUrl: './subscription-add-form.html', - styleUrl: './subscription-add-form.css', + templateUrl: './feed-add-form.html', + styleUrl: './feed-add-form.css', + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SubscriptionAddForm { - fb = inject(NonNullableFormBuilder) - feedService = inject(FeedService) - destroyRef = inject(DestroyRef) - dialogRef = inject(MatDialogRef) +export class FeedAddForm { + private readonly fb = inject(NonNullableFormBuilder) + private readonly feedService = inject(FeedService) + private readonly destroyRef = inject(DestroyRef) + private readonly dialogRef = inject(MatDialogRef) - isLoading = signal(false) - errorMessage = signal(null) + readonly isLoading = signal(false) + readonly errorMessage = signal(null) form = this.fb.group({ - title: ['', Validators['required']], + title: ['', Validators.required], description: [''], - link: ['', Validators['required']], + link: ['', Validators.required], settings: this.fb.group({ enabled: [true], - loadFullText: [false], }), }) - onSubmit() { + onSubmit(): void { this.isLoading.set(true) this.form.disable() this.feedService - .addOneSubscription({ subscription: this.form.getRawValue() }) + .addOneFeed({ feed: this.form.getRawValue() }) .pipe( catchError((error: HttpErrorResponse) => { this.errorMessage.set(error.error.message) diff --git a/src/app/components/feed-edit-form/feed-edit-form.css b/src/app/components/feed-edit-form/feed-edit-form.css new file mode 100644 index 0000000..c98268a --- /dev/null +++ b/src/app/components/feed-edit-form/feed-edit-form.css @@ -0,0 +1,7 @@ +form { + display: flex; + flex-direction: column; + gap: 1ch; + min-width: 30vw; + overflow: hidden; +} diff --git a/src/app/components/feed-edit-form/feed-edit-form.html b/src/app/components/feed-edit-form/feed-edit-form.html new file mode 100644 index 0000000..8b7e95a --- /dev/null +++ b/src/app/components/feed-edit-form/feed-edit-form.html @@ -0,0 +1,55 @@ +Add new feed + + + Title + + + + Description + + + + Link + + + + Enabled + + + @if (errorMessage()) { + {{ errorMessage() }} + } + + + Cancel + + Save + + diff --git a/src/app/components/feed-edit-form/feed-edit-form.ts b/src/app/components/feed-edit-form/feed-edit-form.ts new file mode 100644 index 0000000..a2efe9a --- /dev/null +++ b/src/app/components/feed-edit-form/feed-edit-form.ts @@ -0,0 +1,91 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + OnInit, + signal, +} from '@angular/core' +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatInput } from '@angular/material/input' +import { MatSlideToggle } from '@angular/material/slide-toggle' +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MatButton } from '@angular/material/button' +import { FeedService } from '../../services/feed-service' +import { Feed } from '../../entities/feed/feed.types' +import { HttpErrorResponse } from '@angular/common/http' +import { catchError, of } from 'rxjs' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' + +@Component({ + selector: 'app-feed-edit-form', + imports: [ + ReactiveFormsModule, + MatFormFieldModule, + MatInput, + MatSlideToggle, + MatDialogModule, + MatButton, + ], + templateUrl: './feed-edit-form.html', + styleUrl: './feed-edit-form.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FeedEditForm implements OnInit { + private readonly fb = inject(NonNullableFormBuilder) + private readonly feedService = inject(FeedService) + private readonly destroyRef = inject(DestroyRef) + private readonly dialogRef = inject(MatDialogRef) + + readonly data: { feed: Feed; feedId: string } | null = inject(MAT_DIALOG_DATA) + readonly isLoading = signal(false) + readonly errorMessage = signal(null) + + form = this.fb.group({ + title: ['', Validators.required], + description: [''], + link: ['', Validators.required], + settings: this.fb.group({ + enabled: [false], + }), + }) + + onSubmit(): void { + if (!this.data?.feed._id) { + return + } + this.isLoading.set(true) + this.form.disable() + this.feedService + .changeOneFeed({ id: this.data?.feed._id, dto: this.form.getRawValue() }) + .pipe( + catchError((error: HttpErrorResponse) => { + this.errorMessage.set(error.error.message) + this.form.enable() + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((result) => { + this.isLoading.set(false) + if (result) { + this.dialogRef.close({ result }) + } + }) + } + + ngOnInit(): void { + if (this.data?.feed) { + const { feed } = this.data + this.form.patchValue({ + title: feed.title, + description: feed.description, + link: feed.link, + settings: { + enabled: feed.settings?.enabled || false, + }, + }) + } + } +} diff --git a/src/app/components/health-status/health-status.css b/src/app/components/health-status/health-status.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/health-status/health-status.html b/src/app/components/health-status/health-status.html new file mode 100644 index 0000000..50b2438 --- /dev/null +++ b/src/app/components/health-status/health-status.html @@ -0,0 +1,11 @@ + + + Backend status: {{ status() }} + + + Backend version: {{ version() }} + + + Backend uptime: {{ uptime() }} + + diff --git a/src/app/components/health-status/health-status.ts b/src/app/components/health-status/health-status.ts new file mode 100644 index 0000000..5fd9032 --- /dev/null +++ b/src/app/components/health-status/health-status.ts @@ -0,0 +1,39 @@ +import { Component, DestroyRef, inject, OnInit, signal } from '@angular/core' +import { HealthService } from '../../services/health-service' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { catchError, of } from 'rxjs' + +@Component({ + selector: 'app-health-status', + imports: [], + templateUrl: './health-status.html', + styleUrl: './health-status.css', +}) +export class HealthStatus implements OnInit { + private readonly healthService = inject(HealthService) + private readonly destroyRef = inject(DestroyRef) + + readonly status = signal('') + readonly version = signal('') + readonly uptime = signal('') + + ngOnInit() { + this.healthService + .getBackendStatus() + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + return of({ + status: 'error', + uptime: 'unknown', + version: 'unknown', + }) + }), + ) + .subscribe(({ status, uptime, version }) => { + this.status.set(status) + this.uptime.set(uptime) + this.version.set(version) + }) + } +} diff --git a/src/app/components/login-form/login-form.css b/src/app/components/login-form/login-form.css new file mode 100644 index 0000000..46ba7b7 --- /dev/null +++ b/src/app/components/login-form/login-form.css @@ -0,0 +1,10 @@ +.credentials { + display: flex; + flex-direction: column; + gap: 1rem; + margin: 1rem 0; +} + +.description { + max-width: 30ch; +} diff --git a/src/app/components/login-form/login-form.html b/src/app/components/login-form/login-form.html new file mode 100644 index 0000000..adccd26 --- /dev/null +++ b/src/app/components/login-form/login-form.html @@ -0,0 +1,51 @@ + + + + Login with your existing password and login. + + + Login + + + + Password + + + + @let status = (authStatus | async); + @if (status?.error) { + {{ status?.error }} + } + + @if (isLoading()) { + + } + + + Login + + + + diff --git a/src/app/components/login-form/login-form.ts b/src/app/components/login-form/login-form.ts new file mode 100644 index 0000000..5ad06d0 --- /dev/null +++ b/src/app/components/login-form/login-form.ts @@ -0,0 +1,74 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + model, + signal, +} from '@angular/core' +import { AsyncPipe } from '@angular/common' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { MatButton } from '@angular/material/button' +import { MatCardActions, MatCardContent } from '@angular/material/card' +import { MatError, MatFormField, MatInput, MatLabel } from '@angular/material/input' +import { AuthService } from '../../services/auth-service' +import { Router } from '@angular/router' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { MatProgressBar } from '@angular/material/progress-bar' + +@Component({ + selector: 'app-login-form', + imports: [ + AsyncPipe, + FormsModule, + MatButton, + MatCardActions, + MatCardContent, + MatInput, + MatLabel, + ReactiveFormsModule, + MatFormField, + MatError, + MatProgressBar, + ], + templateUrl: './login-form.html', + styleUrl: './login-form.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LoginForm { + private readonly authService = inject(AuthService) + private readonly router = inject(Router) + private readonly destroyRef = inject(DestroyRef) + + readonly isLoading = signal(false) + + readonly formData = model({ + login: '', + password: '', + }) + + readonly authStatus = this.authService.$authStatus + + inputHandler(field: 'login' | 'password', event: Event): void { + const { value } = event.target as HTMLInputElement + if (value) { + this.formData.update((prev) => ({ + ...prev, + [field]: value, + })) + } + } + + onSubmit(): void { + this.isLoading.set(true) + this.authService + .login(this.formData()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => { + if (result) { + this.isLoading.set(false) + this.router.navigate(['/articles']) + } + }) + } +} diff --git a/src/app/components/nav/nav.component.css b/src/app/components/nav/nav.component.css index e9e6778..938eadb 100644 --- a/src/app/components/nav/nav.component.css +++ b/src/app/components/nav/nav.component.css @@ -1,7 +1,3 @@ -.sidenav-container { - height: calc(100vh - 2rem); -} - .sidenav { width: max-content; padding: 0 2rem; @@ -18,17 +14,39 @@ } .label h1 { + width: 50vw; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.mat-toolbar.mat-primary { - position: sticky; - top: 0; - z-index: 1; -} - mat-nav-list a { margin: 0.5rem 0 } + +mat-sidenav-content { + display: grid; + grid-template-rows: auto 1fr auto; + height: calc(100cqh - 2rem); + scroll-behavior: smooth; +} + +.page-content { + overflow: auto; +} + +.title { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.title h2 { + font-size: smaller; +} + +.highlighted { + background: var(--mat-sys-secondary-container); +} diff --git a/src/app/components/nav/nav.component.html b/src/app/components/nav/nav.component.html index 4ee5323..f7019c4 100644 --- a/src/app/components/nav/nav.component.html +++ b/src/app/components/nav/nav.component.html @@ -8,20 +8,29 @@ [opened]="(isHandset$ | async) === false" > Menu + + @for (i of menuItems; track i.title) { + + {{ i.icon }} + {{ i.title }} + + } + Articles - Subscriptions - Tags + (click)="onLogOut()" + > + logout + Logout + + @@ -35,16 +44,17 @@ menu } - @if (!topLevelRoute()) { - - arrow_back - - } - {{ currentTitle() }} + + {{ currentTitle() }} + @if (isHandset() && currentSubtitle()) { + filter_alt + } @else { + {{ currentSubtitle() }} + } + - + + + diff --git a/src/app/components/nav/nav.component.ts b/src/app/components/nav/nav.component.ts index 68ff5f7..6eafcbd 100644 --- a/src/app/components/nav/nav.component.ts +++ b/src/app/components/nav/nav.component.ts @@ -1,16 +1,25 @@ -import { Component, inject, OnInit, signal } from '@angular/core' +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + OnInit, + signal, + viewChild, +} from '@angular/core' import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout' -import { AsyncPipe, Location } from '@angular/common' +import { AsyncPipe } from '@angular/common' import { MatToolbarModule } from '@angular/material/toolbar' import { MatButtonModule } from '@angular/material/button' -import { MatSidenavModule } from '@angular/material/sidenav' +import { MatSidenav, MatSidenavModule } from '@angular/material/sidenav' import { MatListModule } from '@angular/material/list' import { MatIconModule } from '@angular/material/icon' -import { Observable } from 'rxjs' +import { Observable, tap } from 'rxjs' import { map, shareReplay } from 'rxjs/operators' -import { EventType, Router, RouterLink } from '@angular/router' +import { EventType, Router, RouterLink, RouterLinkActive } from '@angular/router' import { TitleService } from '../../services/title-service' -import { toSignal } from '@angular/core/rxjs-interop' +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop' +import { AuthService } from '../../services/auth-service' @Component({ selector: 'app-nav', @@ -24,34 +33,60 @@ import { toSignal } from '@angular/core/rxjs-interop' MatIconModule, AsyncPipe, RouterLink, + RouterLinkActive, ], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class NavComponent implements OnInit { - private breakpointObserver = inject(BreakpointObserver) - private titleService = inject(TitleService) + private readonly breakpointObserver = inject(BreakpointObserver) + private readonly titleService = inject(TitleService) + private readonly authService = inject(AuthService) + private readonly destroyRef = inject(DestroyRef) + private readonly sideNav = viewChild('drawer') + private readonly router = inject(Router) - currentTitle = toSignal(this.titleService.$currentTitle) + readonly currentTitle = toSignal(this.titleService.$currentTitle) + readonly currentSubtitle = toSignal(this.titleService.$currentSubtitle) + + readonly isHandset = signal(false) isHandset$: Observable = this.breakpointObserver.observe(Breakpoints.Handset).pipe( + tap((result) => this.isHandset.set(result.matches)), map((result) => result.matches), shareReplay(), ) - private router = inject(Router) - private location = inject(Location) + menuItems: { title: string; icon?: string; url: string }[] = [ + { title: 'Articles', url: '/articles', icon: 'library_books' }, + { title: 'Bookmarks', url: '/bookmarks', icon: 'bookmark' }, + { title: 'Feeds', url: '/feeds', icon: 'rss_feed' }, + { title: 'Tags', url: '/tags', icon: 'tag' }, + { title: 'User', url: '/user', icon: 'person' }, + { title: 'Status', url: '/status', icon: 'memory' }, + ] - topLevelRoute = signal(true) + onMenuItemClick() { + if (this.isHandset()) { + this.sideNav()?.close() + } + } - navigateBack() { - this.location.back() + onLogOut() { + this.authService + .logout() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => { + if (result) { + this.router.navigate(['/auth']) + } + }) } - ngOnInit() { - this.router.events.subscribe((event) => { + ngOnInit(): void { + this.router.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { if (event.type === EventType.ActivationStart) { const title: string = event.snapshot.data?.['title'] this.titleService.setTitle(title) - this.topLevelRoute.set(event.snapshot.url.length === 1) } }) } diff --git a/src/app/components/page-display-toggle/page-display-toggle.css b/src/app/components/page-display-toggle/page-display-toggle.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/page-display-toggle/page-display-toggle.html b/src/app/components/page-display-toggle/page-display-toggle.html new file mode 100644 index 0000000..9626151 --- /dev/null +++ b/src/app/components/page-display-toggle/page-display-toggle.html @@ -0,0 +1,16 @@ + + + view_day + + + view_agenda + + diff --git a/src/app/components/page-display-toggle/page-display-toggle.ts b/src/app/components/page-display-toggle/page-display-toggle.ts new file mode 100644 index 0000000..6746ea2 --- /dev/null +++ b/src/app/components/page-display-toggle/page-display-toggle.ts @@ -0,0 +1,23 @@ +import { Component, inject } from '@angular/core' +import { PageService } from '../../services/page-service' +import { MatButtonToggle } from '@angular/material/button-toggle' +import { MatIcon } from '@angular/material/icon' +import { PageDisplay } from '../../entities/page/page.enums' +import { AsyncPipe } from '@angular/common' +import { MatIconButton } from '@angular/material/button' + +@Component({ + selector: 'app-page-display-toggle', + imports: [MatButtonToggle, MatIcon, MatButtonToggle, MatIcon, AsyncPipe, MatIconButton], + templateUrl: './page-display-toggle.html', + styleUrl: './page-display-toggle.css', +}) +export class PageDisplayToggle { + private readonly pageService = inject(PageService, { skipSelf: true }) + readonly display = this.pageService.$display + protected readonly PageDisplay = PageDisplay + + toggleDisplay(display: PageDisplay) { + this.pageService.setDisplay(display) + } +} diff --git a/src/app/components/page-title/page-title.css b/src/app/components/page-title/page-title.css deleted file mode 100644 index e67d4c6..0000000 --- a/src/app/components/page-title/page-title.css +++ /dev/null @@ -1,9 +0,0 @@ -:host { - --background: var(--mat-sys-primary-container); - --border: 0; -} -mat-toolbar { - background: var(--background); - border-radius: var(--border); - transition: border-radius 0.5s ease-in-out; -} diff --git a/src/app/components/page-title/page-title.html b/src/app/components/page-title/page-title.html deleted file mode 100644 index f657a5f..0000000 --- a/src/app/components/page-title/page-title.html +++ /dev/null @@ -1,3 +0,0 @@ - - {{ title() }} - diff --git a/src/app/components/page-title/page-title.ts b/src/app/components/page-title/page-title.ts deleted file mode 100644 index 37a949d..0000000 --- a/src/app/components/page-title/page-title.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Component, computed, HostBinding, HostListener, input } from '@angular/core' -import { MatToolbar } from '@angular/material/toolbar' - -@Component({ - selector: 'app-page-title', - imports: [MatToolbar], - templateUrl: './page-title.html', - styleUrl: './page-title.css', -}) -export class PageTitle { - title = input() - randomColor = computed(() => { - return `#${Math.floor(Math.random() * 16777215) - .toString(16) - .padStart(6, '0')}` - }) - - @HostBinding('style.--background') - value: string = this.randomColor() - - @HostListener('mouseenter', ['$event']) - onMouseEnter(event: MouseEvent) { - const element = event.target as HTMLElement - if (element) { - element.style.setProperty('--border', '50cqh') - } - } - - @HostListener('mouseleave', ['$event']) - onMouseLeave(event: MouseEvent) { - const element = event.target as HTMLElement - if (element) { - element.style.setProperty('--border', '0') - } - } -} diff --git a/src/app/components/paginator/paginator.css b/src/app/components/paginator/paginator.css new file mode 100644 index 0000000..ae0579d --- /dev/null +++ b/src/app/components/paginator/paginator.css @@ -0,0 +1,7 @@ +#paginator { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + padding-block: 1rem; +} diff --git a/src/app/components/paginator/paginator.html b/src/app/components/paginator/paginator.html new file mode 100644 index 0000000..1b5a156 --- /dev/null +++ b/src/app/components/paginator/paginator.html @@ -0,0 +1,11 @@ +@if (!hidden()) { + + + +} diff --git a/src/app/components/paginator/paginator.ts b/src/app/components/paginator/paginator.ts new file mode 100644 index 0000000..d1e1400 --- /dev/null +++ b/src/app/components/paginator/paginator.ts @@ -0,0 +1,27 @@ +import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core' +import { MatToolbarRow } from '@angular/material/toolbar' +import { MatPaginator, PageEvent } from '@angular/material/paginator' +import { PageService } from '../../services/page-service' +import { AsyncPipe } from '@angular/common' + +@Component({ + selector: 'app-paginator', + imports: [MatPaginator, MatToolbarRow, AsyncPipe], + templateUrl: './paginator.html', + styleUrl: './paginator.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Paginator { + private readonly pageService = inject(PageService, { skipSelf: true }) + public readonly totalResults = this.pageService.$totalResults + public readonly pageSize = this.pageService.$pageSize + + readonly pageChange = output() + readonly hidden = input(false) + + onPageChange(event: PageEvent): void { + this.pageChange.emit(event) + this.pageService.setCurrentPage(event.pageIndex + 1) + this.pageService.setPageSize(event.pageSize) + } +} diff --git a/src/app/components/row-spacer/row-spacer.ts b/src/app/components/row-spacer/row-spacer.ts index b3ab66f..29fd60c 100644 --- a/src/app/components/row-spacer/row-spacer.ts +++ b/src/app/components/row-spacer/row-spacer.ts @@ -1,11 +1,9 @@ -import { Component } from '@angular/core'; +import { Component } from '@angular/core' @Component({ selector: 'app-row-spacer', imports: [], templateUrl: './row-spacer.html', - styleUrl: './row-spacer.css' + styleUrl: './row-spacer.css', }) -export class RowSpacer { - -} +export class RowSpacer {} diff --git a/src/app/components/signup-form/signup-form.css b/src/app/components/signup-form/signup-form.css new file mode 100644 index 0000000..bbc35f0 --- /dev/null +++ b/src/app/components/signup-form/signup-form.css @@ -0,0 +1,11 @@ +.credentials { + display: flex; + flex-direction: column; + gap: 1rem; + margin: 1rem 0; +} + +.description, +mat-error { + max-width: 30ch; +} diff --git a/src/app/components/signup-form/signup-form.html b/src/app/components/signup-form/signup-form.html new file mode 100644 index 0000000..780c19c --- /dev/null +++ b/src/app/components/signup-form/signup-form.html @@ -0,0 +1,52 @@ + + + + Signup with your password, but random username provided by the server. + + + + Password + + + + @let status = (authStatus | async); + @if (status?.error) { + {{ status?.error }} + } + + @if (password.invalid && (password.touched || password.dirty)) { + @if (password.hasError('required')) { + Password is required + } + + @if (password.hasError('minlength')) { + Password must be at least 8 characters long + } + + @if (password.hasError('maxlength')) { + Password must be at most 30 characters long + } + } + + @if (isLoading()) { + + } + + + + Signup + + + + + diff --git a/src/app/components/signup-form/signup-form.ts b/src/app/components/signup-form/signup-form.ts new file mode 100644 index 0000000..b2c2154 --- /dev/null +++ b/src/app/components/signup-form/signup-form.ts @@ -0,0 +1,62 @@ +import { Component, DestroyRef, inject, signal } from '@angular/core' +import { AsyncPipe } from '@angular/common' +import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' +import { MatButton } from '@angular/material/button' +import { MatCardActions, MatCardContent } from '@angular/material/card' +import { MatError, MatFormField, MatInput, MatLabel } from '@angular/material/input' +import { AuthService } from '../../services/auth-service' +import { Router } from '@angular/router' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' +import { MatIconModule } from '@angular/material/icon' +import { MatProgressBarModule } from '@angular/material/progress-bar' + +@Component({ + selector: 'app-signup-form', + imports: [ + AsyncPipe, + FormsModule, + MatButton, + MatCardActions, + MatCardContent, + MatInput, + MatLabel, + ReactiveFormsModule, + MatFormField, + MatError, + MatProgressSpinnerModule, + MatIconModule, + MatProgressBarModule, + ], + templateUrl: './signup-form.html', + styleUrl: './signup-form.css', +}) +export class SignupForm { + private readonly authService = inject(AuthService) + private readonly router = inject(Router) + private readonly destroyRef = inject(DestroyRef) + + readonly isLoading = signal(false) + + password = new FormControl('', { + validators: [Validators.required, Validators.minLength(8), Validators.maxLength(30)], + }) + + authStatus = this.authService.$authStatus + + onSubmit() { + if (!this.password.value) { + return + } + this.isLoading.set(true) + this.authService + .signup({ password: this.password.value as string }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => { + if (result) { + this.isLoading.set(false) + this.router.navigate(['/user']) + } + }) + } +} diff --git a/src/app/components/tag-add-form/tag-add-form.html b/src/app/components/tag-add-form/tag-add-form.html deleted file mode 100644 index 45d180e..0000000 --- a/src/app/components/tag-add-form/tag-add-form.html +++ /dev/null @@ -1,34 +0,0 @@ -Add new tag - - - Tag name - - - - @if (errorMessage()) { - {{ errorMessage() }} - } - - - Cancel - - Save - - - diff --git a/src/app/components/tag-add-form/tag-add-form.ts b/src/app/components/tag-add-form/tag-add-form.ts deleted file mode 100644 index f7cf14c..0000000 --- a/src/app/components/tag-add-form/tag-add-form.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Component, DestroyRef, inject, signal } from '@angular/core' -import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms' -import { TagService } from '../../services/tag-service' -import { - MatDialogActions, MatDialogClose, - MatDialogContent, - MatDialogRef, - MatDialogTitle, -} from '@angular/material/dialog' -import { catchError, of } from 'rxjs' -import { HttpErrorResponse } from '@angular/common/http' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { MatFormFieldModule } from '@angular/material/form-field' -import { MatInput } from '@angular/material/input' -import { MatButton } from '@angular/material/button' - -@Component({ - selector: 'app-tag-add-form', - imports: [ - MatDialogTitle, - MatDialogContent, - ReactiveFormsModule, - MatFormFieldModule, - MatInput, - MatDialogActions, - MatButton, - MatDialogClose, - ], - templateUrl: './tag-add-form.html', - styleUrl: './tag-add-form.css', -}) -export class TagAddForm { - fb = inject(NonNullableFormBuilder) - tagService = inject(TagService) - destroyRef = inject(DestroyRef) - dialogRef = inject(MatDialogRef) - - isLoading = signal(false) - errorMessage = signal(null) - - form = this.fb.group({ - name: ['', Validators['required']], - }) - - onSubmit() { - this.form.disable() - this.isLoading.set(true) - this.tagService - .addOneTag({ name: this.form.getRawValue().name }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.error(error) - this.errorMessage.set(error.error.message) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - this.isLoading.set(false) - if (result) { - this.dialogRef.close({ result }) - } - }) - } -} diff --git a/src/app/entities/article/article.types.ts b/src/app/entities/article/article.types.ts index cfa7a73..6859d84 100644 --- a/src/app/entities/article/article.types.ts +++ b/src/app/entities/article/article.types.ts @@ -11,5 +11,5 @@ export interface Article extends RssItem { read: boolean readonly fullText?: string readonly userId: string - readonly subscriptionId: string + readonly feedId: string } diff --git a/src/app/entities/base/base.enums.ts b/src/app/entities/base/base.enums.ts index e69de29..cc9ed4e 100644 --- a/src/app/entities/base/base.enums.ts +++ b/src/app/entities/base/base.enums.ts @@ -0,0 +1,4 @@ +export enum SortOrder { + Asc = 'asc', + Desc = 'desc', +} diff --git a/src/app/entities/base/base.types.ts b/src/app/entities/base/base.types.ts index d14de85..d1379c5 100644 --- a/src/app/entities/base/base.types.ts +++ b/src/app/entities/base/base.types.ts @@ -1,7 +1,7 @@ export type Pagination = { - pageNumber: number; - perPage: number; -}; + pageNumber: number + perPage: number +} export type Paginated = { total: number diff --git a/src/app/entities/feed/feed.types.ts b/src/app/entities/feed/feed.types.ts index 6336048..3d86fe6 100644 --- a/src/app/entities/feed/feed.types.ts +++ b/src/app/entities/feed/feed.types.ts @@ -1,5 +1,5 @@ export interface FeedSettings { - enable?: boolean + enabled?: boolean loadFullText?: boolean } diff --git a/src/app/entities/page/page.enums.ts b/src/app/entities/page/page.enums.ts new file mode 100644 index 0000000..d631787 --- /dev/null +++ b/src/app/entities/page/page.enums.ts @@ -0,0 +1,4 @@ +export enum PageDisplay { + Title = 'title', + Short = 'short', +} diff --git a/src/app/entities/user/user.enums.ts b/src/app/entities/user/user.enums.ts new file mode 100644 index 0000000..285b9c5 --- /dev/null +++ b/src/app/entities/user/user.enums.ts @@ -0,0 +1,4 @@ +export enum Role { + User = 'user', + Admin = 'admin', +} diff --git a/src/app/entities/user/user.types.ts b/src/app/entities/user/user.types.ts index 054a653..d2e73f2 100644 --- a/src/app/entities/user/user.types.ts +++ b/src/app/entities/user/user.types.ts @@ -1,3 +1,5 @@ +import { Role } from './user.enums' + export interface UserDTO { login: string password: string @@ -5,7 +7,8 @@ export interface UserDTO { export interface User extends UserDTO { _id: string - role: string + role: Role createdAt: Date modifiedAt: Date + lastLogin?: Date } diff --git a/src/app/guards/auth-guard.ts b/src/app/guards/auth-guard.ts index 7c2f34b..342a65c 100644 --- a/src/app/guards/auth-guard.ts +++ b/src/app/guards/auth-guard.ts @@ -1,11 +1,37 @@ import { CanMatchFn, Router } from '@angular/router' -import { inject } from '@angular/core' +import { DestroyRef, inject } from '@angular/core' import { AuthService } from '../services/auth-service' import { map } from 'rxjs/operators' +import { UserService } from '../services/user-service' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { catchError, of } from 'rxjs' +import { HttpErrorResponse } from '@angular/common/http' export const authGuard: CanMatchFn = (route, segments) => { + const savedUserId = localStorage.getItem('user') + const router = inject(Router) const authService = inject(AuthService) + const userService = inject(UserService) + const destroyRef = inject(DestroyRef) + + if (savedUserId) { + return userService.getUser().pipe( + takeUntilDestroyed(destroyRef), + catchError((error: HttpErrorResponse) => { + return of(null) + }), + map((user) => { + if (user) { + authService.updateAuth(user) + return true + } else { + return router.createUrlTree(['auth']) + } + }), + ) + } + return authService.$authStatus.pipe( map((status) => { return status.authenticated ? true : router.createUrlTree(['auth']) diff --git a/src/app/outlet/private-outlet/private-outlet.css b/src/app/outlet/private-outlet/private-outlet.css new file mode 100644 index 0000000..84a9cbc --- /dev/null +++ b/src/app/outlet/private-outlet/private-outlet.css @@ -0,0 +1,4 @@ +.private { + padding: 1rem; + container-type: inline-size; +} diff --git a/src/app/outlet/private-outlet/private-outlet.html b/src/app/outlet/private-outlet/private-outlet.html new file mode 100644 index 0000000..f649745 --- /dev/null +++ b/src/app/outlet/private-outlet/private-outlet.html @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/outlet/private-outlet/private-outlet.ts b/src/app/outlet/private-outlet/private-outlet.ts new file mode 100644 index 0000000..7c48e3f --- /dev/null +++ b/src/app/outlet/private-outlet/private-outlet.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core' +import { NavComponent } from '../../components/nav/nav.component' +import { RouterOutlet } from '@angular/router' + +@Component({ + selector: 'app-private-outlet', + imports: [NavComponent, RouterOutlet], + templateUrl: './private-outlet.html', + styleUrl: './private-outlet.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PrivateOutlet {} diff --git a/src/app/outlet/public-outlet/public-outlet.css b/src/app/outlet/public-outlet/public-outlet.css new file mode 100644 index 0000000..971f48a --- /dev/null +++ b/src/app/outlet/public-outlet/public-outlet.css @@ -0,0 +1,6 @@ +.public { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; +} diff --git a/src/app/outlet/public-outlet/public-outlet.html b/src/app/outlet/public-outlet/public-outlet.html new file mode 100644 index 0000000..807e82b --- /dev/null +++ b/src/app/outlet/public-outlet/public-outlet.html @@ -0,0 +1,3 @@ + + + diff --git a/src/app/outlet/public-outlet/public-outlet.ts b/src/app/outlet/public-outlet/public-outlet.ts new file mode 100644 index 0000000..30dd0b4 --- /dev/null +++ b/src/app/outlet/public-outlet/public-outlet.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core' +import { RouterOutlet } from '@angular/router' + +@Component({ + selector: 'app-public', + imports: [RouterOutlet], + templateUrl: './public-outlet.html', + styleUrl: './public-outlet.css', +}) +export class PublicOutlet {} diff --git a/src/app/pages/article-page/article-page.component.css b/src/app/pages/article-page/article-page.component.css deleted file mode 100644 index 9bbddbe..0000000 --- a/src/app/pages/article-page/article-page.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.highlighted { - color: var(--mat-sys-primary); -} diff --git a/src/app/pages/article-page/article-page.component.html b/src/app/pages/article-page/article-page.component.html deleted file mode 100644 index 6ad8765..0000000 --- a/src/app/pages/article-page/article-page.component.html +++ /dev/null @@ -1,44 +0,0 @@ -@defer { - @if (article()) { - - - Published: {{ article()?.isoDate | date:'short' }} - - - {{ readStatus() ? 'mark_email_read' : 'mark_email_unread' }} - - - bookmark - - - link - - - - {{ article()?.title }} - - - - @if (article()?.categories?.length) { - Categories - - @for (c of article()?.categories; track c) { - {{ c }} - } - - } - } -} @loading (minimum 0.5s) { - -} diff --git a/src/app/pages/article-page/article-page.css b/src/app/pages/article-page/article-page.css new file mode 100644 index 0000000..aef7674 --- /dev/null +++ b/src/app/pages/article-page/article-page.css @@ -0,0 +1,10 @@ +:host { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem 0.5rem; +} + +.highlighted { + color: var(--mat-sys-primary); +} diff --git a/src/app/pages/article-page/article-page.html b/src/app/pages/article-page/article-page.html new file mode 100644 index 0000000..e562355 --- /dev/null +++ b/src/app/pages/article-page/article-page.html @@ -0,0 +1,70 @@ + + @defer { + @if (article()) { + + + + + {{ readStatus() ? 'mark_email_read' : 'mark_email_unread' }} + + + bookmark + + + link + + + arrow_circle_down + + + + {{ article()?.title }} + + @if (isLoading()) { + + } + + @if (fullText()) { + + + } @else { + + + } + + + @if (article()?.categories?.length) { + Categories + + @for (c of article()?.categories; track c) { + {{ c }} + } + + } + } + } @loading (minimum 0.5s) { + + } + diff --git a/src/app/pages/article-page/article-page.component.ts b/src/app/pages/article-page/article-page.ts similarity index 66% rename from src/app/pages/article-page/article-page.component.ts rename to src/app/pages/article-page/article-page.ts index 61bc8d2..77f4b71 100644 --- a/src/app/pages/article-page/article-page.component.ts +++ b/src/app/pages/article-page/article-page.ts @@ -1,4 +1,12 @@ -import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core' +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + inject, + OnInit, + signal, +} from '@angular/core' import { FeedService } from '../../services/feed-service' import { ActivatedRoute } from '@angular/router' import { takeUntilDestroyed } from '@angular/core/rxjs-interop' @@ -11,14 +19,13 @@ import { MatChip, MatChipSet } from '@angular/material/chips' import { MatIconModule } from '@angular/material/icon' import { MatIconButton } from '@angular/material/button' import { MatDivider } from '@angular/material/divider' -import { DatePipe } from '@angular/common' import { HttpErrorResponse } from '@angular/common/http' import { TagService } from '../../services/tag-service' import { TitleService } from '../../services/title-service' import { DomSanitizer, SafeHtml } from '@angular/platform-browser' @Component({ - selector: 'app-article', + selector: 'app-article-page', imports: [ MatToolbarModule, MatProgressBarModule, @@ -28,26 +35,26 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser' MatIconModule, MatIconButton, MatDivider, - DatePipe, ], - templateUrl: './article-page.component.html', - styleUrl: './article-page.component.css', + templateUrl: './article-page.html', + styleUrl: './article-page.css', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class ArticlePage implements OnInit { - feedService = inject(FeedService) - tagService = inject(TagService) - route = inject(ActivatedRoute) - titleService = inject(TitleService) - destroyRef = inject(DestroyRef) - domSanitizer = inject(DomSanitizer) + private readonly feedService = inject(FeedService) + private readonly tagService = inject(TagService) + private readonly route = inject(ActivatedRoute) + private readonly titleService = inject(TitleService) + private readonly destroyRef = inject(DestroyRef) + private readonly domSanitizer = inject(DomSanitizer) - article = signal(null) - - favTagId = signal('') - - readStatus = computed(() => { + readonly article = signal(null) + readonly fullText = signal(undefined) + readonly favTagId = signal('') + readonly readStatus = computed(() => { return !!this.article()?.read }) + readonly isLoading = signal(false) safeHtml(html?: string): SafeHtml | undefined { if (!html) { @@ -56,7 +63,7 @@ export class ArticlePage implements OnInit { return this.domSanitizer.bypassSecurityTrustHtml(html) } - ngOnInit() { + ngOnInit(): void { this.route.params .pipe( exhaustMap((params) => { @@ -78,21 +85,26 @@ export class ArticlePage implements OnInit { }), takeUntilDestroyed(this.destroyRef), catchError((error: HttpErrorResponse) => { - console.log(error) + console.error(error) return of(null) }), ) .subscribe((result) => { this.article.set(result) + if (result?.fullText) { + const parsed = this.safeHtml(result.fullText || '') + this.fullText.set(parsed) + } this.titleService.setTitle(result?.title || '') + this.titleService.setSubtitle(null) }) - this.tagService.$defaultTags.subscribe((tags) => { + this.tagService.$defaultTags.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((tags) => { this.favTagId.set(tags.find((t) => t.name === 'fav')?._id || '') }) } - onMarkAsRead() { + onMarkAsRead(): void { if (this.article() !== null) { this.feedService .changeOneArticle({ @@ -104,7 +116,7 @@ export class ArticlePage implements OnInit { .pipe( takeUntilDestroyed(this.destroyRef), catchError((error: HttpErrorResponse) => { - console.log(error) + console.error(error) return of(null) }), ) @@ -119,7 +131,7 @@ export class ArticlePage implements OnInit { } } - onAddToBookmarks() { + onAddToBookmarks(): void { const existingTag = this.article()?.tags.find((t) => t === this.favTagId()) const tags = existingTag ? [...(this.article()?.tags || [])].filter((t) => t !== this.favTagId()) @@ -134,7 +146,7 @@ export class ArticlePage implements OnInit { .pipe( takeUntilDestroyed(this.destroyRef), catchError((error: HttpErrorResponse) => { - console.log(error) + console.error(error) return of(null) }), ) @@ -148,4 +160,27 @@ export class ArticlePage implements OnInit { }) } } + + getFullText(): void { + const articleId = this.article()?._id + if (!articleId) { + return + } + this.isLoading.set(true) + this.feedService + .getFullText({ articleId }) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + console.error(e) + this.isLoading.set(false) + return of(null) + }), + ) + .subscribe((result) => { + const parsed = this.safeHtml(result?.fullText || '') + this.fullText.set(parsed) + this.isLoading.set(false) + }) + } } diff --git a/src/app/pages/home/home-page.component.css b/src/app/pages/articles-page/articles-page.css similarity index 53% rename from src/app/pages/home/home-page.component.css rename to src/app/pages/articles-page/articles-page.css index bc1230e..ae579e5 100644 --- a/src/app/pages/home/home-page.component.css +++ b/src/app/pages/articles-page/articles-page.css @@ -2,7 +2,8 @@ display: flex; flex-direction: column; gap: 1rem; - padding: 0.5rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); } #view-toggle { @@ -11,23 +12,9 @@ justify-content: space-between; } -#paginator, #actions { display: flex; align-items: center; justify-content: center; gap: 1rem; } - -.card { - cursor: pointer; -} - -.card:hover { - box-shadow: 0 0 0.2rem var(--mat-sys-primary); - transition: box-shadow 0.3s ease-in-out; -} - -.highlighted { - color: var(--mat-sys-primary); -} diff --git a/src/app/pages/articles-page/articles-page.html b/src/app/pages/articles-page/articles-page.html new file mode 100644 index 0000000..c681b17 --- /dev/null +++ b/src/app/pages/articles-page/articles-page.html @@ -0,0 +1,73 @@ + + + + + refresh + + + + + + schedule + + + + {{ ($readFilter | async) ? 'mark_email_unread' : 'mark_email_read' }} + + + + bookmark + + + + + + + + +@if (articles().length) { + + + + check + Skip visible + + + done_all + Skip all + + +} diff --git a/src/app/pages/articles-page/articles-page.ts b/src/app/pages/articles-page/articles-page.ts new file mode 100644 index 0000000..1b8e97a --- /dev/null +++ b/src/app/pages/articles-page/articles-page.ts @@ -0,0 +1,252 @@ +import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core' +import { MatCardModule } from '@angular/material/card' +import { FeedService } from '../../services/feed-service' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { HttpErrorResponse } from '@angular/common/http' +import { BehaviorSubject, catchError, combineLatest, forkJoin, of, switchMap } from 'rxjs' +import { MatButton, MatIconButton } from '@angular/material/button' +import { MatToolbarModule } from '@angular/material/toolbar' +import { MatButtonToggleModule } from '@angular/material/button-toggle' +import { MatIconModule } from '@angular/material/icon' +import { RowSpacer } from '../../components/row-spacer/row-spacer' +import { ActivatedRoute } from '@angular/router' +import { Article } from '../../entities/article/article.types' +import { MatPaginatorModule } from '@angular/material/paginator' +import { TagService } from '../../services/tag-service' +import { Tag } from '../../entities/tag/tag.types' +import { TitleService } from '../../services/title-service' +import { ArticleList } from '../../components/article-list/article-list' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { PageDisplayToggle } from '../../components/page-display-toggle/page-display-toggle' +import { AsyncPipe } from '@angular/common' +import { SortOrder } from '../../entities/base/base.enums' + +@Component({ + selector: 'app-articles-page', + imports: [ + MatCardModule, + MatToolbarModule, + MatButtonToggleModule, + MatIconModule, + RowSpacer, + MatIconButton, + MatPaginatorModule, + MatButton, + ArticleList, + Paginator, + PageDisplayToggle, + AsyncPipe, + ], + templateUrl: './articles-page.html', + styleUrl: './articles-page.css', +}) +export class ArticlesPage implements OnInit { + private readonly feedService = inject(FeedService) + private readonly route = inject(ActivatedRoute) + private readonly destroyRef = inject(DestroyRef) + private readonly tagService = inject(TagService) + private readonly titleService = inject(TitleService) + private readonly pageService = inject(PageService) + + readonly articles = signal([]) + readonly articleIds = computed(() => this.articles().map(({ _id }) => _id)) + readonly favTagId = signal('') + readonly userTags = signal([]) + readonly isRefreshingAll = signal(false) + + $readFilter = new BehaviorSubject(true) + $favFilter = new BehaviorSubject(false) + $feedFilter = new BehaviorSubject(null) + $tagFilter = new BehaviorSubject(null) + $dateOrder = new BehaviorSubject(SortOrder.Desc) + + ngOnInit() { + this.tagService + .getAllTags({}) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + ) + .subscribe((tags) => { + if (tags?.result) { + this.userTags.set(tags.result.filter((t) => t.userId !== 'all')) + } + }) + + this.tagService + .getDefaultTags() + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((tags) => { + const favTag = tags?.find((t) => t.name === 'fav')?._id + if (!favTag) { + return of(null) + } + this.favTagId.set(favTag) + + return combineLatest([ + this.pageService.$pageSize, + this.pageService.$currentPage, + this.$favFilter, + this.$readFilter, + this.$feedFilter, + this.$tagFilter, + this.$dateOrder, + ]).pipe( + switchMap(([perPage, pageNumber, fav, read, feed, tag, dateSort]) => { + const filters: Record = {} + + if (read) { + filters['read'] = false + } + + if (fav) { + filters['tags'] = favTag + } + + if (feed) { + filters['feed'] = feed + } + + if (tag) { + filters['tags'] = tag + } + + return this.feedService.getAllArticles({ + pagination: { + perPage, + pageNumber, + }, + filters, + sort: { + date: dateSort, + }, + }) + }), + ) + }), + ) + .subscribe((result) => { + if (result) { + this.articles.set(result.result) + this.pageService.setTotalResults(result.total) + this.titleService.setTitle(`Articles: ${result.total} articles`) + } else { + this.titleService.setTitle('Articles') + } + }) + + this.route.queryParams + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((params) => { + const feedId: string = params['feed'] + const tagName: string = params['tag'] + if (tagName) { + return forkJoin([of(null), this.tagService.getOne({ name: tagName })]) + } + + if (feedId) { + return forkJoin([this.feedService.getOneFeed({ feedId }), of(null)]) + } + + return forkJoin([of(null), of(null)]) + }), + catchError((e) => { + console.error(e) + return of(null) + }), + ) + .subscribe((results) => { + if (!results) { + return + } + + const [feed, tag] = results + + if (feed) { + this.titleService.setSubtitle(feed.title) + this.$feedFilter.next(feed._id) + this.$tagFilter.next(null) + } else if (tag) { + this.titleService.setSubtitle(tag.name) + this.$feedFilter.next(null) + this.$tagFilter.next(tag._id) + } else { + this.titleService.setSubtitle(null) + this.$feedFilter.next(null) + this.$tagFilter.next(null) + } + }) + } + + markManyAsRead({ + articleIds, + read, + all, + }: { + articleIds?: string[] + read: boolean + all?: boolean + }) { + this.feedService + .changeManyArticles({ + ids: articleIds, + article: { read }, + all, + }) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + ) + .subscribe((result) => { + if (!result?.modifiedCount) { + return + } + this.pageService.setCurrentPage(1) + }) + } + + filterHandler(filter: 'read' | 'fav') { + if (filter === 'read') { + this.$readFilter.next(!this.$readFilter.value) + } else { + this.$favFilter.next(!this.$favFilter.value) + } + this.pageService.setCurrentPage(1) + } + + orderHandler(param: 'date') { + if (param === 'date') { + this.$dateOrder.next(this.$dateOrder.value === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc) + this.pageService.setCurrentPage(1) + } + } + + onRefreshAll() { + this.isRefreshingAll.set(true) + this.feedService + .refreshAllFeeds() + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + this.isRefreshingAll.set(false) + console.error(e) + return of(null) + }), + ) + .subscribe(() => { + this.isRefreshingAll.set(false) + this.pageService.setCurrentPage(1) + }) + } + + protected readonly SortOrder = SortOrder +} diff --git a/src/app/pages/auth-page/auth-page.component.css b/src/app/pages/auth-page/auth-page.component.css index 6de3848..a37ef5f 100644 --- a/src/app/pages/auth-page/auth-page.component.css +++ b/src/app/pages/auth-page/auth-page.component.css @@ -4,11 +4,5 @@ justify-content: center; align-items: center; padding: 2rem; -} - -.credentials { - display: flex; - flex-direction: column; gap: 1rem; - margin: 1rem 0; } diff --git a/src/app/pages/auth-page/auth-page.component.html b/src/app/pages/auth-page/auth-page.component.html index 42fa334..bc4a51d 100644 --- a/src/app/pages/auth-page/auth-page.component.html +++ b/src/app/pages/auth-page/auth-page.component.html @@ -1,47 +1,18 @@ - - - Login - - - - - Login - - - - Password - - - - @let status = (authStatus | async); - @if (status?.error) { - {{ status?.error }} - } - - - Login - - - - + + + + + + + + + + + + memory + diff --git a/src/app/pages/auth-page/auth-page.component.ts b/src/app/pages/auth-page/auth-page.component.ts index 40bf015..b73dca9 100644 --- a/src/app/pages/auth-page/auth-page.component.ts +++ b/src/app/pages/auth-page/auth-page.component.ts @@ -1,67 +1,35 @@ -import { Component, DestroyRef, inject, model } from '@angular/core' -import { - MatCard, - MatCardActions, - MatCardContent, - MatCardHeader, - MatCardTitle, -} from '@angular/material/card' -import { MatInput } from '@angular/material/input' +import { Component, inject } from '@angular/core' import { MatFormFieldModule } from '@angular/material/form-field' -import { MatButton } from '@angular/material/button' import { FormsModule } from '@angular/forms' -import { AuthService } from '../../services/auth-service' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { Router } from '@angular/router' -import { AsyncPipe } from '@angular/common' +import { LoginForm } from '../../components/login-form/login-form' +import { SignupForm } from '../../components/signup-form/signup-form' +import { MatTab, MatTabGroup } from '@angular/material/tabs' +import { MatCard } from '@angular/material/card' +import { MatIconButton } from '@angular/material/button' +import { HealthStatus } from '../../components/health-status/health-status' +import { MatBottomSheet } from '@angular/material/bottom-sheet' +import { MatIconModule } from '@angular/material/icon' @Component({ selector: 'app-auth', imports: [ - MatCard, - MatCardTitle, - MatCardContent, MatFormFieldModule, - MatInput, - MatCardActions, - MatButton, - MatCardHeader, FormsModule, - AsyncPipe, + LoginForm, + SignupForm, + MatTabGroup, + MatTab, + MatCard, + MatIconModule, + MatIconButton, ], templateUrl: './auth-page.component.html', styleUrl: './auth-page.component.css', }) export class AuthPage { - authService = inject(AuthService) - router = inject(Router) - destroyRef = inject(DestroyRef) - - formData = model({ - login: '', - password: '', - }) - - authStatus = this.authService.$authStatus - - inputHandler(field: 'login' | 'password', event: Event) { - const { value } = event.target as HTMLInputElement - if (value) { - this.formData.update((prev) => ({ - ...prev, - [field]: value, - })) - } - } + private readonly bottomSheet = inject(MatBottomSheet) - onSubmit() { - this.authService - .login(this.formData()) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((result) => { - if (result) { - this.router.navigate(['/home']) - } - }) + showAppHealth() { + this.bottomSheet.open(HealthStatus) } } diff --git a/src/app/pages/bookmarks-page/bookmarks-page.css b/src/app/pages/bookmarks-page/bookmarks-page.css new file mode 100644 index 0000000..ae579e5 --- /dev/null +++ b/src/app/pages/bookmarks-page/bookmarks-page.css @@ -0,0 +1,20 @@ +:host { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); +} + +#view-toggle { + display: flex; + align-items: center; + justify-content: space-between; +} + +#actions { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; +} diff --git a/src/app/pages/bookmarks-page/bookmarks-page.html b/src/app/pages/bookmarks-page/bookmarks-page.html new file mode 100644 index 0000000..f534b9b --- /dev/null +++ b/src/app/pages/bookmarks-page/bookmarks-page.html @@ -0,0 +1,11 @@ + + + + + + + diff --git a/src/app/pages/bookmarks-page/bookmarks-page.ts b/src/app/pages/bookmarks-page/bookmarks-page.ts new file mode 100644 index 0000000..07c9f9f --- /dev/null +++ b/src/app/pages/bookmarks-page/bookmarks-page.ts @@ -0,0 +1,85 @@ +import { Component, DestroyRef, inject, OnInit, signal } from '@angular/core' +import { Article } from '../../entities/article/article.types' +import { FeedService } from '../../services/feed-service' +import { catchError, combineLatest, of, switchMap } from 'rxjs' +import { HttpErrorResponse } from '@angular/common/http' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { Tag } from '../../entities/tag/tag.types' +import { TagService } from '../../services/tag-service' +import { TitleService } from '../../services/title-service' +import { ArticleList } from '../../components/article-list/article-list' +import { MatToolbarRow } from '@angular/material/toolbar' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { PageDisplayToggle } from '../../components/page-display-toggle/page-display-toggle' + +@Component({ + selector: 'app-bookmarks-page', + imports: [ArticleList, MatToolbarRow, Paginator, PageDisplayToggle], + templateUrl: './bookmarks-page.html', + styleUrl: './bookmarks-page.css', +}) +export class BookmarksPage implements OnInit { + private readonly feedService = inject(FeedService) + private readonly destroyRef = inject(DestroyRef) + private readonly tagService = inject(TagService) + private readonly pageService = inject(PageService) + private readonly titleService = inject(TitleService) + + articles = signal([]) + + favTagId = signal('') + userTags = signal([]) + + ngOnInit() { + this.tagService + .getDefaultTags() + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((tags) => { + const favTag = tags?.find((t) => t.name === 'fav')?._id + if (!favTag) { + return of(null) + } + return combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]).pipe( + switchMap(([perPage, pageNumber]) => { + this.favTagId.set(favTag) + const filters = { tags: favTag } + return this.feedService.getAllArticles({ + pagination: { + perPage, + pageNumber, + }, + filters, + }) + }), + ) + }), + ) + .subscribe((result) => { + if (result) { + this.articles.set(result.result) + this.pageService.setTotalResults(result.total) + this.titleService.setTitle(`Bookmarks: ${result.total}`) + } else { + this.titleService.setTitle('Bookmarks') + } + this.titleService.setSubtitle(null) + }) + + this.tagService + .getAllTags({}) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + ) + .subscribe((tags) => { + if (tags?.result) { + this.userTags.set(tags.result.filter((t) => t.userId !== 'all')) + } + }) + } +} diff --git a/src/app/pages/feeds-page/feeds-page.css b/src/app/pages/feeds-page/feeds-page.css new file mode 100644 index 0000000..b25979e --- /dev/null +++ b/src/app/pages/feeds-page/feeds-page.css @@ -0,0 +1,24 @@ +:host { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 1rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); +} + +:host .mat-mdc-paginator { + background: transparent; +} + +.card { + cursor: pointer; +} + +.link { + font-style: italic; + font-size: smaller; +} + +.description { + color: var(--mat-sys-secondary); +} diff --git a/src/app/pages/feeds-page/feeds-page.html b/src/app/pages/feeds-page/feeds-page.html new file mode 100644 index 0000000..ebf9186 --- /dev/null +++ b/src/app/pages/feeds-page/feeds-page.html @@ -0,0 +1,75 @@ + + + + add + + + + refresh + + + + + + @if (isRefreshingAll()) { + + } @else { + @defer { + @for (f of feeds(); track f._id) { + + + {{ f.title }} + + + delete + + + refresh + + + edit + + + + Link: {{ f.link | linkTrim:15 }} + {{ f.description }} + + library_books + + + + } @empty { + No feeds found + } + } @loading (minimum 0.5s) { + + } + } + + + diff --git a/src/app/pages/feeds-page/feeds-page.ts b/src/app/pages/feeds-page/feeds-page.ts new file mode 100644 index 0000000..7606309 --- /dev/null +++ b/src/app/pages/feeds-page/feeds-page.ts @@ -0,0 +1,182 @@ +import { Component, DestroyRef, effect, inject, OnInit, signal } from '@angular/core' +import { MatCardModule } from '@angular/material/card' +import { FeedService } from '../../services/feed-service' +import { catchError, combineLatest, of, switchMap } from 'rxjs' +import { HttpErrorResponse } from '@angular/common/http' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { MatToolbarModule } from '@angular/material/toolbar' +import { MatIconModule } from '@angular/material/icon' +import { MatIconButton } from '@angular/material/button' +import { RowSpacer } from '../../components/row-spacer/row-spacer' +import { MatDialog, MatDialogModule } from '@angular/material/dialog' +import { Feed } from '../../entities/feed/feed.types' +import { MatProgressBar } from '@angular/material/progress-bar' +import { MatPaginatorModule } from '@angular/material/paginator' +import { LinkTrimPipe } from '../../pipes/link-trim-pipe' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { TitleService } from '../../services/title-service' +import { scrollUp } from '../../../utils' +import { RouterLink } from '@angular/router' +import { MatBadgeModule } from '@angular/material/badge' +import { ConfirmationDialog } from '../../components/confirmation-dialog/confirmation-dialog' +import { FeedAddForm } from '../../components/feed-add-form/feed-add-form' +import { FeedEditForm } from '../../components/feed-edit-form/feed-edit-form' +import { MatBottomSheet } from '@angular/material/bottom-sheet' +import { BottomErrorSheet } from '../../components/bottom-error-sheet/bottom-error-sheet' + +@Component({ + selector: 'app-feed-page', + imports: [ + MatCardModule, + MatToolbarModule, + MatIconModule, + MatIconButton, + RowSpacer, + MatDialogModule, + MatProgressBar, + MatPaginatorModule, + LinkTrimPipe, + Paginator, + RouterLink, + MatBadgeModule, + ], + templateUrl: './feeds-page.html', + styleUrl: './feeds-page.css', +}) +export class FeedsPage implements OnInit { + constructor() { + effect(() => { + scrollUp({ trigger: !!this.feeds().length }) + }) + } + + private readonly feedService = inject(FeedService) + private readonly pageService = inject(PageService) + private readonly dialog = inject(MatDialog) + private readonly destroyRef = inject(DestroyRef) + private readonly titleService = inject(TitleService) + private readonly bottomError = inject(MatBottomSheet) + + readonly feeds = signal([]) + readonly isRefreshing = signal>({}) + readonly isRefreshingAll = signal(false) + + ngOnInit(): void { + combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]) + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(([perPage, pageNumber]) => { + return this.feedService.getAllFeeds({ + pagination: { + perPage, + pageNumber, + }, + }) + }), + ) + .subscribe((result) => { + if (result) { + this.pageService.setTotalResults(result.total) + this.feeds.set(result.result) + } + this.titleService.setTitle('Feeds') + this.titleService.setSubtitle(null) + }) + } + + onAdd(): void { + const dialogRef = this.dialog.open(FeedAddForm) + + dialogRef.afterClosed().subscribe((result) => { + console.log('The dialog was closed', result) + this.pageService.setCurrentPage(1) + }) + } + + onRefreshOne(e: MouseEvent, feedId: string): void { + e.stopPropagation() + this.isRefreshing.update((prev) => ({ + ...prev, + [feedId]: true, + })) + this.feedService + .refreshOneFeed({ feedId }) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + this.isRefreshing.update((prev) => ({ + ...prev, + [feedId]: false, + })) + this.bottomError.open(BottomErrorSheet, { data: { error: e.error.message } }) + console.error(e) + return of(null) + }), + ) + .subscribe(() => { + this.isRefreshing.update((prev) => ({ + ...prev, + [feedId]: false, + })) + }) + } + + onRefreshAll(): void { + this.isRefreshingAll.set(true) + this.feedService + .refreshAllFeeds() + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + this.isRefreshingAll.set(false) + console.error(e) + return of(null) + }), + ) + .subscribe(() => { + this.isRefreshingAll.set(false) + }) + } + + onRemove(e: MouseEvent, id: string): void { + e.stopPropagation() + + const dialogRef = this.dialog.open(ConfirmationDialog, { + data: { + title: 'Delete feed?', + message: 'Are you sure you want to delete this feed?', + confirmButtonText: 'Delete', + }, + }) + + dialogRef.afterClosed().subscribe((agree) => { + if (agree) { + this.feedService + .deleteOneFeed({ feedId: id }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { + this.pageService.setCurrentPage(1) + }) + } + }) + } + + onEdit(e: MouseEvent, feed: Feed): void { + e.stopPropagation() + const dialogRef = this.dialog.open(FeedEditForm, { + data: { feed }, + }) + + dialogRef.afterClosed().subscribe((result) => { + console.log('The dialog was closed', result) + this.pageService.setCurrentPage(1) + }) + } +} diff --git a/src/app/pages/home/home-page.component.html b/src/app/pages/home/home-page.component.html deleted file mode 100644 index e2da575..0000000 --- a/src/app/pages/home/home-page.component.html +++ /dev/null @@ -1,103 +0,0 @@ - - - - view_day - - view_agenda - - - - {{ readFilter() ? 'mark_email_unread' : 'mark_email_read' }} - - bookmark - - - - -@defer { - @for (a of articles(); track a._id) { - - - {{ a.title }} - Published: {{ a.isoDate | date: 'short' }} - - @if (display() !== 'title') { - - - - } - - - link - - - open_in_new - - - {{ a.read ? 'mark_email_read' : 'mark_email_unread' }} - - - bookmark - - - - @for (t of userTags(); track t._id) { - {{ t.name }} - - } - - - - } @empty { - No articles found - } -} @loading (minimum 0.5s) { - -} - - - @if (articles().length > 0) { - - } - - - - check - Skip visible - - - done_all - Skip all - - - diff --git a/src/app/pages/home/home-page.component.ts b/src/app/pages/home/home-page.component.ts deleted file mode 100644 index 7a96830..0000000 --- a/src/app/pages/home/home-page.component.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { FeedService } from '../../services/feed-service' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { HttpErrorResponse } from '@angular/common/http' -import { catchError, of } from 'rxjs' -import { DatePipe } from '@angular/common' -import { MatButton, MatIconButton } from '@angular/material/button' -import { MatProgressBar } from '@angular/material/progress-bar' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatButtonToggleModule } from '@angular/material/button-toggle' -import { MatIconModule } from '@angular/material/icon' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { Router, RouterLink } from '@angular/router' -import { Article } from '../../entities/article/article.types' -import { MatPaginatorModule, PageEvent } from '@angular/material/paginator' -import { TagService } from '../../services/tag-service' -import { Tag } from '../../entities/tag/tag.types' -import { MatChipOption, MatChipSet } from '@angular/material/chips' -import { TitleService } from '../../services/title-service' -import { DomSanitizer, SafeHtml } from '@angular/platform-browser' - -@Component({ - selector: 'app-home', - imports: [ - MatCardModule, - DatePipe, - MatProgressBar, - MatToolbarModule, - MatButtonToggleModule, - MatIconModule, - RowSpacer, - MatIconButton, - RouterLink, - MatPaginatorModule, - MatChipOption, - MatChipSet, - MatButton, - ], - templateUrl: './home-page.component.html', - styleUrl: './home-page.component.css', -}) -export class HomePage implements OnInit { - feedService = inject(FeedService) - router = inject(Router) - destroyRef = inject(DestroyRef) - tagService = inject(TagService) - titleService = inject(TitleService) - htmlSanitizer = inject(DomSanitizer) - - articles = signal([]) - articleIds = computed(() => this.articles().map(({ _id }) => _id)) - display = signal<'title' | 'short'>('title') - - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - readFilter = signal(true) - favFilter = signal(false) - - favTagId = signal('') - userTags = signal([]) - - ngOnInit() { - this.getData() - this.tagService.$defaultTags - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((tags) => { - this.favTagId.set(tags?.find((t) => t.name === 'fav')?._id || '') - }) - this.tagService - .getAllTags({}) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((tags) => { - if (tags?.result) { - this.userTags.set(tags.result.filter((t) => t.userId !== 'all')) - } - }) - } - - getData() { - const filters: Record = {} - - if (this.readFilter()) { - filters['read'] = false - } - - if (this.favFilter()) { - filters['tags'] = this.favTagId() - } - - this.feedService - .getAllArticles({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - filters, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.articles.set(result.result) - this.totalResults.set(result.total) - this.titleService.setTitle(`News: ${result.total} articles`) - } else { - this.titleService.setTitle('News') - } - }) - } - - toggleDisplay(display: 'title' | 'short') { - this.display.set(display) - } - - safeHtml(html: string): SafeHtml { - return this.htmlSanitizer.bypassSecurityTrustHtml(html) - } - - async onArticleClick(article: Article) { - await this.router.navigate(['subscription', article.subscriptionId, 'article', article._id]) - } - - markAsRead(article: Article, event: MouseEvent) { - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { - read: !article.read, - }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, read: !a.read } : a)) - } - return prev - }) - }) - } - } - - markManyAsRead({ - articleIds, - read, - all, - }: { - articleIds?: string[] - read: boolean - all?: boolean - }) { - this.feedService - .changeManyArticles({ - ids: articleIds, - article: { read }, - all, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe(() => { - this.getData() - }) - } - - onAddToBookmarks(article: Article, event: MouseEvent) { - const existingTag = article.tags.find((t) => t === this.favTagId()) - const tags = existingTag - ? [...article.tags].filter((t) => t !== this.favTagId()) - : [...article.tags, this.favTagId()] - - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { tags }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, tags } : a)) - } - return prev - }) - }) - } - } - - paginationHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getData() - } - - filterHandler(filter: 'read' | 'fav') { - if (filter === 'read') { - this.readFilter.update((prev) => !prev) - } else { - this.favFilter.update((prev) => !prev) - } - this.getData() - } - - tagHandler(article: Article, tag: Tag, event: MouseEvent) { - const existingTag = article.tags.find((t) => t === tag._id) - const tags = existingTag - ? [...article.tags].filter((t) => t !== tag._id) - : [...article.tags, tag._id] - - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { tags }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, tags } : a)) - } - return prev - }) - }) - } - } -} diff --git a/src/app/pages/not-found/not-found-page.component.css b/src/app/pages/not-found/not-found-page.component.css index e69de29..a9bc56f 100644 --- a/src/app/pages/not-found/not-found-page.component.css +++ b/src/app/pages/not-found/not-found-page.component.css @@ -0,0 +1,13 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 1rem +} + +.emoji { + font-size: 20cqw; + font-weight: bold; + color: var(--mat-sys-secondary); +} diff --git a/src/app/pages/not-found/not-found-page.component.html b/src/app/pages/not-found/not-found-page.component.html index 8071020..5d3bd69 100644 --- a/src/app/pages/not-found/not-found-page.component.html +++ b/src/app/pages/not-found/not-found-page.component.html @@ -1 +1,2 @@ -not-found works! +Page not found +\(o_o)/ diff --git a/src/app/pages/status-page/status-page.css b/src/app/pages/status-page/status-page.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/status-page/status-page.html b/src/app/pages/status-page/status-page.html new file mode 100644 index 0000000..d7ddc81 --- /dev/null +++ b/src/app/pages/status-page/status-page.html @@ -0,0 +1 @@ + diff --git a/src/app/pages/status-page/status-page.ts b/src/app/pages/status-page/status-page.ts new file mode 100644 index 0000000..851f3fe --- /dev/null +++ b/src/app/pages/status-page/status-page.ts @@ -0,0 +1,18 @@ +import { Component, inject, OnInit } from '@angular/core' +import { HealthStatus } from '../../components/health-status/health-status' +import { TitleService } from '../../services/title-service' + +@Component({ + selector: 'app-status-page', + imports: [HealthStatus], + templateUrl: './status-page.html', + styleUrl: './status-page.css', +}) +export class StatusPage implements OnInit { + private readonly titleService = inject(TitleService) + + ngOnInit() { + this.titleService.setTitle('User') + this.titleService.setSubtitle(null) + } +} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.css b/src/app/pages/subscriptions/subscriptions-page.component.css deleted file mode 100644 index 67cb9da..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.css +++ /dev/null @@ -1,9 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 1rem; -} - -:host .mat-mdc-paginator { - background: transparent; -} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.html b/src/app/pages/subscriptions/subscriptions-page.component.html deleted file mode 100644 index 10aabda..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - add - - - @if (feeds().length > 0) { - - } - - -@defer { - @for (f of feeds(); track f._id) { - - - {{ f.title }} - - - delete - - - - Link: {{ f.link }} - Articles: {{ f.articles.length }} - - - } @empty { - No subscriptions found - } -} @loading (minimum 0.5s) { - -} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.ts b/src/app/pages/subscriptions/subscriptions-page.component.ts deleted file mode 100644 index 720d0ed..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Component, DestroyRef, inject, OnInit, signal, viewChild } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { FeedService } from '../../services/feed-service' -import { catchError, of } from 'rxjs' -import { HttpErrorResponse } from '@angular/common/http' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatIconModule } from '@angular/material/icon' -import { MatIconButton } from '@angular/material/button' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { MatDialog, MatDialogModule } from '@angular/material/dialog' -import { SubscriptionAddForm } from '../../components/subscription-add-form/subscription-add-form' -import { Feed } from '../../entities/feed/feed.types' -import { MatProgressBar } from '@angular/material/progress-bar' -import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator' - -@Component({ - selector: 'app-subscriptions', - imports: [ - MatCardModule, - MatToolbarModule, - MatIconModule, - MatIconButton, - RowSpacer, - MatDialogModule, - MatProgressBar, - MatPaginatorModule, - ], - templateUrl: './subscriptions-page.component.html', - styleUrl: './subscriptions-page.component.css', -}) -export class SubscriptionsPage implements OnInit { - feedService = inject(FeedService) - readonly dialog = inject(MatDialog) - destroyRef = inject(DestroyRef) - - feeds = signal([]) - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - ngOnInit() { - this.getData() - } - - getData() { - this.feedService - .getAllSubscriptions({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.currentPage.set(1) - this.feeds.set(result.result) - this.totalResults.set(result.total) - } - }) - } - - onAdd() { - const dialogRef = this.dialog.open(SubscriptionAddForm) - - dialogRef.afterClosed().subscribe((result) => { - if (result) { - this.getData() - } - }) - } - - paginator = viewChild(MatPaginator) - - onRemove(id: string) { - this.feedService - .deleteOneSubscription({ subscriptionId: id }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - this.getData() - this.paginator()?.firstPage() - }) - } - - paginationHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getData() - } -} diff --git a/src/app/pages/tags-page/tags-page.css b/src/app/pages/tags-page/tags-page.css new file mode 100644 index 0000000..31b5e77 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.css @@ -0,0 +1,11 @@ +:host { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 1rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); +} + +input { + margin-top: 2rem; +} diff --git a/src/app/pages/tags-page/tags-page.html b/src/app/pages/tags-page/tags-page.html new file mode 100644 index 0000000..50596d6 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.html @@ -0,0 +1,47 @@ + + @defer { + User tags + + + @for (t of userTags(); track t._id) { + + + edit + + {{ t.name }} + + cancel + + + } + + + + + App tags + + @for (t of appTags(); track t._id) { + + {{ t.name }} + + } + + + + } + + + diff --git a/src/app/pages/tags-page/tags-page.ts b/src/app/pages/tags-page/tags-page.ts new file mode 100644 index 0000000..c376202 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.ts @@ -0,0 +1,153 @@ +import { Component, DestroyRef, inject, linkedSignal, OnInit, signal } from '@angular/core' +import { TagService } from '../../services/tag-service' +import { Tag } from '../../entities/tag/tag.types' +import { HttpErrorResponse } from '@angular/common/http' +import { catchError, combineLatest, of, switchMap } from 'rxjs' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { MatIconModule } from '@angular/material/icon' +import { MatChipEditedEvent, MatChipInputEvent, MatChipsModule } from '@angular/material/chips' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { TitleService } from '../../services/title-service' +import { Router } from '@angular/router' +import { MatFormFieldModule } from '@angular/material/form-field' +import { COMMA, ENTER } from '@angular/cdk/keycodes' +import { MatBottomSheet } from '@angular/material/bottom-sheet' +import { BottomErrorSheet } from '../../components/bottom-error-sheet/bottom-error-sheet' +import { MatDialog } from '@angular/material/dialog' +import { ConfirmationDialog } from '../../components/confirmation-dialog/confirmation-dialog' + +@Component({ + selector: 'app-tags-page', + imports: [MatIconModule, Paginator, MatFormFieldModule, MatChipsModule], + templateUrl: './tags-page.html', + styleUrl: './tags-page.css', +}) +export class TagsPage implements OnInit { + private readonly tagsService = inject(TagService) + private readonly pageService = inject(PageService) + private readonly destroyRef = inject(DestroyRef) + private readonly titleService = inject(TitleService) + private readonly router = inject(Router) + private readonly errorSheet = inject(MatBottomSheet) + private readonly dialog = inject(MatDialog) + + public readonly separatorKeysCodes = [ENTER, COMMA] as const + + private readonly tags = signal([]) + readonly userTags = linkedSignal(() => { + return this.tags().filter((t) => t.userId !== 'all') + }) + readonly appTags = linkedSignal(() => { + return this.tags().filter((t) => t.userId === 'all') + }) + + ngOnInit() { + combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]) + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(([perPage, pageNumber]) => { + return this.tagsService.getAllTags({ + pagination: { + perPage, + pageNumber, + }, + }) + }), + ) + .subscribe((result) => { + if (result) { + this.pageService.setTotalResults(result.total) + this.tags.set(result.result) + this.titleService.setTitle('Tags') + this.titleService.setSubtitle(null) + } + }) + } + + onAdd(e: MatChipInputEvent) { + const name = (e.value || '').trim() + + if (!name) { + return + } + + this.tagsService + .addOneTag({ name }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + this.errorSheet.open(BottomErrorSheet, { data: { error: error.error.message } }) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + e.chipInput.clear() + } + }) + } + + onEdit(tag: Tag, e: MatChipEditedEvent) { + const newName = (e.value || '').trim() + const currentName = tag.name + + if (!newName) { + return + } + + this.tagsService + .changeOneTag({ newName, currentName }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + this.errorSheet.open(BottomErrorSheet, { data: { error: error.error.message } }) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + } + }) + } + + onRemove(tag: Tag) { + const dialogRef = this.dialog.open(ConfirmationDialog, { + data: { + title: 'Delete tag', + message: `Are you sure you want to delete the tag "${tag.name}"?`, + confirmButtonText: 'Delete', + }, + }) + + dialogRef.afterClosed().subscribe((agree) => { + if (agree) { + this.tagsService + .deleteOneTag({ name: tag.name }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + } + }) + } + }) + } + + onClick(name: string) { + if (!name) { + return + } + this.router.navigate(['/articles'], { queryParams: { tag: name } }) + } +} diff --git a/src/app/pages/tags/tags-page.component.css b/src/app/pages/tags/tags-page.component.css deleted file mode 100644 index 1556b2c..0000000 --- a/src/app/pages/tags/tags-page.component.css +++ /dev/null @@ -1,5 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 1rem; -} diff --git a/src/app/pages/tags/tags-page.component.html b/src/app/pages/tags/tags-page.component.html deleted file mode 100644 index 1e851b0..0000000 --- a/src/app/pages/tags/tags-page.component.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - add - - - @if (tags().length > 0) { - - } - - -@defer { - - @for (t of tags(); track t._id) { - - {{ t.name }} - @if (t.userId !== 'all') { - - cancel - - } - - } - -} diff --git a/src/app/pages/tags/tags-page.component.ts b/src/app/pages/tags/tags-page.component.ts deleted file mode 100644 index d6866e4..0000000 --- a/src/app/pages/tags/tags-page.component.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Component, DestroyRef, inject, OnInit, signal, viewChild } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { TagService } from '../../services/tag-service' -import { Tag } from '../../entities/tag/tag.types' -import { HttpErrorResponse } from '@angular/common/http' -import { catchError, of } from 'rxjs' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { MatPaginator, PageEvent } from '@angular/material/paginator' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatIconButton } from '@angular/material/button' -import { MatIconModule } from '@angular/material/icon' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { MatDialog } from '@angular/material/dialog' -import { TagAddForm } from '../../components/tag-add-form/tag-add-form' -import { MatChipRemove, MatChipRow, MatChipSet } from '@angular/material/chips' - -@Component({ - selector: 'app-tags', - imports: [ - MatCardModule, - MatToolbarModule, - MatIconButton, - MatIconModule, - RowSpacer, - MatPaginator, - MatChipRow, - MatChipRemove, - MatChipSet, - ], - templateUrl: './tags-page.component.html', - styleUrl: './tags-page.component.css', -}) -export class TagsPage implements OnInit { - tagsService = inject(TagService) - destroyRef = inject(DestroyRef) - readonly dialog = inject(MatDialog) - - tags = signal([]) - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - getDate() { - this.tagsService - .getAllTags({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.error(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.currentPage.set(1) - this.tags.set(result.result) - this.totalResults.set(result.total) - } - }) - } - - ngOnInit() { - this.getDate() - } - - paginator = viewChild(MatPaginator) - - onAdd() { - const dialogRef = this.dialog.open(TagAddForm) - - dialogRef.afterClosed().subscribe((result) => { - if (result) { - this.getDate() - } - }) - } - - onRemove(tag: Tag) { - this.tagsService - .deleteOneTag({ name: tag.name }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.error(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe(() => { - this.getDate() - this.paginator()?.firstPage() - }) - } - - paginatorHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getDate() - } -} diff --git a/src/app/pages/user-page/user-page.css b/src/app/pages/user-page/user-page.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/user-page/user-page.html b/src/app/pages/user-page/user-page.html new file mode 100644 index 0000000..49a288a --- /dev/null +++ b/src/app/pages/user-page/user-page.html @@ -0,0 +1,8 @@ + + + Username: {{ ($currentUser | async)?.user?.login }} + + + Role: {{ ($currentUser | async)?.user?.role }} + + diff --git a/src/app/pages/user-page/user-page.ts b/src/app/pages/user-page/user-page.ts new file mode 100644 index 0000000..5aa3f70 --- /dev/null +++ b/src/app/pages/user-page/user-page.ts @@ -0,0 +1,22 @@ +import { Component, inject, OnInit } from '@angular/core' +import { AuthService } from '../../services/auth-service' +import { AsyncPipe } from '@angular/common' +import { TitleService } from '../../services/title-service' + +@Component({ + selector: 'app-user-page', + imports: [AsyncPipe], + templateUrl: './user-page.html', + styleUrl: './user-page.css', +}) +export class UserPage implements OnInit { + private readonly authService = inject(AuthService) + private readonly titleService = inject(TitleService) + + $currentUser = this.authService.$authStatus + + ngOnInit() { + this.titleService.setTitle('User') + this.titleService.setSubtitle(null) + } +} diff --git a/src/app/pipes/link-trim-pipe.ts b/src/app/pipes/link-trim-pipe.ts new file mode 100644 index 0000000..45aeb2b --- /dev/null +++ b/src/app/pipes/link-trim-pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core' + +@Pipe({ + name: 'linkTrim', +}) +export class LinkTrimPipe implements PipeTransform { + transform(value: string, length: number = Infinity): string { + try { + const parsed = URL.parse(value) + if (!parsed) { + throw new Error('Invalid URL') + } + const { host, pathname } = parsed + return `${host}${pathname.slice(0, length) + '...'}` + } catch (e) { + console.error(e) + return value.slice(0, length) + '...' + } + } +} diff --git a/src/app/pipes/safe-html-pipe.ts b/src/app/pipes/safe-html-pipe.ts new file mode 100644 index 0000000..51f0a54 --- /dev/null +++ b/src/app/pipes/safe-html-pipe.ts @@ -0,0 +1,16 @@ +import { inject, Pipe, PipeTransform } from '@angular/core' +import { DomSanitizer, SafeHtml } from '@angular/platform-browser' + +@Pipe({ + name: 'safeHtml', +}) +export class SafeHtmlPipe implements PipeTransform { + sanitizer = inject(DomSanitizer) + + transform(value: string): SafeHtml { + if (!value) { + return '' + } + return this.sanitizer.bypassSecurityTrustHtml(value) + } +} diff --git a/src/app/services/auth-service.ts b/src/app/services/auth-service.ts index 43d1908..1f18117 100644 --- a/src/app/services/auth-service.ts +++ b/src/app/services/auth-service.ts @@ -23,15 +23,58 @@ export class AuthService { $authStatus = this.$$authStatus.asObservable() login({ login, password }: UserDTO) { - return this.httpClient.post(`${environment.api}/auth/login`, { login, password }).pipe( - switchMap((response) => { - this.$$authStatus.next({ authenticated: true, user: response, error: null }) - return of(response) - }), + return this.httpClient + .post(`${environment.api}/auth/login`, { + login: login.trim(), + password: password.trim(), + }) + .pipe( + switchMap((response) => { + localStorage.setItem('user', response._id) + this.$$authStatus.next({ authenticated: true, user: response, error: null }) + return of(response) + }), + catchError((error: HttpErrorResponse) => { + this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) + return of(null) + }), + ) + } + + signup({ password }: { password: string }) { + return this.httpClient + .post(`${environment.api}/auth/signup`, { + password: password.trim(), + }) + .pipe( + switchMap((response) => { + localStorage.setItem('user', response._id) + this.$$authStatus.next({ authenticated: true, user: response, error: null }) + return of(response) + }), + catchError((error: HttpErrorResponse) => { + this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) + return of(null) + }), + ) + } + + logout() { + return this.httpClient.get<{ message: string }>(`${environment.api}/auth/logout`).pipe( catchError((error: HttpErrorResponse) => { - this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) - return of(null) + console.error(error) + return of(false) + }), + switchMap(() => { + localStorage.removeItem('user') + this.$$authStatus.next({ authenticated: false, user: null, error: null }) + return of(true) }), ) } + + updateAuth(user: User) { + this.$$authStatus.next({ authenticated: true, user, error: null }) + localStorage.setItem('user', user._id) + } } diff --git a/src/app/services/feed-service.ts b/src/app/services/feed-service.ts index eb563be..9b3aae7 100644 --- a/src/app/services/feed-service.ts +++ b/src/app/services/feed-service.ts @@ -4,17 +4,16 @@ import { environment } from '../../environments/environment' import { Feed, FeedDTO } from '../entities/feed/feed.types' import { Article, ArticleDTO } from '../entities/article/article.types' import { Paginated, Pagination } from '../entities/base/base.types' -import { TagService } from './tag-service' +import { SortOrder } from '../entities/base/base.enums' @Injectable({ providedIn: 'root', }) export class FeedService { - httpClient = inject(HttpClient) - tagService = inject(TagService) + readonly httpClient = inject(HttpClient) - getAllSubscriptions({ pagination }: { pagination?: Partial }) { - return this.httpClient.get>(`${environment.api}/subscription`, { + getAllFeeds({ pagination }: { pagination?: Partial }) { + return this.httpClient.get>(`${environment.api}/feed`, { params: pagination, }) } @@ -22,18 +21,27 @@ export class FeedService { getAllArticles({ pagination, filters, + sort, }: { pagination?: Partial filters?: { tags?: string; read?: boolean } + sort?: { date: SortOrder } }) { - this.tagService.getDefaultTags() return this.httpClient.get>(`${environment.api}/article`, { - params: { ...pagination, ...filters }, + params: { + ...pagination, + ...filters, + dateSort: sort?.date || SortOrder.Desc, + }, }) } - getOneSubscription({ subscriptionId }: { subscriptionId: string }) { - return this.httpClient.get(`${environment.api}/subscription/${subscriptionId}`) + getOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.get(`${environment.api}/feed/${feedId}`) + } + + changeOneFeed({ id, dto }: { id: string; dto: Partial }) { + return this.httpClient.patch(`${environment.api}/feed/${id}`, dto) } getOneArticle({ articleId }: { articleId: string }) { @@ -63,11 +71,23 @@ export class FeedService { }) } - addOneSubscription({ subscription }: { subscription: FeedDTO }) { - return this.httpClient.post(`${environment.api}/subscription`, subscription) + addOneFeed({ feed }: { feed: FeedDTO }) { + return this.httpClient.post(`${environment.api}/feed`, feed) + } + + deleteOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.delete(`${environment.api}/feed/${feedId}`) + } + + refreshAllFeeds() { + return this.httpClient.get(`${environment.api}/feed/refresh`) + } + + refreshOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.get(`${environment.api}/feed/${feedId}/refresh`) } - deleteOneSubscription({ subscriptionId }: { subscriptionId: string }) { - return this.httpClient.delete(`${environment.api}/subscription/${subscriptionId}`) + getFullText({ articleId }: { articleId: string }) { + return this.httpClient.get<{ fullText: string }>(`${environment.api}/article/${articleId}/full`) } } diff --git a/src/app/services/health-service.ts b/src/app/services/health-service.ts new file mode 100644 index 0000000..f50b578 --- /dev/null +++ b/src/app/services/health-service.ts @@ -0,0 +1,17 @@ +import { inject, Injectable } from '@angular/core' +import { HttpClient } from '@angular/common/http' +import { environment } from '../../environments/environment' + +@Injectable({ + providedIn: 'root', +}) +export class HealthService { + constructor() {} + httpClient = inject(HttpClient) + + getBackendStatus() { + return this.httpClient.get<{ status: string; version: string; uptime: string }>( + `${environment.api}/health`, + ) + } +} diff --git a/src/app/services/page-service.ts b/src/app/services/page-service.ts new file mode 100644 index 0000000..516dbc4 --- /dev/null +++ b/src/app/services/page-service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core' +import { PageDisplay } from '../entities/page/page.enums' +import { BehaviorSubject } from 'rxjs' + +@Injectable({ + providedIn: 'any', +}) +export class PageService { + private $$pageSize = new BehaviorSubject(5) + $pageSize = this.$$pageSize.asObservable() + + private $$currentPage = new BehaviorSubject(1) + $currentPage = this.$$currentPage.asObservable() + + private $$totalResults = new BehaviorSubject(0) + $totalResults = this.$$totalResults.asObservable() + + private $$display = new BehaviorSubject(PageDisplay.Title) + $display = this.$$display.asObservable() + + setCurrentPage(currentPage: number) { + this.$$currentPage.next(currentPage) + } + + setPageSize(pageSize: number) { + this.$$pageSize.next(pageSize) + } + + setTotalResults(totalResults: number) { + this.$$totalResults.next(totalResults) + } + + setDisplay(display: PageDisplay) { + this.$$display.next(display) + } +} diff --git a/src/app/services/tag-service.ts b/src/app/services/tag-service.ts index bc9f128..810f379 100644 --- a/src/app/services/tag-service.ts +++ b/src/app/services/tag-service.ts @@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http' import { Tag } from '../entities/tag/tag.types' import { environment } from '../../environments/environment' import { Paginated, Pagination } from '../entities/base/base.types' -import { BehaviorSubject } from 'rxjs' +import { BehaviorSubject, tap } from 'rxjs' @Injectable({ providedIn: 'root', @@ -18,9 +18,11 @@ export class TagService { $defaultTags = this.$$defaultTags.asObservable() getDefaultTags() { - return this.httpClient.get(`${environment.api}/tag?default=true`).subscribe((tags) => { - this.$$defaultTags.next(tags) - }) + return this.httpClient.get(`${environment.api}/tag?default=true`).pipe( + tap((tags) => { + this.$$defaultTags.next(tags) + }), + ) } getAllTags({ pagination }: { pagination?: Partial }) { @@ -29,6 +31,10 @@ export class TagService { }) } + getOne({ name }: { name: Tag['_id'] }) { + return this.httpClient.get(`${environment.api}/tag/${name}`) + } + addOneTag({ name }: { name: string }) { return this.httpClient.post(`${environment.api}/tag`, { name }) } diff --git a/src/app/services/title-service.ts b/src/app/services/title-service.ts index 8600424..701c846 100644 --- a/src/app/services/title-service.ts +++ b/src/app/services/title-service.ts @@ -11,8 +11,15 @@ export class TitleService { private $$currentTitle = new BehaviorSubject('') $currentTitle = this.$$currentTitle.asObservable() + private $$currentSubtitle = new BehaviorSubject(null) + $currentSubtitle = this.$$currentSubtitle.asObservable() + setTitle(title: string) { this.$$currentTitle.next(title) this.pageTitleService.setTitle(title) } + + setSubtitle(subtitle: string | null) { + this.$$currentSubtitle.next(subtitle) + } } diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 05fac53..3402093 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,5 +1,5 @@ import { Environment } from './environment.types' export const environment: Environment = { - api: 'http://rss-nest:3600/api', + api: 'api', } diff --git a/src/index.html b/src/index.html index 78c77ef..fedac66 100644 --- a/src/index.html +++ b/src/index.html @@ -1,18 +1,28 @@ - - - News - - - - - - - - - + + + News + + + + + + + + + diff --git a/src/styles.css b/src/styles.css index c784d4f..0609480 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,4 +1,5 @@ -/* You can add global styles to this file, and also import other style files */ +@import "@angular/material/prebuilt-themes/cyan-orange.css" (prefers-color-scheme: dark); +@import "@angular/material/prebuilt-themes/azure-blue.css" (prefers-color-scheme: light); html, body { @@ -20,3 +21,29 @@ body { mat-toolbar-row { height: auto !important; } + +.external { + display: flex; + flex-direction: column; +} + +.content_layout { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.content_layout img { + max-height: 20ch; + max-width: 70cqw; + align-self: center; + border-radius: 1rem; +} + +.content_layout a { + color: inherit; +} + +.highlighted { + color: var(--mat-sys-primary) !important; +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..f723ed7 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export { scrollUp } from './scrollUp' diff --git a/src/utils/scrollUp.ts b/src/utils/scrollUp.ts new file mode 100644 index 0000000..2d43ce8 --- /dev/null +++ b/src/utils/scrollUp.ts @@ -0,0 +1,7 @@ +export function scrollUp({ trigger }: { trigger: boolean }) { + if (!trigger) { + return + } + const page = document.querySelector('.page-content') + page?.scroll({ top: 0, behavior: 'smooth' }) +}
(null) - - favTagId = signal('') - - readStatus = computed(() => { + readonly article = signal(null) + readonly fullText = signal(undefined) + readonly favTagId = signal('') + readonly readStatus = computed(() => { return !!this.article()?.read }) + readonly isLoading = signal(false) safeHtml(html?: string): SafeHtml | undefined { if (!html) { @@ -56,7 +63,7 @@ export class ArticlePage implements OnInit { return this.domSanitizer.bypassSecurityTrustHtml(html) } - ngOnInit() { + ngOnInit(): void { this.route.params .pipe( exhaustMap((params) => { @@ -78,21 +85,26 @@ export class ArticlePage implements OnInit { }), takeUntilDestroyed(this.destroyRef), catchError((error: HttpErrorResponse) => { - console.log(error) + console.error(error) return of(null) }), ) .subscribe((result) => { this.article.set(result) + if (result?.fullText) { + const parsed = this.safeHtml(result.fullText || '') + this.fullText.set(parsed) + } this.titleService.setTitle(result?.title || '') + this.titleService.setSubtitle(null) }) - this.tagService.$defaultTags.subscribe((tags) => { + this.tagService.$defaultTags.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((tags) => { this.favTagId.set(tags.find((t) => t.name === 'fav')?._id || '') }) } - onMarkAsRead() { + onMarkAsRead(): void { if (this.article() !== null) { this.feedService .changeOneArticle({ @@ -104,7 +116,7 @@ export class ArticlePage implements OnInit { .pipe( takeUntilDestroyed(this.destroyRef), catchError((error: HttpErrorResponse) => { - console.log(error) + console.error(error) return of(null) }), ) @@ -119,7 +131,7 @@ export class ArticlePage implements OnInit { } } - onAddToBookmarks() { + onAddToBookmarks(): void { const existingTag = this.article()?.tags.find((t) => t === this.favTagId()) const tags = existingTag ? [...(this.article()?.tags || [])].filter((t) => t !== this.favTagId()) @@ -134,7 +146,7 @@ export class ArticlePage implements OnInit { .pipe( takeUntilDestroyed(this.destroyRef), catchError((error: HttpErrorResponse) => { - console.log(error) + console.error(error) return of(null) }), ) @@ -148,4 +160,27 @@ export class ArticlePage implements OnInit { }) } } + + getFullText(): void { + const articleId = this.article()?._id + if (!articleId) { + return + } + this.isLoading.set(true) + this.feedService + .getFullText({ articleId }) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + console.error(e) + this.isLoading.set(false) + return of(null) + }), + ) + .subscribe((result) => { + const parsed = this.safeHtml(result?.fullText || '') + this.fullText.set(parsed) + this.isLoading.set(false) + }) + } } diff --git a/src/app/pages/home/home-page.component.css b/src/app/pages/articles-page/articles-page.css similarity index 53% rename from src/app/pages/home/home-page.component.css rename to src/app/pages/articles-page/articles-page.css index bc1230e..ae579e5 100644 --- a/src/app/pages/home/home-page.component.css +++ b/src/app/pages/articles-page/articles-page.css @@ -2,7 +2,8 @@ display: flex; flex-direction: column; gap: 1rem; - padding: 0.5rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); } #view-toggle { @@ -11,23 +12,9 @@ justify-content: space-between; } -#paginator, #actions { display: flex; align-items: center; justify-content: center; gap: 1rem; } - -.card { - cursor: pointer; -} - -.card:hover { - box-shadow: 0 0 0.2rem var(--mat-sys-primary); - transition: box-shadow 0.3s ease-in-out; -} - -.highlighted { - color: var(--mat-sys-primary); -} diff --git a/src/app/pages/articles-page/articles-page.html b/src/app/pages/articles-page/articles-page.html new file mode 100644 index 0000000..c681b17 --- /dev/null +++ b/src/app/pages/articles-page/articles-page.html @@ -0,0 +1,73 @@ + + + + + refresh + + + + + + schedule + + + + {{ ($readFilter | async) ? 'mark_email_unread' : 'mark_email_read' }} + + + + bookmark + + + + + + + + +@if (articles().length) { + + + + check + Skip visible + + + done_all + Skip all + + +} diff --git a/src/app/pages/articles-page/articles-page.ts b/src/app/pages/articles-page/articles-page.ts new file mode 100644 index 0000000..1b8e97a --- /dev/null +++ b/src/app/pages/articles-page/articles-page.ts @@ -0,0 +1,252 @@ +import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core' +import { MatCardModule } from '@angular/material/card' +import { FeedService } from '../../services/feed-service' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { HttpErrorResponse } from '@angular/common/http' +import { BehaviorSubject, catchError, combineLatest, forkJoin, of, switchMap } from 'rxjs' +import { MatButton, MatIconButton } from '@angular/material/button' +import { MatToolbarModule } from '@angular/material/toolbar' +import { MatButtonToggleModule } from '@angular/material/button-toggle' +import { MatIconModule } from '@angular/material/icon' +import { RowSpacer } from '../../components/row-spacer/row-spacer' +import { ActivatedRoute } from '@angular/router' +import { Article } from '../../entities/article/article.types' +import { MatPaginatorModule } from '@angular/material/paginator' +import { TagService } from '../../services/tag-service' +import { Tag } from '../../entities/tag/tag.types' +import { TitleService } from '../../services/title-service' +import { ArticleList } from '../../components/article-list/article-list' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { PageDisplayToggle } from '../../components/page-display-toggle/page-display-toggle' +import { AsyncPipe } from '@angular/common' +import { SortOrder } from '../../entities/base/base.enums' + +@Component({ + selector: 'app-articles-page', + imports: [ + MatCardModule, + MatToolbarModule, + MatButtonToggleModule, + MatIconModule, + RowSpacer, + MatIconButton, + MatPaginatorModule, + MatButton, + ArticleList, + Paginator, + PageDisplayToggle, + AsyncPipe, + ], + templateUrl: './articles-page.html', + styleUrl: './articles-page.css', +}) +export class ArticlesPage implements OnInit { + private readonly feedService = inject(FeedService) + private readonly route = inject(ActivatedRoute) + private readonly destroyRef = inject(DestroyRef) + private readonly tagService = inject(TagService) + private readonly titleService = inject(TitleService) + private readonly pageService = inject(PageService) + + readonly articles = signal([]) + readonly articleIds = computed(() => this.articles().map(({ _id }) => _id)) + readonly favTagId = signal('') + readonly userTags = signal([]) + readonly isRefreshingAll = signal(false) + + $readFilter = new BehaviorSubject(true) + $favFilter = new BehaviorSubject(false) + $feedFilter = new BehaviorSubject(null) + $tagFilter = new BehaviorSubject(null) + $dateOrder = new BehaviorSubject(SortOrder.Desc) + + ngOnInit() { + this.tagService + .getAllTags({}) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + ) + .subscribe((tags) => { + if (tags?.result) { + this.userTags.set(tags.result.filter((t) => t.userId !== 'all')) + } + }) + + this.tagService + .getDefaultTags() + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((tags) => { + const favTag = tags?.find((t) => t.name === 'fav')?._id + if (!favTag) { + return of(null) + } + this.favTagId.set(favTag) + + return combineLatest([ + this.pageService.$pageSize, + this.pageService.$currentPage, + this.$favFilter, + this.$readFilter, + this.$feedFilter, + this.$tagFilter, + this.$dateOrder, + ]).pipe( + switchMap(([perPage, pageNumber, fav, read, feed, tag, dateSort]) => { + const filters: Record = {} + + if (read) { + filters['read'] = false + } + + if (fav) { + filters['tags'] = favTag + } + + if (feed) { + filters['feed'] = feed + } + + if (tag) { + filters['tags'] = tag + } + + return this.feedService.getAllArticles({ + pagination: { + perPage, + pageNumber, + }, + filters, + sort: { + date: dateSort, + }, + }) + }), + ) + }), + ) + .subscribe((result) => { + if (result) { + this.articles.set(result.result) + this.pageService.setTotalResults(result.total) + this.titleService.setTitle(`Articles: ${result.total} articles`) + } else { + this.titleService.setTitle('Articles') + } + }) + + this.route.queryParams + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((params) => { + const feedId: string = params['feed'] + const tagName: string = params['tag'] + if (tagName) { + return forkJoin([of(null), this.tagService.getOne({ name: tagName })]) + } + + if (feedId) { + return forkJoin([this.feedService.getOneFeed({ feedId }), of(null)]) + } + + return forkJoin([of(null), of(null)]) + }), + catchError((e) => { + console.error(e) + return of(null) + }), + ) + .subscribe((results) => { + if (!results) { + return + } + + const [feed, tag] = results + + if (feed) { + this.titleService.setSubtitle(feed.title) + this.$feedFilter.next(feed._id) + this.$tagFilter.next(null) + } else if (tag) { + this.titleService.setSubtitle(tag.name) + this.$feedFilter.next(null) + this.$tagFilter.next(tag._id) + } else { + this.titleService.setSubtitle(null) + this.$feedFilter.next(null) + this.$tagFilter.next(null) + } + }) + } + + markManyAsRead({ + articleIds, + read, + all, + }: { + articleIds?: string[] + read: boolean + all?: boolean + }) { + this.feedService + .changeManyArticles({ + ids: articleIds, + article: { read }, + all, + }) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + ) + .subscribe((result) => { + if (!result?.modifiedCount) { + return + } + this.pageService.setCurrentPage(1) + }) + } + + filterHandler(filter: 'read' | 'fav') { + if (filter === 'read') { + this.$readFilter.next(!this.$readFilter.value) + } else { + this.$favFilter.next(!this.$favFilter.value) + } + this.pageService.setCurrentPage(1) + } + + orderHandler(param: 'date') { + if (param === 'date') { + this.$dateOrder.next(this.$dateOrder.value === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc) + this.pageService.setCurrentPage(1) + } + } + + onRefreshAll() { + this.isRefreshingAll.set(true) + this.feedService + .refreshAllFeeds() + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + this.isRefreshingAll.set(false) + console.error(e) + return of(null) + }), + ) + .subscribe(() => { + this.isRefreshingAll.set(false) + this.pageService.setCurrentPage(1) + }) + } + + protected readonly SortOrder = SortOrder +} diff --git a/src/app/pages/auth-page/auth-page.component.css b/src/app/pages/auth-page/auth-page.component.css index 6de3848..a37ef5f 100644 --- a/src/app/pages/auth-page/auth-page.component.css +++ b/src/app/pages/auth-page/auth-page.component.css @@ -4,11 +4,5 @@ justify-content: center; align-items: center; padding: 2rem; -} - -.credentials { - display: flex; - flex-direction: column; gap: 1rem; - margin: 1rem 0; } diff --git a/src/app/pages/auth-page/auth-page.component.html b/src/app/pages/auth-page/auth-page.component.html index 42fa334..bc4a51d 100644 --- a/src/app/pages/auth-page/auth-page.component.html +++ b/src/app/pages/auth-page/auth-page.component.html @@ -1,47 +1,18 @@ - - - Login - - - - - Login - - - - Password - - - - @let status = (authStatus | async); - @if (status?.error) { - {{ status?.error }} - } - - - Login - - - - + + + + + + + + + + + + memory + diff --git a/src/app/pages/auth-page/auth-page.component.ts b/src/app/pages/auth-page/auth-page.component.ts index 40bf015..b73dca9 100644 --- a/src/app/pages/auth-page/auth-page.component.ts +++ b/src/app/pages/auth-page/auth-page.component.ts @@ -1,67 +1,35 @@ -import { Component, DestroyRef, inject, model } from '@angular/core' -import { - MatCard, - MatCardActions, - MatCardContent, - MatCardHeader, - MatCardTitle, -} from '@angular/material/card' -import { MatInput } from '@angular/material/input' +import { Component, inject } from '@angular/core' import { MatFormFieldModule } from '@angular/material/form-field' -import { MatButton } from '@angular/material/button' import { FormsModule } from '@angular/forms' -import { AuthService } from '../../services/auth-service' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { Router } from '@angular/router' -import { AsyncPipe } from '@angular/common' +import { LoginForm } from '../../components/login-form/login-form' +import { SignupForm } from '../../components/signup-form/signup-form' +import { MatTab, MatTabGroup } from '@angular/material/tabs' +import { MatCard } from '@angular/material/card' +import { MatIconButton } from '@angular/material/button' +import { HealthStatus } from '../../components/health-status/health-status' +import { MatBottomSheet } from '@angular/material/bottom-sheet' +import { MatIconModule } from '@angular/material/icon' @Component({ selector: 'app-auth', imports: [ - MatCard, - MatCardTitle, - MatCardContent, MatFormFieldModule, - MatInput, - MatCardActions, - MatButton, - MatCardHeader, FormsModule, - AsyncPipe, + LoginForm, + SignupForm, + MatTabGroup, + MatTab, + MatCard, + MatIconModule, + MatIconButton, ], templateUrl: './auth-page.component.html', styleUrl: './auth-page.component.css', }) export class AuthPage { - authService = inject(AuthService) - router = inject(Router) - destroyRef = inject(DestroyRef) - - formData = model({ - login: '', - password: '', - }) - - authStatus = this.authService.$authStatus - - inputHandler(field: 'login' | 'password', event: Event) { - const { value } = event.target as HTMLInputElement - if (value) { - this.formData.update((prev) => ({ - ...prev, - [field]: value, - })) - } - } + private readonly bottomSheet = inject(MatBottomSheet) - onSubmit() { - this.authService - .login(this.formData()) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((result) => { - if (result) { - this.router.navigate(['/home']) - } - }) + showAppHealth() { + this.bottomSheet.open(HealthStatus) } } diff --git a/src/app/pages/bookmarks-page/bookmarks-page.css b/src/app/pages/bookmarks-page/bookmarks-page.css new file mode 100644 index 0000000..ae579e5 --- /dev/null +++ b/src/app/pages/bookmarks-page/bookmarks-page.css @@ -0,0 +1,20 @@ +:host { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); +} + +#view-toggle { + display: flex; + align-items: center; + justify-content: space-between; +} + +#actions { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; +} diff --git a/src/app/pages/bookmarks-page/bookmarks-page.html b/src/app/pages/bookmarks-page/bookmarks-page.html new file mode 100644 index 0000000..f534b9b --- /dev/null +++ b/src/app/pages/bookmarks-page/bookmarks-page.html @@ -0,0 +1,11 @@ + + + + + + + diff --git a/src/app/pages/bookmarks-page/bookmarks-page.ts b/src/app/pages/bookmarks-page/bookmarks-page.ts new file mode 100644 index 0000000..07c9f9f --- /dev/null +++ b/src/app/pages/bookmarks-page/bookmarks-page.ts @@ -0,0 +1,85 @@ +import { Component, DestroyRef, inject, OnInit, signal } from '@angular/core' +import { Article } from '../../entities/article/article.types' +import { FeedService } from '../../services/feed-service' +import { catchError, combineLatest, of, switchMap } from 'rxjs' +import { HttpErrorResponse } from '@angular/common/http' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { Tag } from '../../entities/tag/tag.types' +import { TagService } from '../../services/tag-service' +import { TitleService } from '../../services/title-service' +import { ArticleList } from '../../components/article-list/article-list' +import { MatToolbarRow } from '@angular/material/toolbar' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { PageDisplayToggle } from '../../components/page-display-toggle/page-display-toggle' + +@Component({ + selector: 'app-bookmarks-page', + imports: [ArticleList, MatToolbarRow, Paginator, PageDisplayToggle], + templateUrl: './bookmarks-page.html', + styleUrl: './bookmarks-page.css', +}) +export class BookmarksPage implements OnInit { + private readonly feedService = inject(FeedService) + private readonly destroyRef = inject(DestroyRef) + private readonly tagService = inject(TagService) + private readonly pageService = inject(PageService) + private readonly titleService = inject(TitleService) + + articles = signal([]) + + favTagId = signal('') + userTags = signal([]) + + ngOnInit() { + this.tagService + .getDefaultTags() + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((tags) => { + const favTag = tags?.find((t) => t.name === 'fav')?._id + if (!favTag) { + return of(null) + } + return combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]).pipe( + switchMap(([perPage, pageNumber]) => { + this.favTagId.set(favTag) + const filters = { tags: favTag } + return this.feedService.getAllArticles({ + pagination: { + perPage, + pageNumber, + }, + filters, + }) + }), + ) + }), + ) + .subscribe((result) => { + if (result) { + this.articles.set(result.result) + this.pageService.setTotalResults(result.total) + this.titleService.setTitle(`Bookmarks: ${result.total}`) + } else { + this.titleService.setTitle('Bookmarks') + } + this.titleService.setSubtitle(null) + }) + + this.tagService + .getAllTags({}) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + ) + .subscribe((tags) => { + if (tags?.result) { + this.userTags.set(tags.result.filter((t) => t.userId !== 'all')) + } + }) + } +} diff --git a/src/app/pages/feeds-page/feeds-page.css b/src/app/pages/feeds-page/feeds-page.css new file mode 100644 index 0000000..b25979e --- /dev/null +++ b/src/app/pages/feeds-page/feeds-page.css @@ -0,0 +1,24 @@ +:host { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 1rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); +} + +:host .mat-mdc-paginator { + background: transparent; +} + +.card { + cursor: pointer; +} + +.link { + font-style: italic; + font-size: smaller; +} + +.description { + color: var(--mat-sys-secondary); +} diff --git a/src/app/pages/feeds-page/feeds-page.html b/src/app/pages/feeds-page/feeds-page.html new file mode 100644 index 0000000..ebf9186 --- /dev/null +++ b/src/app/pages/feeds-page/feeds-page.html @@ -0,0 +1,75 @@ + + + + add + + + + refresh + + + + + + @if (isRefreshingAll()) { + + } @else { + @defer { + @for (f of feeds(); track f._id) { + + + {{ f.title }} + + + delete + + + refresh + + + edit + + + + Link: {{ f.link | linkTrim:15 }} + {{ f.description }} + + library_books + + + + } @empty { + No feeds found + } + } @loading (minimum 0.5s) { + + } + } + + + diff --git a/src/app/pages/feeds-page/feeds-page.ts b/src/app/pages/feeds-page/feeds-page.ts new file mode 100644 index 0000000..7606309 --- /dev/null +++ b/src/app/pages/feeds-page/feeds-page.ts @@ -0,0 +1,182 @@ +import { Component, DestroyRef, effect, inject, OnInit, signal } from '@angular/core' +import { MatCardModule } from '@angular/material/card' +import { FeedService } from '../../services/feed-service' +import { catchError, combineLatest, of, switchMap } from 'rxjs' +import { HttpErrorResponse } from '@angular/common/http' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { MatToolbarModule } from '@angular/material/toolbar' +import { MatIconModule } from '@angular/material/icon' +import { MatIconButton } from '@angular/material/button' +import { RowSpacer } from '../../components/row-spacer/row-spacer' +import { MatDialog, MatDialogModule } from '@angular/material/dialog' +import { Feed } from '../../entities/feed/feed.types' +import { MatProgressBar } from '@angular/material/progress-bar' +import { MatPaginatorModule } from '@angular/material/paginator' +import { LinkTrimPipe } from '../../pipes/link-trim-pipe' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { TitleService } from '../../services/title-service' +import { scrollUp } from '../../../utils' +import { RouterLink } from '@angular/router' +import { MatBadgeModule } from '@angular/material/badge' +import { ConfirmationDialog } from '../../components/confirmation-dialog/confirmation-dialog' +import { FeedAddForm } from '../../components/feed-add-form/feed-add-form' +import { FeedEditForm } from '../../components/feed-edit-form/feed-edit-form' +import { MatBottomSheet } from '@angular/material/bottom-sheet' +import { BottomErrorSheet } from '../../components/bottom-error-sheet/bottom-error-sheet' + +@Component({ + selector: 'app-feed-page', + imports: [ + MatCardModule, + MatToolbarModule, + MatIconModule, + MatIconButton, + RowSpacer, + MatDialogModule, + MatProgressBar, + MatPaginatorModule, + LinkTrimPipe, + Paginator, + RouterLink, + MatBadgeModule, + ], + templateUrl: './feeds-page.html', + styleUrl: './feeds-page.css', +}) +export class FeedsPage implements OnInit { + constructor() { + effect(() => { + scrollUp({ trigger: !!this.feeds().length }) + }) + } + + private readonly feedService = inject(FeedService) + private readonly pageService = inject(PageService) + private readonly dialog = inject(MatDialog) + private readonly destroyRef = inject(DestroyRef) + private readonly titleService = inject(TitleService) + private readonly bottomError = inject(MatBottomSheet) + + readonly feeds = signal([]) + readonly isRefreshing = signal>({}) + readonly isRefreshingAll = signal(false) + + ngOnInit(): void { + combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]) + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(([perPage, pageNumber]) => { + return this.feedService.getAllFeeds({ + pagination: { + perPage, + pageNumber, + }, + }) + }), + ) + .subscribe((result) => { + if (result) { + this.pageService.setTotalResults(result.total) + this.feeds.set(result.result) + } + this.titleService.setTitle('Feeds') + this.titleService.setSubtitle(null) + }) + } + + onAdd(): void { + const dialogRef = this.dialog.open(FeedAddForm) + + dialogRef.afterClosed().subscribe((result) => { + console.log('The dialog was closed', result) + this.pageService.setCurrentPage(1) + }) + } + + onRefreshOne(e: MouseEvent, feedId: string): void { + e.stopPropagation() + this.isRefreshing.update((prev) => ({ + ...prev, + [feedId]: true, + })) + this.feedService + .refreshOneFeed({ feedId }) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + this.isRefreshing.update((prev) => ({ + ...prev, + [feedId]: false, + })) + this.bottomError.open(BottomErrorSheet, { data: { error: e.error.message } }) + console.error(e) + return of(null) + }), + ) + .subscribe(() => { + this.isRefreshing.update((prev) => ({ + ...prev, + [feedId]: false, + })) + }) + } + + onRefreshAll(): void { + this.isRefreshingAll.set(true) + this.feedService + .refreshAllFeeds() + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + this.isRefreshingAll.set(false) + console.error(e) + return of(null) + }), + ) + .subscribe(() => { + this.isRefreshingAll.set(false) + }) + } + + onRemove(e: MouseEvent, id: string): void { + e.stopPropagation() + + const dialogRef = this.dialog.open(ConfirmationDialog, { + data: { + title: 'Delete feed?', + message: 'Are you sure you want to delete this feed?', + confirmButtonText: 'Delete', + }, + }) + + dialogRef.afterClosed().subscribe((agree) => { + if (agree) { + this.feedService + .deleteOneFeed({ feedId: id }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { + this.pageService.setCurrentPage(1) + }) + } + }) + } + + onEdit(e: MouseEvent, feed: Feed): void { + e.stopPropagation() + const dialogRef = this.dialog.open(FeedEditForm, { + data: { feed }, + }) + + dialogRef.afterClosed().subscribe((result) => { + console.log('The dialog was closed', result) + this.pageService.setCurrentPage(1) + }) + } +} diff --git a/src/app/pages/home/home-page.component.html b/src/app/pages/home/home-page.component.html deleted file mode 100644 index e2da575..0000000 --- a/src/app/pages/home/home-page.component.html +++ /dev/null @@ -1,103 +0,0 @@ - - - - view_day - - view_agenda - - - - {{ readFilter() ? 'mark_email_unread' : 'mark_email_read' }} - - bookmark - - - - -@defer { - @for (a of articles(); track a._id) { - - - {{ a.title }} - Published: {{ a.isoDate | date: 'short' }} - - @if (display() !== 'title') { - - - - } - - - link - - - open_in_new - - - {{ a.read ? 'mark_email_read' : 'mark_email_unread' }} - - - bookmark - - - - @for (t of userTags(); track t._id) { - {{ t.name }} - - } - - - - } @empty { - No articles found - } -} @loading (minimum 0.5s) { - -} - - - @if (articles().length > 0) { - - } - - - - check - Skip visible - - - done_all - Skip all - - - diff --git a/src/app/pages/home/home-page.component.ts b/src/app/pages/home/home-page.component.ts deleted file mode 100644 index 7a96830..0000000 --- a/src/app/pages/home/home-page.component.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { FeedService } from '../../services/feed-service' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { HttpErrorResponse } from '@angular/common/http' -import { catchError, of } from 'rxjs' -import { DatePipe } from '@angular/common' -import { MatButton, MatIconButton } from '@angular/material/button' -import { MatProgressBar } from '@angular/material/progress-bar' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatButtonToggleModule } from '@angular/material/button-toggle' -import { MatIconModule } from '@angular/material/icon' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { Router, RouterLink } from '@angular/router' -import { Article } from '../../entities/article/article.types' -import { MatPaginatorModule, PageEvent } from '@angular/material/paginator' -import { TagService } from '../../services/tag-service' -import { Tag } from '../../entities/tag/tag.types' -import { MatChipOption, MatChipSet } from '@angular/material/chips' -import { TitleService } from '../../services/title-service' -import { DomSanitizer, SafeHtml } from '@angular/platform-browser' - -@Component({ - selector: 'app-home', - imports: [ - MatCardModule, - DatePipe, - MatProgressBar, - MatToolbarModule, - MatButtonToggleModule, - MatIconModule, - RowSpacer, - MatIconButton, - RouterLink, - MatPaginatorModule, - MatChipOption, - MatChipSet, - MatButton, - ], - templateUrl: './home-page.component.html', - styleUrl: './home-page.component.css', -}) -export class HomePage implements OnInit { - feedService = inject(FeedService) - router = inject(Router) - destroyRef = inject(DestroyRef) - tagService = inject(TagService) - titleService = inject(TitleService) - htmlSanitizer = inject(DomSanitizer) - - articles = signal([]) - articleIds = computed(() => this.articles().map(({ _id }) => _id)) - display = signal<'title' | 'short'>('title') - - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - readFilter = signal(true) - favFilter = signal(false) - - favTagId = signal('') - userTags = signal([]) - - ngOnInit() { - this.getData() - this.tagService.$defaultTags - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((tags) => { - this.favTagId.set(tags?.find((t) => t.name === 'fav')?._id || '') - }) - this.tagService - .getAllTags({}) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((tags) => { - if (tags?.result) { - this.userTags.set(tags.result.filter((t) => t.userId !== 'all')) - } - }) - } - - getData() { - const filters: Record = {} - - if (this.readFilter()) { - filters['read'] = false - } - - if (this.favFilter()) { - filters['tags'] = this.favTagId() - } - - this.feedService - .getAllArticles({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - filters, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.articles.set(result.result) - this.totalResults.set(result.total) - this.titleService.setTitle(`News: ${result.total} articles`) - } else { - this.titleService.setTitle('News') - } - }) - } - - toggleDisplay(display: 'title' | 'short') { - this.display.set(display) - } - - safeHtml(html: string): SafeHtml { - return this.htmlSanitizer.bypassSecurityTrustHtml(html) - } - - async onArticleClick(article: Article) { - await this.router.navigate(['subscription', article.subscriptionId, 'article', article._id]) - } - - markAsRead(article: Article, event: MouseEvent) { - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { - read: !article.read, - }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, read: !a.read } : a)) - } - return prev - }) - }) - } - } - - markManyAsRead({ - articleIds, - read, - all, - }: { - articleIds?: string[] - read: boolean - all?: boolean - }) { - this.feedService - .changeManyArticles({ - ids: articleIds, - article: { read }, - all, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe(() => { - this.getData() - }) - } - - onAddToBookmarks(article: Article, event: MouseEvent) { - const existingTag = article.tags.find((t) => t === this.favTagId()) - const tags = existingTag - ? [...article.tags].filter((t) => t !== this.favTagId()) - : [...article.tags, this.favTagId()] - - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { tags }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, tags } : a)) - } - return prev - }) - }) - } - } - - paginationHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getData() - } - - filterHandler(filter: 'read' | 'fav') { - if (filter === 'read') { - this.readFilter.update((prev) => !prev) - } else { - this.favFilter.update((prev) => !prev) - } - this.getData() - } - - tagHandler(article: Article, tag: Tag, event: MouseEvent) { - const existingTag = article.tags.find((t) => t === tag._id) - const tags = existingTag - ? [...article.tags].filter((t) => t !== tag._id) - : [...article.tags, tag._id] - - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { tags }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, tags } : a)) - } - return prev - }) - }) - } - } -} diff --git a/src/app/pages/not-found/not-found-page.component.css b/src/app/pages/not-found/not-found-page.component.css index e69de29..a9bc56f 100644 --- a/src/app/pages/not-found/not-found-page.component.css +++ b/src/app/pages/not-found/not-found-page.component.css @@ -0,0 +1,13 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 1rem +} + +.emoji { + font-size: 20cqw; + font-weight: bold; + color: var(--mat-sys-secondary); +} diff --git a/src/app/pages/not-found/not-found-page.component.html b/src/app/pages/not-found/not-found-page.component.html index 8071020..5d3bd69 100644 --- a/src/app/pages/not-found/not-found-page.component.html +++ b/src/app/pages/not-found/not-found-page.component.html @@ -1 +1,2 @@ -not-found works! +Page not found +\(o_o)/ diff --git a/src/app/pages/status-page/status-page.css b/src/app/pages/status-page/status-page.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/status-page/status-page.html b/src/app/pages/status-page/status-page.html new file mode 100644 index 0000000..d7ddc81 --- /dev/null +++ b/src/app/pages/status-page/status-page.html @@ -0,0 +1 @@ + diff --git a/src/app/pages/status-page/status-page.ts b/src/app/pages/status-page/status-page.ts new file mode 100644 index 0000000..851f3fe --- /dev/null +++ b/src/app/pages/status-page/status-page.ts @@ -0,0 +1,18 @@ +import { Component, inject, OnInit } from '@angular/core' +import { HealthStatus } from '../../components/health-status/health-status' +import { TitleService } from '../../services/title-service' + +@Component({ + selector: 'app-status-page', + imports: [HealthStatus], + templateUrl: './status-page.html', + styleUrl: './status-page.css', +}) +export class StatusPage implements OnInit { + private readonly titleService = inject(TitleService) + + ngOnInit() { + this.titleService.setTitle('User') + this.titleService.setSubtitle(null) + } +} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.css b/src/app/pages/subscriptions/subscriptions-page.component.css deleted file mode 100644 index 67cb9da..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.css +++ /dev/null @@ -1,9 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 1rem; -} - -:host .mat-mdc-paginator { - background: transparent; -} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.html b/src/app/pages/subscriptions/subscriptions-page.component.html deleted file mode 100644 index 10aabda..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - add - - - @if (feeds().length > 0) { - - } - - -@defer { - @for (f of feeds(); track f._id) { - - - {{ f.title }} - - - delete - - - - Link: {{ f.link }} - Articles: {{ f.articles.length }} - - - } @empty { - No subscriptions found - } -} @loading (minimum 0.5s) { - -} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.ts b/src/app/pages/subscriptions/subscriptions-page.component.ts deleted file mode 100644 index 720d0ed..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Component, DestroyRef, inject, OnInit, signal, viewChild } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { FeedService } from '../../services/feed-service' -import { catchError, of } from 'rxjs' -import { HttpErrorResponse } from '@angular/common/http' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatIconModule } from '@angular/material/icon' -import { MatIconButton } from '@angular/material/button' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { MatDialog, MatDialogModule } from '@angular/material/dialog' -import { SubscriptionAddForm } from '../../components/subscription-add-form/subscription-add-form' -import { Feed } from '../../entities/feed/feed.types' -import { MatProgressBar } from '@angular/material/progress-bar' -import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator' - -@Component({ - selector: 'app-subscriptions', - imports: [ - MatCardModule, - MatToolbarModule, - MatIconModule, - MatIconButton, - RowSpacer, - MatDialogModule, - MatProgressBar, - MatPaginatorModule, - ], - templateUrl: './subscriptions-page.component.html', - styleUrl: './subscriptions-page.component.css', -}) -export class SubscriptionsPage implements OnInit { - feedService = inject(FeedService) - readonly dialog = inject(MatDialog) - destroyRef = inject(DestroyRef) - - feeds = signal([]) - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - ngOnInit() { - this.getData() - } - - getData() { - this.feedService - .getAllSubscriptions({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.currentPage.set(1) - this.feeds.set(result.result) - this.totalResults.set(result.total) - } - }) - } - - onAdd() { - const dialogRef = this.dialog.open(SubscriptionAddForm) - - dialogRef.afterClosed().subscribe((result) => { - if (result) { - this.getData() - } - }) - } - - paginator = viewChild(MatPaginator) - - onRemove(id: string) { - this.feedService - .deleteOneSubscription({ subscriptionId: id }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - this.getData() - this.paginator()?.firstPage() - }) - } - - paginationHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getData() - } -} diff --git a/src/app/pages/tags-page/tags-page.css b/src/app/pages/tags-page/tags-page.css new file mode 100644 index 0000000..31b5e77 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.css @@ -0,0 +1,11 @@ +:host { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 1rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); +} + +input { + margin-top: 2rem; +} diff --git a/src/app/pages/tags-page/tags-page.html b/src/app/pages/tags-page/tags-page.html new file mode 100644 index 0000000..50596d6 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.html @@ -0,0 +1,47 @@ + + @defer { + User tags + + + @for (t of userTags(); track t._id) { + + + edit + + {{ t.name }} + + cancel + + + } + + + + + App tags + + @for (t of appTags(); track t._id) { + + {{ t.name }} + + } + + + + } + + + diff --git a/src/app/pages/tags-page/tags-page.ts b/src/app/pages/tags-page/tags-page.ts new file mode 100644 index 0000000..c376202 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.ts @@ -0,0 +1,153 @@ +import { Component, DestroyRef, inject, linkedSignal, OnInit, signal } from '@angular/core' +import { TagService } from '../../services/tag-service' +import { Tag } from '../../entities/tag/tag.types' +import { HttpErrorResponse } from '@angular/common/http' +import { catchError, combineLatest, of, switchMap } from 'rxjs' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { MatIconModule } from '@angular/material/icon' +import { MatChipEditedEvent, MatChipInputEvent, MatChipsModule } from '@angular/material/chips' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { TitleService } from '../../services/title-service' +import { Router } from '@angular/router' +import { MatFormFieldModule } from '@angular/material/form-field' +import { COMMA, ENTER } from '@angular/cdk/keycodes' +import { MatBottomSheet } from '@angular/material/bottom-sheet' +import { BottomErrorSheet } from '../../components/bottom-error-sheet/bottom-error-sheet' +import { MatDialog } from '@angular/material/dialog' +import { ConfirmationDialog } from '../../components/confirmation-dialog/confirmation-dialog' + +@Component({ + selector: 'app-tags-page', + imports: [MatIconModule, Paginator, MatFormFieldModule, MatChipsModule], + templateUrl: './tags-page.html', + styleUrl: './tags-page.css', +}) +export class TagsPage implements OnInit { + private readonly tagsService = inject(TagService) + private readonly pageService = inject(PageService) + private readonly destroyRef = inject(DestroyRef) + private readonly titleService = inject(TitleService) + private readonly router = inject(Router) + private readonly errorSheet = inject(MatBottomSheet) + private readonly dialog = inject(MatDialog) + + public readonly separatorKeysCodes = [ENTER, COMMA] as const + + private readonly tags = signal([]) + readonly userTags = linkedSignal(() => { + return this.tags().filter((t) => t.userId !== 'all') + }) + readonly appTags = linkedSignal(() => { + return this.tags().filter((t) => t.userId === 'all') + }) + + ngOnInit() { + combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]) + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(([perPage, pageNumber]) => { + return this.tagsService.getAllTags({ + pagination: { + perPage, + pageNumber, + }, + }) + }), + ) + .subscribe((result) => { + if (result) { + this.pageService.setTotalResults(result.total) + this.tags.set(result.result) + this.titleService.setTitle('Tags') + this.titleService.setSubtitle(null) + } + }) + } + + onAdd(e: MatChipInputEvent) { + const name = (e.value || '').trim() + + if (!name) { + return + } + + this.tagsService + .addOneTag({ name }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + this.errorSheet.open(BottomErrorSheet, { data: { error: error.error.message } }) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + e.chipInput.clear() + } + }) + } + + onEdit(tag: Tag, e: MatChipEditedEvent) { + const newName = (e.value || '').trim() + const currentName = tag.name + + if (!newName) { + return + } + + this.tagsService + .changeOneTag({ newName, currentName }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + this.errorSheet.open(BottomErrorSheet, { data: { error: error.error.message } }) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + } + }) + } + + onRemove(tag: Tag) { + const dialogRef = this.dialog.open(ConfirmationDialog, { + data: { + title: 'Delete tag', + message: `Are you sure you want to delete the tag "${tag.name}"?`, + confirmButtonText: 'Delete', + }, + }) + + dialogRef.afterClosed().subscribe((agree) => { + if (agree) { + this.tagsService + .deleteOneTag({ name: tag.name }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + } + }) + } + }) + } + + onClick(name: string) { + if (!name) { + return + } + this.router.navigate(['/articles'], { queryParams: { tag: name } }) + } +} diff --git a/src/app/pages/tags/tags-page.component.css b/src/app/pages/tags/tags-page.component.css deleted file mode 100644 index 1556b2c..0000000 --- a/src/app/pages/tags/tags-page.component.css +++ /dev/null @@ -1,5 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 1rem; -} diff --git a/src/app/pages/tags/tags-page.component.html b/src/app/pages/tags/tags-page.component.html deleted file mode 100644 index 1e851b0..0000000 --- a/src/app/pages/tags/tags-page.component.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - add - - - @if (tags().length > 0) { - - } - - -@defer { - - @for (t of tags(); track t._id) { - - {{ t.name }} - @if (t.userId !== 'all') { - - cancel - - } - - } - -} diff --git a/src/app/pages/tags/tags-page.component.ts b/src/app/pages/tags/tags-page.component.ts deleted file mode 100644 index d6866e4..0000000 --- a/src/app/pages/tags/tags-page.component.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Component, DestroyRef, inject, OnInit, signal, viewChild } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { TagService } from '../../services/tag-service' -import { Tag } from '../../entities/tag/tag.types' -import { HttpErrorResponse } from '@angular/common/http' -import { catchError, of } from 'rxjs' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { MatPaginator, PageEvent } from '@angular/material/paginator' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatIconButton } from '@angular/material/button' -import { MatIconModule } from '@angular/material/icon' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { MatDialog } from '@angular/material/dialog' -import { TagAddForm } from '../../components/tag-add-form/tag-add-form' -import { MatChipRemove, MatChipRow, MatChipSet } from '@angular/material/chips' - -@Component({ - selector: 'app-tags', - imports: [ - MatCardModule, - MatToolbarModule, - MatIconButton, - MatIconModule, - RowSpacer, - MatPaginator, - MatChipRow, - MatChipRemove, - MatChipSet, - ], - templateUrl: './tags-page.component.html', - styleUrl: './tags-page.component.css', -}) -export class TagsPage implements OnInit { - tagsService = inject(TagService) - destroyRef = inject(DestroyRef) - readonly dialog = inject(MatDialog) - - tags = signal([]) - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - getDate() { - this.tagsService - .getAllTags({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.error(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.currentPage.set(1) - this.tags.set(result.result) - this.totalResults.set(result.total) - } - }) - } - - ngOnInit() { - this.getDate() - } - - paginator = viewChild(MatPaginator) - - onAdd() { - const dialogRef = this.dialog.open(TagAddForm) - - dialogRef.afterClosed().subscribe((result) => { - if (result) { - this.getDate() - } - }) - } - - onRemove(tag: Tag) { - this.tagsService - .deleteOneTag({ name: tag.name }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.error(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe(() => { - this.getDate() - this.paginator()?.firstPage() - }) - } - - paginatorHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getDate() - } -} diff --git a/src/app/pages/user-page/user-page.css b/src/app/pages/user-page/user-page.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/user-page/user-page.html b/src/app/pages/user-page/user-page.html new file mode 100644 index 0000000..49a288a --- /dev/null +++ b/src/app/pages/user-page/user-page.html @@ -0,0 +1,8 @@ + + + Username: {{ ($currentUser | async)?.user?.login }} + + + Role: {{ ($currentUser | async)?.user?.role }} + + diff --git a/src/app/pages/user-page/user-page.ts b/src/app/pages/user-page/user-page.ts new file mode 100644 index 0000000..5aa3f70 --- /dev/null +++ b/src/app/pages/user-page/user-page.ts @@ -0,0 +1,22 @@ +import { Component, inject, OnInit } from '@angular/core' +import { AuthService } from '../../services/auth-service' +import { AsyncPipe } from '@angular/common' +import { TitleService } from '../../services/title-service' + +@Component({ + selector: 'app-user-page', + imports: [AsyncPipe], + templateUrl: './user-page.html', + styleUrl: './user-page.css', +}) +export class UserPage implements OnInit { + private readonly authService = inject(AuthService) + private readonly titleService = inject(TitleService) + + $currentUser = this.authService.$authStatus + + ngOnInit() { + this.titleService.setTitle('User') + this.titleService.setSubtitle(null) + } +} diff --git a/src/app/pipes/link-trim-pipe.ts b/src/app/pipes/link-trim-pipe.ts new file mode 100644 index 0000000..45aeb2b --- /dev/null +++ b/src/app/pipes/link-trim-pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core' + +@Pipe({ + name: 'linkTrim', +}) +export class LinkTrimPipe implements PipeTransform { + transform(value: string, length: number = Infinity): string { + try { + const parsed = URL.parse(value) + if (!parsed) { + throw new Error('Invalid URL') + } + const { host, pathname } = parsed + return `${host}${pathname.slice(0, length) + '...'}` + } catch (e) { + console.error(e) + return value.slice(0, length) + '...' + } + } +} diff --git a/src/app/pipes/safe-html-pipe.ts b/src/app/pipes/safe-html-pipe.ts new file mode 100644 index 0000000..51f0a54 --- /dev/null +++ b/src/app/pipes/safe-html-pipe.ts @@ -0,0 +1,16 @@ +import { inject, Pipe, PipeTransform } from '@angular/core' +import { DomSanitizer, SafeHtml } from '@angular/platform-browser' + +@Pipe({ + name: 'safeHtml', +}) +export class SafeHtmlPipe implements PipeTransform { + sanitizer = inject(DomSanitizer) + + transform(value: string): SafeHtml { + if (!value) { + return '' + } + return this.sanitizer.bypassSecurityTrustHtml(value) + } +} diff --git a/src/app/services/auth-service.ts b/src/app/services/auth-service.ts index 43d1908..1f18117 100644 --- a/src/app/services/auth-service.ts +++ b/src/app/services/auth-service.ts @@ -23,15 +23,58 @@ export class AuthService { $authStatus = this.$$authStatus.asObservable() login({ login, password }: UserDTO) { - return this.httpClient.post(`${environment.api}/auth/login`, { login, password }).pipe( - switchMap((response) => { - this.$$authStatus.next({ authenticated: true, user: response, error: null }) - return of(response) - }), + return this.httpClient + .post(`${environment.api}/auth/login`, { + login: login.trim(), + password: password.trim(), + }) + .pipe( + switchMap((response) => { + localStorage.setItem('user', response._id) + this.$$authStatus.next({ authenticated: true, user: response, error: null }) + return of(response) + }), + catchError((error: HttpErrorResponse) => { + this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) + return of(null) + }), + ) + } + + signup({ password }: { password: string }) { + return this.httpClient + .post(`${environment.api}/auth/signup`, { + password: password.trim(), + }) + .pipe( + switchMap((response) => { + localStorage.setItem('user', response._id) + this.$$authStatus.next({ authenticated: true, user: response, error: null }) + return of(response) + }), + catchError((error: HttpErrorResponse) => { + this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) + return of(null) + }), + ) + } + + logout() { + return this.httpClient.get<{ message: string }>(`${environment.api}/auth/logout`).pipe( catchError((error: HttpErrorResponse) => { - this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) - return of(null) + console.error(error) + return of(false) + }), + switchMap(() => { + localStorage.removeItem('user') + this.$$authStatus.next({ authenticated: false, user: null, error: null }) + return of(true) }), ) } + + updateAuth(user: User) { + this.$$authStatus.next({ authenticated: true, user, error: null }) + localStorage.setItem('user', user._id) + } } diff --git a/src/app/services/feed-service.ts b/src/app/services/feed-service.ts index eb563be..9b3aae7 100644 --- a/src/app/services/feed-service.ts +++ b/src/app/services/feed-service.ts @@ -4,17 +4,16 @@ import { environment } from '../../environments/environment' import { Feed, FeedDTO } from '../entities/feed/feed.types' import { Article, ArticleDTO } from '../entities/article/article.types' import { Paginated, Pagination } from '../entities/base/base.types' -import { TagService } from './tag-service' +import { SortOrder } from '../entities/base/base.enums' @Injectable({ providedIn: 'root', }) export class FeedService { - httpClient = inject(HttpClient) - tagService = inject(TagService) + readonly httpClient = inject(HttpClient) - getAllSubscriptions({ pagination }: { pagination?: Partial }) { - return this.httpClient.get>(`${environment.api}/subscription`, { + getAllFeeds({ pagination }: { pagination?: Partial }) { + return this.httpClient.get>(`${environment.api}/feed`, { params: pagination, }) } @@ -22,18 +21,27 @@ export class FeedService { getAllArticles({ pagination, filters, + sort, }: { pagination?: Partial filters?: { tags?: string; read?: boolean } + sort?: { date: SortOrder } }) { - this.tagService.getDefaultTags() return this.httpClient.get>(`${environment.api}/article`, { - params: { ...pagination, ...filters }, + params: { + ...pagination, + ...filters, + dateSort: sort?.date || SortOrder.Desc, + }, }) } - getOneSubscription({ subscriptionId }: { subscriptionId: string }) { - return this.httpClient.get(`${environment.api}/subscription/${subscriptionId}`) + getOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.get(`${environment.api}/feed/${feedId}`) + } + + changeOneFeed({ id, dto }: { id: string; dto: Partial }) { + return this.httpClient.patch(`${environment.api}/feed/${id}`, dto) } getOneArticle({ articleId }: { articleId: string }) { @@ -63,11 +71,23 @@ export class FeedService { }) } - addOneSubscription({ subscription }: { subscription: FeedDTO }) { - return this.httpClient.post(`${environment.api}/subscription`, subscription) + addOneFeed({ feed }: { feed: FeedDTO }) { + return this.httpClient.post(`${environment.api}/feed`, feed) + } + + deleteOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.delete(`${environment.api}/feed/${feedId}`) + } + + refreshAllFeeds() { + return this.httpClient.get(`${environment.api}/feed/refresh`) + } + + refreshOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.get(`${environment.api}/feed/${feedId}/refresh`) } - deleteOneSubscription({ subscriptionId }: { subscriptionId: string }) { - return this.httpClient.delete(`${environment.api}/subscription/${subscriptionId}`) + getFullText({ articleId }: { articleId: string }) { + return this.httpClient.get<{ fullText: string }>(`${environment.api}/article/${articleId}/full`) } } diff --git a/src/app/services/health-service.ts b/src/app/services/health-service.ts new file mode 100644 index 0000000..f50b578 --- /dev/null +++ b/src/app/services/health-service.ts @@ -0,0 +1,17 @@ +import { inject, Injectable } from '@angular/core' +import { HttpClient } from '@angular/common/http' +import { environment } from '../../environments/environment' + +@Injectable({ + providedIn: 'root', +}) +export class HealthService { + constructor() {} + httpClient = inject(HttpClient) + + getBackendStatus() { + return this.httpClient.get<{ status: string; version: string; uptime: string }>( + `${environment.api}/health`, + ) + } +} diff --git a/src/app/services/page-service.ts b/src/app/services/page-service.ts new file mode 100644 index 0000000..516dbc4 --- /dev/null +++ b/src/app/services/page-service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core' +import { PageDisplay } from '../entities/page/page.enums' +import { BehaviorSubject } from 'rxjs' + +@Injectable({ + providedIn: 'any', +}) +export class PageService { + private $$pageSize = new BehaviorSubject(5) + $pageSize = this.$$pageSize.asObservable() + + private $$currentPage = new BehaviorSubject(1) + $currentPage = this.$$currentPage.asObservable() + + private $$totalResults = new BehaviorSubject(0) + $totalResults = this.$$totalResults.asObservable() + + private $$display = new BehaviorSubject(PageDisplay.Title) + $display = this.$$display.asObservable() + + setCurrentPage(currentPage: number) { + this.$$currentPage.next(currentPage) + } + + setPageSize(pageSize: number) { + this.$$pageSize.next(pageSize) + } + + setTotalResults(totalResults: number) { + this.$$totalResults.next(totalResults) + } + + setDisplay(display: PageDisplay) { + this.$$display.next(display) + } +} diff --git a/src/app/services/tag-service.ts b/src/app/services/tag-service.ts index bc9f128..810f379 100644 --- a/src/app/services/tag-service.ts +++ b/src/app/services/tag-service.ts @@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http' import { Tag } from '../entities/tag/tag.types' import { environment } from '../../environments/environment' import { Paginated, Pagination } from '../entities/base/base.types' -import { BehaviorSubject } from 'rxjs' +import { BehaviorSubject, tap } from 'rxjs' @Injectable({ providedIn: 'root', @@ -18,9 +18,11 @@ export class TagService { $defaultTags = this.$$defaultTags.asObservable() getDefaultTags() { - return this.httpClient.get(`${environment.api}/tag?default=true`).subscribe((tags) => { - this.$$defaultTags.next(tags) - }) + return this.httpClient.get(`${environment.api}/tag?default=true`).pipe( + tap((tags) => { + this.$$defaultTags.next(tags) + }), + ) } getAllTags({ pagination }: { pagination?: Partial }) { @@ -29,6 +31,10 @@ export class TagService { }) } + getOne({ name }: { name: Tag['_id'] }) { + return this.httpClient.get(`${environment.api}/tag/${name}`) + } + addOneTag({ name }: { name: string }) { return this.httpClient.post(`${environment.api}/tag`, { name }) } diff --git a/src/app/services/title-service.ts b/src/app/services/title-service.ts index 8600424..701c846 100644 --- a/src/app/services/title-service.ts +++ b/src/app/services/title-service.ts @@ -11,8 +11,15 @@ export class TitleService { private $$currentTitle = new BehaviorSubject('') $currentTitle = this.$$currentTitle.asObservable() + private $$currentSubtitle = new BehaviorSubject(null) + $currentSubtitle = this.$$currentSubtitle.asObservable() + setTitle(title: string) { this.$$currentTitle.next(title) this.pageTitleService.setTitle(title) } + + setSubtitle(subtitle: string | null) { + this.$$currentSubtitle.next(subtitle) + } } diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 05fac53..3402093 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,5 +1,5 @@ import { Environment } from './environment.types' export const environment: Environment = { - api: 'http://rss-nest:3600/api', + api: 'api', } diff --git a/src/index.html b/src/index.html index 78c77ef..fedac66 100644 --- a/src/index.html +++ b/src/index.html @@ -1,18 +1,28 @@ - - - News - - - - - - - - - + + + News + + + + + + + + + diff --git a/src/styles.css b/src/styles.css index c784d4f..0609480 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,4 +1,5 @@ -/* You can add global styles to this file, and also import other style files */ +@import "@angular/material/prebuilt-themes/cyan-orange.css" (prefers-color-scheme: dark); +@import "@angular/material/prebuilt-themes/azure-blue.css" (prefers-color-scheme: light); html, body { @@ -20,3 +21,29 @@ body { mat-toolbar-row { height: auto !important; } + +.external { + display: flex; + flex-direction: column; +} + +.content_layout { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.content_layout img { + max-height: 20ch; + max-width: 70cqw; + align-self: center; + border-radius: 1rem; +} + +.content_layout a { + color: inherit; +} + +.highlighted { + color: var(--mat-sys-primary) !important; +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..f723ed7 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export { scrollUp } from './scrollUp' diff --git a/src/utils/scrollUp.ts b/src/utils/scrollUp.ts new file mode 100644 index 0000000..2d43ce8 --- /dev/null +++ b/src/utils/scrollUp.ts @@ -0,0 +1,7 @@ +export function scrollUp({ trigger }: { trigger: boolean }) { + if (!trigger) { + return + } + const page = document.querySelector('.page-content') + page?.scroll({ top: 0, behavior: 'smooth' }) +}
(null) + readonly fullText = signal(undefined) + readonly favTagId = signal('') + readonly readStatus = computed(() => { return !!this.article()?.read }) + readonly isLoading = signal(false) safeHtml(html?: string): SafeHtml | undefined { if (!html) { @@ -56,7 +63,7 @@ export class ArticlePage implements OnInit { return this.domSanitizer.bypassSecurityTrustHtml(html) } - ngOnInit() { + ngOnInit(): void { this.route.params .pipe( exhaustMap((params) => { @@ -78,21 +85,26 @@ export class ArticlePage implements OnInit { }), takeUntilDestroyed(this.destroyRef), catchError((error: HttpErrorResponse) => { - console.log(error) + console.error(error) return of(null) }), ) .subscribe((result) => { this.article.set(result) + if (result?.fullText) { + const parsed = this.safeHtml(result.fullText || '') + this.fullText.set(parsed) + } this.titleService.setTitle(result?.title || '') + this.titleService.setSubtitle(null) }) - this.tagService.$defaultTags.subscribe((tags) => { + this.tagService.$defaultTags.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((tags) => { this.favTagId.set(tags.find((t) => t.name === 'fav')?._id || '') }) } - onMarkAsRead() { + onMarkAsRead(): void { if (this.article() !== null) { this.feedService .changeOneArticle({ @@ -104,7 +116,7 @@ export class ArticlePage implements OnInit { .pipe( takeUntilDestroyed(this.destroyRef), catchError((error: HttpErrorResponse) => { - console.log(error) + console.error(error) return of(null) }), ) @@ -119,7 +131,7 @@ export class ArticlePage implements OnInit { } } - onAddToBookmarks() { + onAddToBookmarks(): void { const existingTag = this.article()?.tags.find((t) => t === this.favTagId()) const tags = existingTag ? [...(this.article()?.tags || [])].filter((t) => t !== this.favTagId()) @@ -134,7 +146,7 @@ export class ArticlePage implements OnInit { .pipe( takeUntilDestroyed(this.destroyRef), catchError((error: HttpErrorResponse) => { - console.log(error) + console.error(error) return of(null) }), ) @@ -148,4 +160,27 @@ export class ArticlePage implements OnInit { }) } } + + getFullText(): void { + const articleId = this.article()?._id + if (!articleId) { + return + } + this.isLoading.set(true) + this.feedService + .getFullText({ articleId }) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + console.error(e) + this.isLoading.set(false) + return of(null) + }), + ) + .subscribe((result) => { + const parsed = this.safeHtml(result?.fullText || '') + this.fullText.set(parsed) + this.isLoading.set(false) + }) + } } diff --git a/src/app/pages/home/home-page.component.css b/src/app/pages/articles-page/articles-page.css similarity index 53% rename from src/app/pages/home/home-page.component.css rename to src/app/pages/articles-page/articles-page.css index bc1230e..ae579e5 100644 --- a/src/app/pages/home/home-page.component.css +++ b/src/app/pages/articles-page/articles-page.css @@ -2,7 +2,8 @@ display: flex; flex-direction: column; gap: 1rem; - padding: 0.5rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); } #view-toggle { @@ -11,23 +12,9 @@ justify-content: space-between; } -#paginator, #actions { display: flex; align-items: center; justify-content: center; gap: 1rem; } - -.card { - cursor: pointer; -} - -.card:hover { - box-shadow: 0 0 0.2rem var(--mat-sys-primary); - transition: box-shadow 0.3s ease-in-out; -} - -.highlighted { - color: var(--mat-sys-primary); -} diff --git a/src/app/pages/articles-page/articles-page.html b/src/app/pages/articles-page/articles-page.html new file mode 100644 index 0000000..c681b17 --- /dev/null +++ b/src/app/pages/articles-page/articles-page.html @@ -0,0 +1,73 @@ + + + + + refresh + + + + + + schedule + + + + {{ ($readFilter | async) ? 'mark_email_unread' : 'mark_email_read' }} + + + + bookmark + + + + + + + + +@if (articles().length) { + + + + check + Skip visible + + + done_all + Skip all + + +} diff --git a/src/app/pages/articles-page/articles-page.ts b/src/app/pages/articles-page/articles-page.ts new file mode 100644 index 0000000..1b8e97a --- /dev/null +++ b/src/app/pages/articles-page/articles-page.ts @@ -0,0 +1,252 @@ +import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core' +import { MatCardModule } from '@angular/material/card' +import { FeedService } from '../../services/feed-service' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { HttpErrorResponse } from '@angular/common/http' +import { BehaviorSubject, catchError, combineLatest, forkJoin, of, switchMap } from 'rxjs' +import { MatButton, MatIconButton } from '@angular/material/button' +import { MatToolbarModule } from '@angular/material/toolbar' +import { MatButtonToggleModule } from '@angular/material/button-toggle' +import { MatIconModule } from '@angular/material/icon' +import { RowSpacer } from '../../components/row-spacer/row-spacer' +import { ActivatedRoute } from '@angular/router' +import { Article } from '../../entities/article/article.types' +import { MatPaginatorModule } from '@angular/material/paginator' +import { TagService } from '../../services/tag-service' +import { Tag } from '../../entities/tag/tag.types' +import { TitleService } from '../../services/title-service' +import { ArticleList } from '../../components/article-list/article-list' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { PageDisplayToggle } from '../../components/page-display-toggle/page-display-toggle' +import { AsyncPipe } from '@angular/common' +import { SortOrder } from '../../entities/base/base.enums' + +@Component({ + selector: 'app-articles-page', + imports: [ + MatCardModule, + MatToolbarModule, + MatButtonToggleModule, + MatIconModule, + RowSpacer, + MatIconButton, + MatPaginatorModule, + MatButton, + ArticleList, + Paginator, + PageDisplayToggle, + AsyncPipe, + ], + templateUrl: './articles-page.html', + styleUrl: './articles-page.css', +}) +export class ArticlesPage implements OnInit { + private readonly feedService = inject(FeedService) + private readonly route = inject(ActivatedRoute) + private readonly destroyRef = inject(DestroyRef) + private readonly tagService = inject(TagService) + private readonly titleService = inject(TitleService) + private readonly pageService = inject(PageService) + + readonly articles = signal([]) + readonly articleIds = computed(() => this.articles().map(({ _id }) => _id)) + readonly favTagId = signal('') + readonly userTags = signal([]) + readonly isRefreshingAll = signal(false) + + $readFilter = new BehaviorSubject(true) + $favFilter = new BehaviorSubject(false) + $feedFilter = new BehaviorSubject(null) + $tagFilter = new BehaviorSubject(null) + $dateOrder = new BehaviorSubject(SortOrder.Desc) + + ngOnInit() { + this.tagService + .getAllTags({}) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + ) + .subscribe((tags) => { + if (tags?.result) { + this.userTags.set(tags.result.filter((t) => t.userId !== 'all')) + } + }) + + this.tagService + .getDefaultTags() + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((tags) => { + const favTag = tags?.find((t) => t.name === 'fav')?._id + if (!favTag) { + return of(null) + } + this.favTagId.set(favTag) + + return combineLatest([ + this.pageService.$pageSize, + this.pageService.$currentPage, + this.$favFilter, + this.$readFilter, + this.$feedFilter, + this.$tagFilter, + this.$dateOrder, + ]).pipe( + switchMap(([perPage, pageNumber, fav, read, feed, tag, dateSort]) => { + const filters: Record = {} + + if (read) { + filters['read'] = false + } + + if (fav) { + filters['tags'] = favTag + } + + if (feed) { + filters['feed'] = feed + } + + if (tag) { + filters['tags'] = tag + } + + return this.feedService.getAllArticles({ + pagination: { + perPage, + pageNumber, + }, + filters, + sort: { + date: dateSort, + }, + }) + }), + ) + }), + ) + .subscribe((result) => { + if (result) { + this.articles.set(result.result) + this.pageService.setTotalResults(result.total) + this.titleService.setTitle(`Articles: ${result.total} articles`) + } else { + this.titleService.setTitle('Articles') + } + }) + + this.route.queryParams + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((params) => { + const feedId: string = params['feed'] + const tagName: string = params['tag'] + if (tagName) { + return forkJoin([of(null), this.tagService.getOne({ name: tagName })]) + } + + if (feedId) { + return forkJoin([this.feedService.getOneFeed({ feedId }), of(null)]) + } + + return forkJoin([of(null), of(null)]) + }), + catchError((e) => { + console.error(e) + return of(null) + }), + ) + .subscribe((results) => { + if (!results) { + return + } + + const [feed, tag] = results + + if (feed) { + this.titleService.setSubtitle(feed.title) + this.$feedFilter.next(feed._id) + this.$tagFilter.next(null) + } else if (tag) { + this.titleService.setSubtitle(tag.name) + this.$feedFilter.next(null) + this.$tagFilter.next(tag._id) + } else { + this.titleService.setSubtitle(null) + this.$feedFilter.next(null) + this.$tagFilter.next(null) + } + }) + } + + markManyAsRead({ + articleIds, + read, + all, + }: { + articleIds?: string[] + read: boolean + all?: boolean + }) { + this.feedService + .changeManyArticles({ + ids: articleIds, + article: { read }, + all, + }) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + ) + .subscribe((result) => { + if (!result?.modifiedCount) { + return + } + this.pageService.setCurrentPage(1) + }) + } + + filterHandler(filter: 'read' | 'fav') { + if (filter === 'read') { + this.$readFilter.next(!this.$readFilter.value) + } else { + this.$favFilter.next(!this.$favFilter.value) + } + this.pageService.setCurrentPage(1) + } + + orderHandler(param: 'date') { + if (param === 'date') { + this.$dateOrder.next(this.$dateOrder.value === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc) + this.pageService.setCurrentPage(1) + } + } + + onRefreshAll() { + this.isRefreshingAll.set(true) + this.feedService + .refreshAllFeeds() + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + this.isRefreshingAll.set(false) + console.error(e) + return of(null) + }), + ) + .subscribe(() => { + this.isRefreshingAll.set(false) + this.pageService.setCurrentPage(1) + }) + } + + protected readonly SortOrder = SortOrder +} diff --git a/src/app/pages/auth-page/auth-page.component.css b/src/app/pages/auth-page/auth-page.component.css index 6de3848..a37ef5f 100644 --- a/src/app/pages/auth-page/auth-page.component.css +++ b/src/app/pages/auth-page/auth-page.component.css @@ -4,11 +4,5 @@ justify-content: center; align-items: center; padding: 2rem; -} - -.credentials { - display: flex; - flex-direction: column; gap: 1rem; - margin: 1rem 0; } diff --git a/src/app/pages/auth-page/auth-page.component.html b/src/app/pages/auth-page/auth-page.component.html index 42fa334..bc4a51d 100644 --- a/src/app/pages/auth-page/auth-page.component.html +++ b/src/app/pages/auth-page/auth-page.component.html @@ -1,47 +1,18 @@ - - - Login - - - - - Login - - - - Password - - - - @let status = (authStatus | async); - @if (status?.error) { - {{ status?.error }} - } - - - Login - - - - + + + + + + + + + + + + memory + diff --git a/src/app/pages/auth-page/auth-page.component.ts b/src/app/pages/auth-page/auth-page.component.ts index 40bf015..b73dca9 100644 --- a/src/app/pages/auth-page/auth-page.component.ts +++ b/src/app/pages/auth-page/auth-page.component.ts @@ -1,67 +1,35 @@ -import { Component, DestroyRef, inject, model } from '@angular/core' -import { - MatCard, - MatCardActions, - MatCardContent, - MatCardHeader, - MatCardTitle, -} from '@angular/material/card' -import { MatInput } from '@angular/material/input' +import { Component, inject } from '@angular/core' import { MatFormFieldModule } from '@angular/material/form-field' -import { MatButton } from '@angular/material/button' import { FormsModule } from '@angular/forms' -import { AuthService } from '../../services/auth-service' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { Router } from '@angular/router' -import { AsyncPipe } from '@angular/common' +import { LoginForm } from '../../components/login-form/login-form' +import { SignupForm } from '../../components/signup-form/signup-form' +import { MatTab, MatTabGroup } from '@angular/material/tabs' +import { MatCard } from '@angular/material/card' +import { MatIconButton } from '@angular/material/button' +import { HealthStatus } from '../../components/health-status/health-status' +import { MatBottomSheet } from '@angular/material/bottom-sheet' +import { MatIconModule } from '@angular/material/icon' @Component({ selector: 'app-auth', imports: [ - MatCard, - MatCardTitle, - MatCardContent, MatFormFieldModule, - MatInput, - MatCardActions, - MatButton, - MatCardHeader, FormsModule, - AsyncPipe, + LoginForm, + SignupForm, + MatTabGroup, + MatTab, + MatCard, + MatIconModule, + MatIconButton, ], templateUrl: './auth-page.component.html', styleUrl: './auth-page.component.css', }) export class AuthPage { - authService = inject(AuthService) - router = inject(Router) - destroyRef = inject(DestroyRef) - - formData = model({ - login: '', - password: '', - }) - - authStatus = this.authService.$authStatus - - inputHandler(field: 'login' | 'password', event: Event) { - const { value } = event.target as HTMLInputElement - if (value) { - this.formData.update((prev) => ({ - ...prev, - [field]: value, - })) - } - } + private readonly bottomSheet = inject(MatBottomSheet) - onSubmit() { - this.authService - .login(this.formData()) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((result) => { - if (result) { - this.router.navigate(['/home']) - } - }) + showAppHealth() { + this.bottomSheet.open(HealthStatus) } } diff --git a/src/app/pages/bookmarks-page/bookmarks-page.css b/src/app/pages/bookmarks-page/bookmarks-page.css new file mode 100644 index 0000000..ae579e5 --- /dev/null +++ b/src/app/pages/bookmarks-page/bookmarks-page.css @@ -0,0 +1,20 @@ +:host { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); +} + +#view-toggle { + display: flex; + align-items: center; + justify-content: space-between; +} + +#actions { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; +} diff --git a/src/app/pages/bookmarks-page/bookmarks-page.html b/src/app/pages/bookmarks-page/bookmarks-page.html new file mode 100644 index 0000000..f534b9b --- /dev/null +++ b/src/app/pages/bookmarks-page/bookmarks-page.html @@ -0,0 +1,11 @@ + + + + + + + diff --git a/src/app/pages/bookmarks-page/bookmarks-page.ts b/src/app/pages/bookmarks-page/bookmarks-page.ts new file mode 100644 index 0000000..07c9f9f --- /dev/null +++ b/src/app/pages/bookmarks-page/bookmarks-page.ts @@ -0,0 +1,85 @@ +import { Component, DestroyRef, inject, OnInit, signal } from '@angular/core' +import { Article } from '../../entities/article/article.types' +import { FeedService } from '../../services/feed-service' +import { catchError, combineLatest, of, switchMap } from 'rxjs' +import { HttpErrorResponse } from '@angular/common/http' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { Tag } from '../../entities/tag/tag.types' +import { TagService } from '../../services/tag-service' +import { TitleService } from '../../services/title-service' +import { ArticleList } from '../../components/article-list/article-list' +import { MatToolbarRow } from '@angular/material/toolbar' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { PageDisplayToggle } from '../../components/page-display-toggle/page-display-toggle' + +@Component({ + selector: 'app-bookmarks-page', + imports: [ArticleList, MatToolbarRow, Paginator, PageDisplayToggle], + templateUrl: './bookmarks-page.html', + styleUrl: './bookmarks-page.css', +}) +export class BookmarksPage implements OnInit { + private readonly feedService = inject(FeedService) + private readonly destroyRef = inject(DestroyRef) + private readonly tagService = inject(TagService) + private readonly pageService = inject(PageService) + private readonly titleService = inject(TitleService) + + articles = signal([]) + + favTagId = signal('') + userTags = signal([]) + + ngOnInit() { + this.tagService + .getDefaultTags() + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((tags) => { + const favTag = tags?.find((t) => t.name === 'fav')?._id + if (!favTag) { + return of(null) + } + return combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]).pipe( + switchMap(([perPage, pageNumber]) => { + this.favTagId.set(favTag) + const filters = { tags: favTag } + return this.feedService.getAllArticles({ + pagination: { + perPage, + pageNumber, + }, + filters, + }) + }), + ) + }), + ) + .subscribe((result) => { + if (result) { + this.articles.set(result.result) + this.pageService.setTotalResults(result.total) + this.titleService.setTitle(`Bookmarks: ${result.total}`) + } else { + this.titleService.setTitle('Bookmarks') + } + this.titleService.setSubtitle(null) + }) + + this.tagService + .getAllTags({}) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + ) + .subscribe((tags) => { + if (tags?.result) { + this.userTags.set(tags.result.filter((t) => t.userId !== 'all')) + } + }) + } +} diff --git a/src/app/pages/feeds-page/feeds-page.css b/src/app/pages/feeds-page/feeds-page.css new file mode 100644 index 0000000..b25979e --- /dev/null +++ b/src/app/pages/feeds-page/feeds-page.css @@ -0,0 +1,24 @@ +:host { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 1rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); +} + +:host .mat-mdc-paginator { + background: transparent; +} + +.card { + cursor: pointer; +} + +.link { + font-style: italic; + font-size: smaller; +} + +.description { + color: var(--mat-sys-secondary); +} diff --git a/src/app/pages/feeds-page/feeds-page.html b/src/app/pages/feeds-page/feeds-page.html new file mode 100644 index 0000000..ebf9186 --- /dev/null +++ b/src/app/pages/feeds-page/feeds-page.html @@ -0,0 +1,75 @@ + + + + add + + + + refresh + + + + + + @if (isRefreshingAll()) { + + } @else { + @defer { + @for (f of feeds(); track f._id) { + + + {{ f.title }} + + + delete + + + refresh + + + edit + + + + Link: {{ f.link | linkTrim:15 }} + {{ f.description }} + + library_books + + + + } @empty { + No feeds found + } + } @loading (minimum 0.5s) { + + } + } + + + diff --git a/src/app/pages/feeds-page/feeds-page.ts b/src/app/pages/feeds-page/feeds-page.ts new file mode 100644 index 0000000..7606309 --- /dev/null +++ b/src/app/pages/feeds-page/feeds-page.ts @@ -0,0 +1,182 @@ +import { Component, DestroyRef, effect, inject, OnInit, signal } from '@angular/core' +import { MatCardModule } from '@angular/material/card' +import { FeedService } from '../../services/feed-service' +import { catchError, combineLatest, of, switchMap } from 'rxjs' +import { HttpErrorResponse } from '@angular/common/http' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { MatToolbarModule } from '@angular/material/toolbar' +import { MatIconModule } from '@angular/material/icon' +import { MatIconButton } from '@angular/material/button' +import { RowSpacer } from '../../components/row-spacer/row-spacer' +import { MatDialog, MatDialogModule } from '@angular/material/dialog' +import { Feed } from '../../entities/feed/feed.types' +import { MatProgressBar } from '@angular/material/progress-bar' +import { MatPaginatorModule } from '@angular/material/paginator' +import { LinkTrimPipe } from '../../pipes/link-trim-pipe' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { TitleService } from '../../services/title-service' +import { scrollUp } from '../../../utils' +import { RouterLink } from '@angular/router' +import { MatBadgeModule } from '@angular/material/badge' +import { ConfirmationDialog } from '../../components/confirmation-dialog/confirmation-dialog' +import { FeedAddForm } from '../../components/feed-add-form/feed-add-form' +import { FeedEditForm } from '../../components/feed-edit-form/feed-edit-form' +import { MatBottomSheet } from '@angular/material/bottom-sheet' +import { BottomErrorSheet } from '../../components/bottom-error-sheet/bottom-error-sheet' + +@Component({ + selector: 'app-feed-page', + imports: [ + MatCardModule, + MatToolbarModule, + MatIconModule, + MatIconButton, + RowSpacer, + MatDialogModule, + MatProgressBar, + MatPaginatorModule, + LinkTrimPipe, + Paginator, + RouterLink, + MatBadgeModule, + ], + templateUrl: './feeds-page.html', + styleUrl: './feeds-page.css', +}) +export class FeedsPage implements OnInit { + constructor() { + effect(() => { + scrollUp({ trigger: !!this.feeds().length }) + }) + } + + private readonly feedService = inject(FeedService) + private readonly pageService = inject(PageService) + private readonly dialog = inject(MatDialog) + private readonly destroyRef = inject(DestroyRef) + private readonly titleService = inject(TitleService) + private readonly bottomError = inject(MatBottomSheet) + + readonly feeds = signal([]) + readonly isRefreshing = signal>({}) + readonly isRefreshingAll = signal(false) + + ngOnInit(): void { + combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]) + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(([perPage, pageNumber]) => { + return this.feedService.getAllFeeds({ + pagination: { + perPage, + pageNumber, + }, + }) + }), + ) + .subscribe((result) => { + if (result) { + this.pageService.setTotalResults(result.total) + this.feeds.set(result.result) + } + this.titleService.setTitle('Feeds') + this.titleService.setSubtitle(null) + }) + } + + onAdd(): void { + const dialogRef = this.dialog.open(FeedAddForm) + + dialogRef.afterClosed().subscribe((result) => { + console.log('The dialog was closed', result) + this.pageService.setCurrentPage(1) + }) + } + + onRefreshOne(e: MouseEvent, feedId: string): void { + e.stopPropagation() + this.isRefreshing.update((prev) => ({ + ...prev, + [feedId]: true, + })) + this.feedService + .refreshOneFeed({ feedId }) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + this.isRefreshing.update((prev) => ({ + ...prev, + [feedId]: false, + })) + this.bottomError.open(BottomErrorSheet, { data: { error: e.error.message } }) + console.error(e) + return of(null) + }), + ) + .subscribe(() => { + this.isRefreshing.update((prev) => ({ + ...prev, + [feedId]: false, + })) + }) + } + + onRefreshAll(): void { + this.isRefreshingAll.set(true) + this.feedService + .refreshAllFeeds() + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + this.isRefreshingAll.set(false) + console.error(e) + return of(null) + }), + ) + .subscribe(() => { + this.isRefreshingAll.set(false) + }) + } + + onRemove(e: MouseEvent, id: string): void { + e.stopPropagation() + + const dialogRef = this.dialog.open(ConfirmationDialog, { + data: { + title: 'Delete feed?', + message: 'Are you sure you want to delete this feed?', + confirmButtonText: 'Delete', + }, + }) + + dialogRef.afterClosed().subscribe((agree) => { + if (agree) { + this.feedService + .deleteOneFeed({ feedId: id }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { + this.pageService.setCurrentPage(1) + }) + } + }) + } + + onEdit(e: MouseEvent, feed: Feed): void { + e.stopPropagation() + const dialogRef = this.dialog.open(FeedEditForm, { + data: { feed }, + }) + + dialogRef.afterClosed().subscribe((result) => { + console.log('The dialog was closed', result) + this.pageService.setCurrentPage(1) + }) + } +} diff --git a/src/app/pages/home/home-page.component.html b/src/app/pages/home/home-page.component.html deleted file mode 100644 index e2da575..0000000 --- a/src/app/pages/home/home-page.component.html +++ /dev/null @@ -1,103 +0,0 @@ - - - - view_day - - view_agenda - - - - {{ readFilter() ? 'mark_email_unread' : 'mark_email_read' }} - - bookmark - - - - -@defer { - @for (a of articles(); track a._id) { - - - {{ a.title }} - Published: {{ a.isoDate | date: 'short' }} - - @if (display() !== 'title') { - - - - } - - - link - - - open_in_new - - - {{ a.read ? 'mark_email_read' : 'mark_email_unread' }} - - - bookmark - - - - @for (t of userTags(); track t._id) { - {{ t.name }} - - } - - - - } @empty { - No articles found - } -} @loading (minimum 0.5s) { - -} - - - @if (articles().length > 0) { - - } - - - - check - Skip visible - - - done_all - Skip all - - - diff --git a/src/app/pages/home/home-page.component.ts b/src/app/pages/home/home-page.component.ts deleted file mode 100644 index 7a96830..0000000 --- a/src/app/pages/home/home-page.component.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { FeedService } from '../../services/feed-service' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { HttpErrorResponse } from '@angular/common/http' -import { catchError, of } from 'rxjs' -import { DatePipe } from '@angular/common' -import { MatButton, MatIconButton } from '@angular/material/button' -import { MatProgressBar } from '@angular/material/progress-bar' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatButtonToggleModule } from '@angular/material/button-toggle' -import { MatIconModule } from '@angular/material/icon' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { Router, RouterLink } from '@angular/router' -import { Article } from '../../entities/article/article.types' -import { MatPaginatorModule, PageEvent } from '@angular/material/paginator' -import { TagService } from '../../services/tag-service' -import { Tag } from '../../entities/tag/tag.types' -import { MatChipOption, MatChipSet } from '@angular/material/chips' -import { TitleService } from '../../services/title-service' -import { DomSanitizer, SafeHtml } from '@angular/platform-browser' - -@Component({ - selector: 'app-home', - imports: [ - MatCardModule, - DatePipe, - MatProgressBar, - MatToolbarModule, - MatButtonToggleModule, - MatIconModule, - RowSpacer, - MatIconButton, - RouterLink, - MatPaginatorModule, - MatChipOption, - MatChipSet, - MatButton, - ], - templateUrl: './home-page.component.html', - styleUrl: './home-page.component.css', -}) -export class HomePage implements OnInit { - feedService = inject(FeedService) - router = inject(Router) - destroyRef = inject(DestroyRef) - tagService = inject(TagService) - titleService = inject(TitleService) - htmlSanitizer = inject(DomSanitizer) - - articles = signal([]) - articleIds = computed(() => this.articles().map(({ _id }) => _id)) - display = signal<'title' | 'short'>('title') - - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - readFilter = signal(true) - favFilter = signal(false) - - favTagId = signal('') - userTags = signal([]) - - ngOnInit() { - this.getData() - this.tagService.$defaultTags - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((tags) => { - this.favTagId.set(tags?.find((t) => t.name === 'fav')?._id || '') - }) - this.tagService - .getAllTags({}) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((tags) => { - if (tags?.result) { - this.userTags.set(tags.result.filter((t) => t.userId !== 'all')) - } - }) - } - - getData() { - const filters: Record = {} - - if (this.readFilter()) { - filters['read'] = false - } - - if (this.favFilter()) { - filters['tags'] = this.favTagId() - } - - this.feedService - .getAllArticles({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - filters, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.articles.set(result.result) - this.totalResults.set(result.total) - this.titleService.setTitle(`News: ${result.total} articles`) - } else { - this.titleService.setTitle('News') - } - }) - } - - toggleDisplay(display: 'title' | 'short') { - this.display.set(display) - } - - safeHtml(html: string): SafeHtml { - return this.htmlSanitizer.bypassSecurityTrustHtml(html) - } - - async onArticleClick(article: Article) { - await this.router.navigate(['subscription', article.subscriptionId, 'article', article._id]) - } - - markAsRead(article: Article, event: MouseEvent) { - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { - read: !article.read, - }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, read: !a.read } : a)) - } - return prev - }) - }) - } - } - - markManyAsRead({ - articleIds, - read, - all, - }: { - articleIds?: string[] - read: boolean - all?: boolean - }) { - this.feedService - .changeManyArticles({ - ids: articleIds, - article: { read }, - all, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe(() => { - this.getData() - }) - } - - onAddToBookmarks(article: Article, event: MouseEvent) { - const existingTag = article.tags.find((t) => t === this.favTagId()) - const tags = existingTag - ? [...article.tags].filter((t) => t !== this.favTagId()) - : [...article.tags, this.favTagId()] - - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { tags }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, tags } : a)) - } - return prev - }) - }) - } - } - - paginationHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getData() - } - - filterHandler(filter: 'read' | 'fav') { - if (filter === 'read') { - this.readFilter.update((prev) => !prev) - } else { - this.favFilter.update((prev) => !prev) - } - this.getData() - } - - tagHandler(article: Article, tag: Tag, event: MouseEvent) { - const existingTag = article.tags.find((t) => t === tag._id) - const tags = existingTag - ? [...article.tags].filter((t) => t !== tag._id) - : [...article.tags, tag._id] - - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { tags }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, tags } : a)) - } - return prev - }) - }) - } - } -} diff --git a/src/app/pages/not-found/not-found-page.component.css b/src/app/pages/not-found/not-found-page.component.css index e69de29..a9bc56f 100644 --- a/src/app/pages/not-found/not-found-page.component.css +++ b/src/app/pages/not-found/not-found-page.component.css @@ -0,0 +1,13 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 1rem +} + +.emoji { + font-size: 20cqw; + font-weight: bold; + color: var(--mat-sys-secondary); +} diff --git a/src/app/pages/not-found/not-found-page.component.html b/src/app/pages/not-found/not-found-page.component.html index 8071020..5d3bd69 100644 --- a/src/app/pages/not-found/not-found-page.component.html +++ b/src/app/pages/not-found/not-found-page.component.html @@ -1 +1,2 @@ -not-found works! +Page not found +\(o_o)/ diff --git a/src/app/pages/status-page/status-page.css b/src/app/pages/status-page/status-page.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/status-page/status-page.html b/src/app/pages/status-page/status-page.html new file mode 100644 index 0000000..d7ddc81 --- /dev/null +++ b/src/app/pages/status-page/status-page.html @@ -0,0 +1 @@ + diff --git a/src/app/pages/status-page/status-page.ts b/src/app/pages/status-page/status-page.ts new file mode 100644 index 0000000..851f3fe --- /dev/null +++ b/src/app/pages/status-page/status-page.ts @@ -0,0 +1,18 @@ +import { Component, inject, OnInit } from '@angular/core' +import { HealthStatus } from '../../components/health-status/health-status' +import { TitleService } from '../../services/title-service' + +@Component({ + selector: 'app-status-page', + imports: [HealthStatus], + templateUrl: './status-page.html', + styleUrl: './status-page.css', +}) +export class StatusPage implements OnInit { + private readonly titleService = inject(TitleService) + + ngOnInit() { + this.titleService.setTitle('User') + this.titleService.setSubtitle(null) + } +} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.css b/src/app/pages/subscriptions/subscriptions-page.component.css deleted file mode 100644 index 67cb9da..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.css +++ /dev/null @@ -1,9 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 1rem; -} - -:host .mat-mdc-paginator { - background: transparent; -} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.html b/src/app/pages/subscriptions/subscriptions-page.component.html deleted file mode 100644 index 10aabda..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - add - - - @if (feeds().length > 0) { - - } - - -@defer { - @for (f of feeds(); track f._id) { - - - {{ f.title }} - - - delete - - - - Link: {{ f.link }} - Articles: {{ f.articles.length }} - - - } @empty { - No subscriptions found - } -} @loading (minimum 0.5s) { - -} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.ts b/src/app/pages/subscriptions/subscriptions-page.component.ts deleted file mode 100644 index 720d0ed..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Component, DestroyRef, inject, OnInit, signal, viewChild } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { FeedService } from '../../services/feed-service' -import { catchError, of } from 'rxjs' -import { HttpErrorResponse } from '@angular/common/http' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatIconModule } from '@angular/material/icon' -import { MatIconButton } from '@angular/material/button' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { MatDialog, MatDialogModule } from '@angular/material/dialog' -import { SubscriptionAddForm } from '../../components/subscription-add-form/subscription-add-form' -import { Feed } from '../../entities/feed/feed.types' -import { MatProgressBar } from '@angular/material/progress-bar' -import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator' - -@Component({ - selector: 'app-subscriptions', - imports: [ - MatCardModule, - MatToolbarModule, - MatIconModule, - MatIconButton, - RowSpacer, - MatDialogModule, - MatProgressBar, - MatPaginatorModule, - ], - templateUrl: './subscriptions-page.component.html', - styleUrl: './subscriptions-page.component.css', -}) -export class SubscriptionsPage implements OnInit { - feedService = inject(FeedService) - readonly dialog = inject(MatDialog) - destroyRef = inject(DestroyRef) - - feeds = signal([]) - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - ngOnInit() { - this.getData() - } - - getData() { - this.feedService - .getAllSubscriptions({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.currentPage.set(1) - this.feeds.set(result.result) - this.totalResults.set(result.total) - } - }) - } - - onAdd() { - const dialogRef = this.dialog.open(SubscriptionAddForm) - - dialogRef.afterClosed().subscribe((result) => { - if (result) { - this.getData() - } - }) - } - - paginator = viewChild(MatPaginator) - - onRemove(id: string) { - this.feedService - .deleteOneSubscription({ subscriptionId: id }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - this.getData() - this.paginator()?.firstPage() - }) - } - - paginationHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getData() - } -} diff --git a/src/app/pages/tags-page/tags-page.css b/src/app/pages/tags-page/tags-page.css new file mode 100644 index 0000000..31b5e77 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.css @@ -0,0 +1,11 @@ +:host { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 1rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); +} + +input { + margin-top: 2rem; +} diff --git a/src/app/pages/tags-page/tags-page.html b/src/app/pages/tags-page/tags-page.html new file mode 100644 index 0000000..50596d6 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.html @@ -0,0 +1,47 @@ + + @defer { + User tags + + + @for (t of userTags(); track t._id) { + + + edit + + {{ t.name }} + + cancel + + + } + + + + + App tags + + @for (t of appTags(); track t._id) { + + {{ t.name }} + + } + + + + } + + + diff --git a/src/app/pages/tags-page/tags-page.ts b/src/app/pages/tags-page/tags-page.ts new file mode 100644 index 0000000..c376202 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.ts @@ -0,0 +1,153 @@ +import { Component, DestroyRef, inject, linkedSignal, OnInit, signal } from '@angular/core' +import { TagService } from '../../services/tag-service' +import { Tag } from '../../entities/tag/tag.types' +import { HttpErrorResponse } from '@angular/common/http' +import { catchError, combineLatest, of, switchMap } from 'rxjs' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { MatIconModule } from '@angular/material/icon' +import { MatChipEditedEvent, MatChipInputEvent, MatChipsModule } from '@angular/material/chips' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { TitleService } from '../../services/title-service' +import { Router } from '@angular/router' +import { MatFormFieldModule } from '@angular/material/form-field' +import { COMMA, ENTER } from '@angular/cdk/keycodes' +import { MatBottomSheet } from '@angular/material/bottom-sheet' +import { BottomErrorSheet } from '../../components/bottom-error-sheet/bottom-error-sheet' +import { MatDialog } from '@angular/material/dialog' +import { ConfirmationDialog } from '../../components/confirmation-dialog/confirmation-dialog' + +@Component({ + selector: 'app-tags-page', + imports: [MatIconModule, Paginator, MatFormFieldModule, MatChipsModule], + templateUrl: './tags-page.html', + styleUrl: './tags-page.css', +}) +export class TagsPage implements OnInit { + private readonly tagsService = inject(TagService) + private readonly pageService = inject(PageService) + private readonly destroyRef = inject(DestroyRef) + private readonly titleService = inject(TitleService) + private readonly router = inject(Router) + private readonly errorSheet = inject(MatBottomSheet) + private readonly dialog = inject(MatDialog) + + public readonly separatorKeysCodes = [ENTER, COMMA] as const + + private readonly tags = signal([]) + readonly userTags = linkedSignal(() => { + return this.tags().filter((t) => t.userId !== 'all') + }) + readonly appTags = linkedSignal(() => { + return this.tags().filter((t) => t.userId === 'all') + }) + + ngOnInit() { + combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]) + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(([perPage, pageNumber]) => { + return this.tagsService.getAllTags({ + pagination: { + perPage, + pageNumber, + }, + }) + }), + ) + .subscribe((result) => { + if (result) { + this.pageService.setTotalResults(result.total) + this.tags.set(result.result) + this.titleService.setTitle('Tags') + this.titleService.setSubtitle(null) + } + }) + } + + onAdd(e: MatChipInputEvent) { + const name = (e.value || '').trim() + + if (!name) { + return + } + + this.tagsService + .addOneTag({ name }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + this.errorSheet.open(BottomErrorSheet, { data: { error: error.error.message } }) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + e.chipInput.clear() + } + }) + } + + onEdit(tag: Tag, e: MatChipEditedEvent) { + const newName = (e.value || '').trim() + const currentName = tag.name + + if (!newName) { + return + } + + this.tagsService + .changeOneTag({ newName, currentName }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + this.errorSheet.open(BottomErrorSheet, { data: { error: error.error.message } }) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + } + }) + } + + onRemove(tag: Tag) { + const dialogRef = this.dialog.open(ConfirmationDialog, { + data: { + title: 'Delete tag', + message: `Are you sure you want to delete the tag "${tag.name}"?`, + confirmButtonText: 'Delete', + }, + }) + + dialogRef.afterClosed().subscribe((agree) => { + if (agree) { + this.tagsService + .deleteOneTag({ name: tag.name }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + } + }) + } + }) + } + + onClick(name: string) { + if (!name) { + return + } + this.router.navigate(['/articles'], { queryParams: { tag: name } }) + } +} diff --git a/src/app/pages/tags/tags-page.component.css b/src/app/pages/tags/tags-page.component.css deleted file mode 100644 index 1556b2c..0000000 --- a/src/app/pages/tags/tags-page.component.css +++ /dev/null @@ -1,5 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 1rem; -} diff --git a/src/app/pages/tags/tags-page.component.html b/src/app/pages/tags/tags-page.component.html deleted file mode 100644 index 1e851b0..0000000 --- a/src/app/pages/tags/tags-page.component.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - add - - - @if (tags().length > 0) { - - } - - -@defer { - - @for (t of tags(); track t._id) { - - {{ t.name }} - @if (t.userId !== 'all') { - - cancel - - } - - } - -} diff --git a/src/app/pages/tags/tags-page.component.ts b/src/app/pages/tags/tags-page.component.ts deleted file mode 100644 index d6866e4..0000000 --- a/src/app/pages/tags/tags-page.component.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Component, DestroyRef, inject, OnInit, signal, viewChild } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { TagService } from '../../services/tag-service' -import { Tag } from '../../entities/tag/tag.types' -import { HttpErrorResponse } from '@angular/common/http' -import { catchError, of } from 'rxjs' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { MatPaginator, PageEvent } from '@angular/material/paginator' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatIconButton } from '@angular/material/button' -import { MatIconModule } from '@angular/material/icon' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { MatDialog } from '@angular/material/dialog' -import { TagAddForm } from '../../components/tag-add-form/tag-add-form' -import { MatChipRemove, MatChipRow, MatChipSet } from '@angular/material/chips' - -@Component({ - selector: 'app-tags', - imports: [ - MatCardModule, - MatToolbarModule, - MatIconButton, - MatIconModule, - RowSpacer, - MatPaginator, - MatChipRow, - MatChipRemove, - MatChipSet, - ], - templateUrl: './tags-page.component.html', - styleUrl: './tags-page.component.css', -}) -export class TagsPage implements OnInit { - tagsService = inject(TagService) - destroyRef = inject(DestroyRef) - readonly dialog = inject(MatDialog) - - tags = signal([]) - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - getDate() { - this.tagsService - .getAllTags({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.error(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.currentPage.set(1) - this.tags.set(result.result) - this.totalResults.set(result.total) - } - }) - } - - ngOnInit() { - this.getDate() - } - - paginator = viewChild(MatPaginator) - - onAdd() { - const dialogRef = this.dialog.open(TagAddForm) - - dialogRef.afterClosed().subscribe((result) => { - if (result) { - this.getDate() - } - }) - } - - onRemove(tag: Tag) { - this.tagsService - .deleteOneTag({ name: tag.name }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.error(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe(() => { - this.getDate() - this.paginator()?.firstPage() - }) - } - - paginatorHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getDate() - } -} diff --git a/src/app/pages/user-page/user-page.css b/src/app/pages/user-page/user-page.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/user-page/user-page.html b/src/app/pages/user-page/user-page.html new file mode 100644 index 0000000..49a288a --- /dev/null +++ b/src/app/pages/user-page/user-page.html @@ -0,0 +1,8 @@ + + + Username: {{ ($currentUser | async)?.user?.login }} + + + Role: {{ ($currentUser | async)?.user?.role }} + + diff --git a/src/app/pages/user-page/user-page.ts b/src/app/pages/user-page/user-page.ts new file mode 100644 index 0000000..5aa3f70 --- /dev/null +++ b/src/app/pages/user-page/user-page.ts @@ -0,0 +1,22 @@ +import { Component, inject, OnInit } from '@angular/core' +import { AuthService } from '../../services/auth-service' +import { AsyncPipe } from '@angular/common' +import { TitleService } from '../../services/title-service' + +@Component({ + selector: 'app-user-page', + imports: [AsyncPipe], + templateUrl: './user-page.html', + styleUrl: './user-page.css', +}) +export class UserPage implements OnInit { + private readonly authService = inject(AuthService) + private readonly titleService = inject(TitleService) + + $currentUser = this.authService.$authStatus + + ngOnInit() { + this.titleService.setTitle('User') + this.titleService.setSubtitle(null) + } +} diff --git a/src/app/pipes/link-trim-pipe.ts b/src/app/pipes/link-trim-pipe.ts new file mode 100644 index 0000000..45aeb2b --- /dev/null +++ b/src/app/pipes/link-trim-pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core' + +@Pipe({ + name: 'linkTrim', +}) +export class LinkTrimPipe implements PipeTransform { + transform(value: string, length: number = Infinity): string { + try { + const parsed = URL.parse(value) + if (!parsed) { + throw new Error('Invalid URL') + } + const { host, pathname } = parsed + return `${host}${pathname.slice(0, length) + '...'}` + } catch (e) { + console.error(e) + return value.slice(0, length) + '...' + } + } +} diff --git a/src/app/pipes/safe-html-pipe.ts b/src/app/pipes/safe-html-pipe.ts new file mode 100644 index 0000000..51f0a54 --- /dev/null +++ b/src/app/pipes/safe-html-pipe.ts @@ -0,0 +1,16 @@ +import { inject, Pipe, PipeTransform } from '@angular/core' +import { DomSanitizer, SafeHtml } from '@angular/platform-browser' + +@Pipe({ + name: 'safeHtml', +}) +export class SafeHtmlPipe implements PipeTransform { + sanitizer = inject(DomSanitizer) + + transform(value: string): SafeHtml { + if (!value) { + return '' + } + return this.sanitizer.bypassSecurityTrustHtml(value) + } +} diff --git a/src/app/services/auth-service.ts b/src/app/services/auth-service.ts index 43d1908..1f18117 100644 --- a/src/app/services/auth-service.ts +++ b/src/app/services/auth-service.ts @@ -23,15 +23,58 @@ export class AuthService { $authStatus = this.$$authStatus.asObservable() login({ login, password }: UserDTO) { - return this.httpClient.post(`${environment.api}/auth/login`, { login, password }).pipe( - switchMap((response) => { - this.$$authStatus.next({ authenticated: true, user: response, error: null }) - return of(response) - }), + return this.httpClient + .post(`${environment.api}/auth/login`, { + login: login.trim(), + password: password.trim(), + }) + .pipe( + switchMap((response) => { + localStorage.setItem('user', response._id) + this.$$authStatus.next({ authenticated: true, user: response, error: null }) + return of(response) + }), + catchError((error: HttpErrorResponse) => { + this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) + return of(null) + }), + ) + } + + signup({ password }: { password: string }) { + return this.httpClient + .post(`${environment.api}/auth/signup`, { + password: password.trim(), + }) + .pipe( + switchMap((response) => { + localStorage.setItem('user', response._id) + this.$$authStatus.next({ authenticated: true, user: response, error: null }) + return of(response) + }), + catchError((error: HttpErrorResponse) => { + this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) + return of(null) + }), + ) + } + + logout() { + return this.httpClient.get<{ message: string }>(`${environment.api}/auth/logout`).pipe( catchError((error: HttpErrorResponse) => { - this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) - return of(null) + console.error(error) + return of(false) + }), + switchMap(() => { + localStorage.removeItem('user') + this.$$authStatus.next({ authenticated: false, user: null, error: null }) + return of(true) }), ) } + + updateAuth(user: User) { + this.$$authStatus.next({ authenticated: true, user, error: null }) + localStorage.setItem('user', user._id) + } } diff --git a/src/app/services/feed-service.ts b/src/app/services/feed-service.ts index eb563be..9b3aae7 100644 --- a/src/app/services/feed-service.ts +++ b/src/app/services/feed-service.ts @@ -4,17 +4,16 @@ import { environment } from '../../environments/environment' import { Feed, FeedDTO } from '../entities/feed/feed.types' import { Article, ArticleDTO } from '../entities/article/article.types' import { Paginated, Pagination } from '../entities/base/base.types' -import { TagService } from './tag-service' +import { SortOrder } from '../entities/base/base.enums' @Injectable({ providedIn: 'root', }) export class FeedService { - httpClient = inject(HttpClient) - tagService = inject(TagService) + readonly httpClient = inject(HttpClient) - getAllSubscriptions({ pagination }: { pagination?: Partial }) { - return this.httpClient.get>(`${environment.api}/subscription`, { + getAllFeeds({ pagination }: { pagination?: Partial }) { + return this.httpClient.get>(`${environment.api}/feed`, { params: pagination, }) } @@ -22,18 +21,27 @@ export class FeedService { getAllArticles({ pagination, filters, + sort, }: { pagination?: Partial filters?: { tags?: string; read?: boolean } + sort?: { date: SortOrder } }) { - this.tagService.getDefaultTags() return this.httpClient.get>(`${environment.api}/article`, { - params: { ...pagination, ...filters }, + params: { + ...pagination, + ...filters, + dateSort: sort?.date || SortOrder.Desc, + }, }) } - getOneSubscription({ subscriptionId }: { subscriptionId: string }) { - return this.httpClient.get(`${environment.api}/subscription/${subscriptionId}`) + getOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.get(`${environment.api}/feed/${feedId}`) + } + + changeOneFeed({ id, dto }: { id: string; dto: Partial }) { + return this.httpClient.patch(`${environment.api}/feed/${id}`, dto) } getOneArticle({ articleId }: { articleId: string }) { @@ -63,11 +71,23 @@ export class FeedService { }) } - addOneSubscription({ subscription }: { subscription: FeedDTO }) { - return this.httpClient.post(`${environment.api}/subscription`, subscription) + addOneFeed({ feed }: { feed: FeedDTO }) { + return this.httpClient.post(`${environment.api}/feed`, feed) + } + + deleteOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.delete(`${environment.api}/feed/${feedId}`) + } + + refreshAllFeeds() { + return this.httpClient.get(`${environment.api}/feed/refresh`) + } + + refreshOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.get(`${environment.api}/feed/${feedId}/refresh`) } - deleteOneSubscription({ subscriptionId }: { subscriptionId: string }) { - return this.httpClient.delete(`${environment.api}/subscription/${subscriptionId}`) + getFullText({ articleId }: { articleId: string }) { + return this.httpClient.get<{ fullText: string }>(`${environment.api}/article/${articleId}/full`) } } diff --git a/src/app/services/health-service.ts b/src/app/services/health-service.ts new file mode 100644 index 0000000..f50b578 --- /dev/null +++ b/src/app/services/health-service.ts @@ -0,0 +1,17 @@ +import { inject, Injectable } from '@angular/core' +import { HttpClient } from '@angular/common/http' +import { environment } from '../../environments/environment' + +@Injectable({ + providedIn: 'root', +}) +export class HealthService { + constructor() {} + httpClient = inject(HttpClient) + + getBackendStatus() { + return this.httpClient.get<{ status: string; version: string; uptime: string }>( + `${environment.api}/health`, + ) + } +} diff --git a/src/app/services/page-service.ts b/src/app/services/page-service.ts new file mode 100644 index 0000000..516dbc4 --- /dev/null +++ b/src/app/services/page-service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core' +import { PageDisplay } from '../entities/page/page.enums' +import { BehaviorSubject } from 'rxjs' + +@Injectable({ + providedIn: 'any', +}) +export class PageService { + private $$pageSize = new BehaviorSubject(5) + $pageSize = this.$$pageSize.asObservable() + + private $$currentPage = new BehaviorSubject(1) + $currentPage = this.$$currentPage.asObservable() + + private $$totalResults = new BehaviorSubject(0) + $totalResults = this.$$totalResults.asObservable() + + private $$display = new BehaviorSubject(PageDisplay.Title) + $display = this.$$display.asObservable() + + setCurrentPage(currentPage: number) { + this.$$currentPage.next(currentPage) + } + + setPageSize(pageSize: number) { + this.$$pageSize.next(pageSize) + } + + setTotalResults(totalResults: number) { + this.$$totalResults.next(totalResults) + } + + setDisplay(display: PageDisplay) { + this.$$display.next(display) + } +} diff --git a/src/app/services/tag-service.ts b/src/app/services/tag-service.ts index bc9f128..810f379 100644 --- a/src/app/services/tag-service.ts +++ b/src/app/services/tag-service.ts @@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http' import { Tag } from '../entities/tag/tag.types' import { environment } from '../../environments/environment' import { Paginated, Pagination } from '../entities/base/base.types' -import { BehaviorSubject } from 'rxjs' +import { BehaviorSubject, tap } from 'rxjs' @Injectable({ providedIn: 'root', @@ -18,9 +18,11 @@ export class TagService { $defaultTags = this.$$defaultTags.asObservable() getDefaultTags() { - return this.httpClient.get(`${environment.api}/tag?default=true`).subscribe((tags) => { - this.$$defaultTags.next(tags) - }) + return this.httpClient.get(`${environment.api}/tag?default=true`).pipe( + tap((tags) => { + this.$$defaultTags.next(tags) + }), + ) } getAllTags({ pagination }: { pagination?: Partial }) { @@ -29,6 +31,10 @@ export class TagService { }) } + getOne({ name }: { name: Tag['_id'] }) { + return this.httpClient.get(`${environment.api}/tag/${name}`) + } + addOneTag({ name }: { name: string }) { return this.httpClient.post(`${environment.api}/tag`, { name }) } diff --git a/src/app/services/title-service.ts b/src/app/services/title-service.ts index 8600424..701c846 100644 --- a/src/app/services/title-service.ts +++ b/src/app/services/title-service.ts @@ -11,8 +11,15 @@ export class TitleService { private $$currentTitle = new BehaviorSubject('') $currentTitle = this.$$currentTitle.asObservable() + private $$currentSubtitle = new BehaviorSubject(null) + $currentSubtitle = this.$$currentSubtitle.asObservable() + setTitle(title: string) { this.$$currentTitle.next(title) this.pageTitleService.setTitle(title) } + + setSubtitle(subtitle: string | null) { + this.$$currentSubtitle.next(subtitle) + } } diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 05fac53..3402093 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,5 +1,5 @@ import { Environment } from './environment.types' export const environment: Environment = { - api: 'http://rss-nest:3600/api', + api: 'api', } diff --git a/src/index.html b/src/index.html index 78c77ef..fedac66 100644 --- a/src/index.html +++ b/src/index.html @@ -1,18 +1,28 @@ - - - News - - - - - - - - - + + + News + + + + + + + + + diff --git a/src/styles.css b/src/styles.css index c784d4f..0609480 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,4 +1,5 @@ -/* You can add global styles to this file, and also import other style files */ +@import "@angular/material/prebuilt-themes/cyan-orange.css" (prefers-color-scheme: dark); +@import "@angular/material/prebuilt-themes/azure-blue.css" (prefers-color-scheme: light); html, body { @@ -20,3 +21,29 @@ body { mat-toolbar-row { height: auto !important; } + +.external { + display: flex; + flex-direction: column; +} + +.content_layout { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.content_layout img { + max-height: 20ch; + max-width: 70cqw; + align-self: center; + border-radius: 1rem; +} + +.content_layout a { + color: inherit; +} + +.highlighted { + color: var(--mat-sys-primary) !important; +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..f723ed7 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export { scrollUp } from './scrollUp' diff --git a/src/utils/scrollUp.ts b/src/utils/scrollUp.ts new file mode 100644 index 0000000..2d43ce8 --- /dev/null +++ b/src/utils/scrollUp.ts @@ -0,0 +1,7 @@ +export function scrollUp({ trigger }: { trigger: boolean }) { + if (!trigger) { + return + } + const page = document.querySelector('.page-content') + page?.scroll({ top: 0, behavior: 'smooth' }) +}
([]) + readonly articleIds = computed(() => this.articles().map(({ _id }) => _id)) + readonly favTagId = signal('') + readonly userTags = signal([]) + readonly isRefreshingAll = signal(false) + + $readFilter = new BehaviorSubject(true) + $favFilter = new BehaviorSubject(false) + $feedFilter = new BehaviorSubject(null) + $tagFilter = new BehaviorSubject(null) + $dateOrder = new BehaviorSubject(SortOrder.Desc) + + ngOnInit() { + this.tagService + .getAllTags({}) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + ) + .subscribe((tags) => { + if (tags?.result) { + this.userTags.set(tags.result.filter((t) => t.userId !== 'all')) + } + }) + + this.tagService + .getDefaultTags() + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((tags) => { + const favTag = tags?.find((t) => t.name === 'fav')?._id + if (!favTag) { + return of(null) + } + this.favTagId.set(favTag) + + return combineLatest([ + this.pageService.$pageSize, + this.pageService.$currentPage, + this.$favFilter, + this.$readFilter, + this.$feedFilter, + this.$tagFilter, + this.$dateOrder, + ]).pipe( + switchMap(([perPage, pageNumber, fav, read, feed, tag, dateSort]) => { + const filters: Record = {} + + if (read) { + filters['read'] = false + } + + if (fav) { + filters['tags'] = favTag + } + + if (feed) { + filters['feed'] = feed + } + + if (tag) { + filters['tags'] = tag + } + + return this.feedService.getAllArticles({ + pagination: { + perPage, + pageNumber, + }, + filters, + sort: { + date: dateSort, + }, + }) + }), + ) + }), + ) + .subscribe((result) => { + if (result) { + this.articles.set(result.result) + this.pageService.setTotalResults(result.total) + this.titleService.setTitle(`Articles: ${result.total} articles`) + } else { + this.titleService.setTitle('Articles') + } + }) + + this.route.queryParams + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((params) => { + const feedId: string = params['feed'] + const tagName: string = params['tag'] + if (tagName) { + return forkJoin([of(null), this.tagService.getOne({ name: tagName })]) + } + + if (feedId) { + return forkJoin([this.feedService.getOneFeed({ feedId }), of(null)]) + } + + return forkJoin([of(null), of(null)]) + }), + catchError((e) => { + console.error(e) + return of(null) + }), + ) + .subscribe((results) => { + if (!results) { + return + } + + const [feed, tag] = results + + if (feed) { + this.titleService.setSubtitle(feed.title) + this.$feedFilter.next(feed._id) + this.$tagFilter.next(null) + } else if (tag) { + this.titleService.setSubtitle(tag.name) + this.$feedFilter.next(null) + this.$tagFilter.next(tag._id) + } else { + this.titleService.setSubtitle(null) + this.$feedFilter.next(null) + this.$tagFilter.next(null) + } + }) + } + + markManyAsRead({ + articleIds, + read, + all, + }: { + articleIds?: string[] + read: boolean + all?: boolean + }) { + this.feedService + .changeManyArticles({ + ids: articleIds, + article: { read }, + all, + }) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + ) + .subscribe((result) => { + if (!result?.modifiedCount) { + return + } + this.pageService.setCurrentPage(1) + }) + } + + filterHandler(filter: 'read' | 'fav') { + if (filter === 'read') { + this.$readFilter.next(!this.$readFilter.value) + } else { + this.$favFilter.next(!this.$favFilter.value) + } + this.pageService.setCurrentPage(1) + } + + orderHandler(param: 'date') { + if (param === 'date') { + this.$dateOrder.next(this.$dateOrder.value === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc) + this.pageService.setCurrentPage(1) + } + } + + onRefreshAll() { + this.isRefreshingAll.set(true) + this.feedService + .refreshAllFeeds() + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + this.isRefreshingAll.set(false) + console.error(e) + return of(null) + }), + ) + .subscribe(() => { + this.isRefreshingAll.set(false) + this.pageService.setCurrentPage(1) + }) + } + + protected readonly SortOrder = SortOrder +} diff --git a/src/app/pages/auth-page/auth-page.component.css b/src/app/pages/auth-page/auth-page.component.css index 6de3848..a37ef5f 100644 --- a/src/app/pages/auth-page/auth-page.component.css +++ b/src/app/pages/auth-page/auth-page.component.css @@ -4,11 +4,5 @@ justify-content: center; align-items: center; padding: 2rem; -} - -.credentials { - display: flex; - flex-direction: column; gap: 1rem; - margin: 1rem 0; } diff --git a/src/app/pages/auth-page/auth-page.component.html b/src/app/pages/auth-page/auth-page.component.html index 42fa334..bc4a51d 100644 --- a/src/app/pages/auth-page/auth-page.component.html +++ b/src/app/pages/auth-page/auth-page.component.html @@ -1,47 +1,18 @@ - - - Login - - - - - Login - - - - Password - - - - @let status = (authStatus | async); - @if (status?.error) { - {{ status?.error }} - } - - - Login - - - - + + + + + + + + + + + + memory + diff --git a/src/app/pages/auth-page/auth-page.component.ts b/src/app/pages/auth-page/auth-page.component.ts index 40bf015..b73dca9 100644 --- a/src/app/pages/auth-page/auth-page.component.ts +++ b/src/app/pages/auth-page/auth-page.component.ts @@ -1,67 +1,35 @@ -import { Component, DestroyRef, inject, model } from '@angular/core' -import { - MatCard, - MatCardActions, - MatCardContent, - MatCardHeader, - MatCardTitle, -} from '@angular/material/card' -import { MatInput } from '@angular/material/input' +import { Component, inject } from '@angular/core' import { MatFormFieldModule } from '@angular/material/form-field' -import { MatButton } from '@angular/material/button' import { FormsModule } from '@angular/forms' -import { AuthService } from '../../services/auth-service' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { Router } from '@angular/router' -import { AsyncPipe } from '@angular/common' +import { LoginForm } from '../../components/login-form/login-form' +import { SignupForm } from '../../components/signup-form/signup-form' +import { MatTab, MatTabGroup } from '@angular/material/tabs' +import { MatCard } from '@angular/material/card' +import { MatIconButton } from '@angular/material/button' +import { HealthStatus } from '../../components/health-status/health-status' +import { MatBottomSheet } from '@angular/material/bottom-sheet' +import { MatIconModule } from '@angular/material/icon' @Component({ selector: 'app-auth', imports: [ - MatCard, - MatCardTitle, - MatCardContent, MatFormFieldModule, - MatInput, - MatCardActions, - MatButton, - MatCardHeader, FormsModule, - AsyncPipe, + LoginForm, + SignupForm, + MatTabGroup, + MatTab, + MatCard, + MatIconModule, + MatIconButton, ], templateUrl: './auth-page.component.html', styleUrl: './auth-page.component.css', }) export class AuthPage { - authService = inject(AuthService) - router = inject(Router) - destroyRef = inject(DestroyRef) - - formData = model({ - login: '', - password: '', - }) - - authStatus = this.authService.$authStatus - - inputHandler(field: 'login' | 'password', event: Event) { - const { value } = event.target as HTMLInputElement - if (value) { - this.formData.update((prev) => ({ - ...prev, - [field]: value, - })) - } - } + private readonly bottomSheet = inject(MatBottomSheet) - onSubmit() { - this.authService - .login(this.formData()) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((result) => { - if (result) { - this.router.navigate(['/home']) - } - }) + showAppHealth() { + this.bottomSheet.open(HealthStatus) } } diff --git a/src/app/pages/bookmarks-page/bookmarks-page.css b/src/app/pages/bookmarks-page/bookmarks-page.css new file mode 100644 index 0000000..ae579e5 --- /dev/null +++ b/src/app/pages/bookmarks-page/bookmarks-page.css @@ -0,0 +1,20 @@ +:host { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); +} + +#view-toggle { + display: flex; + align-items: center; + justify-content: space-between; +} + +#actions { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; +} diff --git a/src/app/pages/bookmarks-page/bookmarks-page.html b/src/app/pages/bookmarks-page/bookmarks-page.html new file mode 100644 index 0000000..f534b9b --- /dev/null +++ b/src/app/pages/bookmarks-page/bookmarks-page.html @@ -0,0 +1,11 @@ + + + + + + + diff --git a/src/app/pages/bookmarks-page/bookmarks-page.ts b/src/app/pages/bookmarks-page/bookmarks-page.ts new file mode 100644 index 0000000..07c9f9f --- /dev/null +++ b/src/app/pages/bookmarks-page/bookmarks-page.ts @@ -0,0 +1,85 @@ +import { Component, DestroyRef, inject, OnInit, signal } from '@angular/core' +import { Article } from '../../entities/article/article.types' +import { FeedService } from '../../services/feed-service' +import { catchError, combineLatest, of, switchMap } from 'rxjs' +import { HttpErrorResponse } from '@angular/common/http' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { Tag } from '../../entities/tag/tag.types' +import { TagService } from '../../services/tag-service' +import { TitleService } from '../../services/title-service' +import { ArticleList } from '../../components/article-list/article-list' +import { MatToolbarRow } from '@angular/material/toolbar' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { PageDisplayToggle } from '../../components/page-display-toggle/page-display-toggle' + +@Component({ + selector: 'app-bookmarks-page', + imports: [ArticleList, MatToolbarRow, Paginator, PageDisplayToggle], + templateUrl: './bookmarks-page.html', + styleUrl: './bookmarks-page.css', +}) +export class BookmarksPage implements OnInit { + private readonly feedService = inject(FeedService) + private readonly destroyRef = inject(DestroyRef) + private readonly tagService = inject(TagService) + private readonly pageService = inject(PageService) + private readonly titleService = inject(TitleService) + + articles = signal([]) + + favTagId = signal('') + userTags = signal([]) + + ngOnInit() { + this.tagService + .getDefaultTags() + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((tags) => { + const favTag = tags?.find((t) => t.name === 'fav')?._id + if (!favTag) { + return of(null) + } + return combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]).pipe( + switchMap(([perPage, pageNumber]) => { + this.favTagId.set(favTag) + const filters = { tags: favTag } + return this.feedService.getAllArticles({ + pagination: { + perPage, + pageNumber, + }, + filters, + }) + }), + ) + }), + ) + .subscribe((result) => { + if (result) { + this.articles.set(result.result) + this.pageService.setTotalResults(result.total) + this.titleService.setTitle(`Bookmarks: ${result.total}`) + } else { + this.titleService.setTitle('Bookmarks') + } + this.titleService.setSubtitle(null) + }) + + this.tagService + .getAllTags({}) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + ) + .subscribe((tags) => { + if (tags?.result) { + this.userTags.set(tags.result.filter((t) => t.userId !== 'all')) + } + }) + } +} diff --git a/src/app/pages/feeds-page/feeds-page.css b/src/app/pages/feeds-page/feeds-page.css new file mode 100644 index 0000000..b25979e --- /dev/null +++ b/src/app/pages/feeds-page/feeds-page.css @@ -0,0 +1,24 @@ +:host { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 1rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); +} + +:host .mat-mdc-paginator { + background: transparent; +} + +.card { + cursor: pointer; +} + +.link { + font-style: italic; + font-size: smaller; +} + +.description { + color: var(--mat-sys-secondary); +} diff --git a/src/app/pages/feeds-page/feeds-page.html b/src/app/pages/feeds-page/feeds-page.html new file mode 100644 index 0000000..ebf9186 --- /dev/null +++ b/src/app/pages/feeds-page/feeds-page.html @@ -0,0 +1,75 @@ + + + + add + + + + refresh + + + + + + @if (isRefreshingAll()) { + + } @else { + @defer { + @for (f of feeds(); track f._id) { + + + {{ f.title }} + + + delete + + + refresh + + + edit + + + + Link: {{ f.link | linkTrim:15 }} + {{ f.description }} + + library_books + + + + } @empty { + No feeds found + } + } @loading (minimum 0.5s) { + + } + } + + + diff --git a/src/app/pages/feeds-page/feeds-page.ts b/src/app/pages/feeds-page/feeds-page.ts new file mode 100644 index 0000000..7606309 --- /dev/null +++ b/src/app/pages/feeds-page/feeds-page.ts @@ -0,0 +1,182 @@ +import { Component, DestroyRef, effect, inject, OnInit, signal } from '@angular/core' +import { MatCardModule } from '@angular/material/card' +import { FeedService } from '../../services/feed-service' +import { catchError, combineLatest, of, switchMap } from 'rxjs' +import { HttpErrorResponse } from '@angular/common/http' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { MatToolbarModule } from '@angular/material/toolbar' +import { MatIconModule } from '@angular/material/icon' +import { MatIconButton } from '@angular/material/button' +import { RowSpacer } from '../../components/row-spacer/row-spacer' +import { MatDialog, MatDialogModule } from '@angular/material/dialog' +import { Feed } from '../../entities/feed/feed.types' +import { MatProgressBar } from '@angular/material/progress-bar' +import { MatPaginatorModule } from '@angular/material/paginator' +import { LinkTrimPipe } from '../../pipes/link-trim-pipe' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { TitleService } from '../../services/title-service' +import { scrollUp } from '../../../utils' +import { RouterLink } from '@angular/router' +import { MatBadgeModule } from '@angular/material/badge' +import { ConfirmationDialog } from '../../components/confirmation-dialog/confirmation-dialog' +import { FeedAddForm } from '../../components/feed-add-form/feed-add-form' +import { FeedEditForm } from '../../components/feed-edit-form/feed-edit-form' +import { MatBottomSheet } from '@angular/material/bottom-sheet' +import { BottomErrorSheet } from '../../components/bottom-error-sheet/bottom-error-sheet' + +@Component({ + selector: 'app-feed-page', + imports: [ + MatCardModule, + MatToolbarModule, + MatIconModule, + MatIconButton, + RowSpacer, + MatDialogModule, + MatProgressBar, + MatPaginatorModule, + LinkTrimPipe, + Paginator, + RouterLink, + MatBadgeModule, + ], + templateUrl: './feeds-page.html', + styleUrl: './feeds-page.css', +}) +export class FeedsPage implements OnInit { + constructor() { + effect(() => { + scrollUp({ trigger: !!this.feeds().length }) + }) + } + + private readonly feedService = inject(FeedService) + private readonly pageService = inject(PageService) + private readonly dialog = inject(MatDialog) + private readonly destroyRef = inject(DestroyRef) + private readonly titleService = inject(TitleService) + private readonly bottomError = inject(MatBottomSheet) + + readonly feeds = signal([]) + readonly isRefreshing = signal>({}) + readonly isRefreshingAll = signal(false) + + ngOnInit(): void { + combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]) + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(([perPage, pageNumber]) => { + return this.feedService.getAllFeeds({ + pagination: { + perPage, + pageNumber, + }, + }) + }), + ) + .subscribe((result) => { + if (result) { + this.pageService.setTotalResults(result.total) + this.feeds.set(result.result) + } + this.titleService.setTitle('Feeds') + this.titleService.setSubtitle(null) + }) + } + + onAdd(): void { + const dialogRef = this.dialog.open(FeedAddForm) + + dialogRef.afterClosed().subscribe((result) => { + console.log('The dialog was closed', result) + this.pageService.setCurrentPage(1) + }) + } + + onRefreshOne(e: MouseEvent, feedId: string): void { + e.stopPropagation() + this.isRefreshing.update((prev) => ({ + ...prev, + [feedId]: true, + })) + this.feedService + .refreshOneFeed({ feedId }) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + this.isRefreshing.update((prev) => ({ + ...prev, + [feedId]: false, + })) + this.bottomError.open(BottomErrorSheet, { data: { error: e.error.message } }) + console.error(e) + return of(null) + }), + ) + .subscribe(() => { + this.isRefreshing.update((prev) => ({ + ...prev, + [feedId]: false, + })) + }) + } + + onRefreshAll(): void { + this.isRefreshingAll.set(true) + this.feedService + .refreshAllFeeds() + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + this.isRefreshingAll.set(false) + console.error(e) + return of(null) + }), + ) + .subscribe(() => { + this.isRefreshingAll.set(false) + }) + } + + onRemove(e: MouseEvent, id: string): void { + e.stopPropagation() + + const dialogRef = this.dialog.open(ConfirmationDialog, { + data: { + title: 'Delete feed?', + message: 'Are you sure you want to delete this feed?', + confirmButtonText: 'Delete', + }, + }) + + dialogRef.afterClosed().subscribe((agree) => { + if (agree) { + this.feedService + .deleteOneFeed({ feedId: id }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { + this.pageService.setCurrentPage(1) + }) + } + }) + } + + onEdit(e: MouseEvent, feed: Feed): void { + e.stopPropagation() + const dialogRef = this.dialog.open(FeedEditForm, { + data: { feed }, + }) + + dialogRef.afterClosed().subscribe((result) => { + console.log('The dialog was closed', result) + this.pageService.setCurrentPage(1) + }) + } +} diff --git a/src/app/pages/home/home-page.component.html b/src/app/pages/home/home-page.component.html deleted file mode 100644 index e2da575..0000000 --- a/src/app/pages/home/home-page.component.html +++ /dev/null @@ -1,103 +0,0 @@ - - - - view_day - - view_agenda - - - - {{ readFilter() ? 'mark_email_unread' : 'mark_email_read' }} - - bookmark - - - - -@defer { - @for (a of articles(); track a._id) { - - - {{ a.title }} - Published: {{ a.isoDate | date: 'short' }} - - @if (display() !== 'title') { - - - - } - - - link - - - open_in_new - - - {{ a.read ? 'mark_email_read' : 'mark_email_unread' }} - - - bookmark - - - - @for (t of userTags(); track t._id) { - {{ t.name }} - - } - - - - } @empty { - No articles found - } -} @loading (minimum 0.5s) { - -} - - - @if (articles().length > 0) { - - } - - - - check - Skip visible - - - done_all - Skip all - - - diff --git a/src/app/pages/home/home-page.component.ts b/src/app/pages/home/home-page.component.ts deleted file mode 100644 index 7a96830..0000000 --- a/src/app/pages/home/home-page.component.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { FeedService } from '../../services/feed-service' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { HttpErrorResponse } from '@angular/common/http' -import { catchError, of } from 'rxjs' -import { DatePipe } from '@angular/common' -import { MatButton, MatIconButton } from '@angular/material/button' -import { MatProgressBar } from '@angular/material/progress-bar' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatButtonToggleModule } from '@angular/material/button-toggle' -import { MatIconModule } from '@angular/material/icon' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { Router, RouterLink } from '@angular/router' -import { Article } from '../../entities/article/article.types' -import { MatPaginatorModule, PageEvent } from '@angular/material/paginator' -import { TagService } from '../../services/tag-service' -import { Tag } from '../../entities/tag/tag.types' -import { MatChipOption, MatChipSet } from '@angular/material/chips' -import { TitleService } from '../../services/title-service' -import { DomSanitizer, SafeHtml } from '@angular/platform-browser' - -@Component({ - selector: 'app-home', - imports: [ - MatCardModule, - DatePipe, - MatProgressBar, - MatToolbarModule, - MatButtonToggleModule, - MatIconModule, - RowSpacer, - MatIconButton, - RouterLink, - MatPaginatorModule, - MatChipOption, - MatChipSet, - MatButton, - ], - templateUrl: './home-page.component.html', - styleUrl: './home-page.component.css', -}) -export class HomePage implements OnInit { - feedService = inject(FeedService) - router = inject(Router) - destroyRef = inject(DestroyRef) - tagService = inject(TagService) - titleService = inject(TitleService) - htmlSanitizer = inject(DomSanitizer) - - articles = signal([]) - articleIds = computed(() => this.articles().map(({ _id }) => _id)) - display = signal<'title' | 'short'>('title') - - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - readFilter = signal(true) - favFilter = signal(false) - - favTagId = signal('') - userTags = signal([]) - - ngOnInit() { - this.getData() - this.tagService.$defaultTags - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((tags) => { - this.favTagId.set(tags?.find((t) => t.name === 'fav')?._id || '') - }) - this.tagService - .getAllTags({}) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((tags) => { - if (tags?.result) { - this.userTags.set(tags.result.filter((t) => t.userId !== 'all')) - } - }) - } - - getData() { - const filters: Record = {} - - if (this.readFilter()) { - filters['read'] = false - } - - if (this.favFilter()) { - filters['tags'] = this.favTagId() - } - - this.feedService - .getAllArticles({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - filters, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.articles.set(result.result) - this.totalResults.set(result.total) - this.titleService.setTitle(`News: ${result.total} articles`) - } else { - this.titleService.setTitle('News') - } - }) - } - - toggleDisplay(display: 'title' | 'short') { - this.display.set(display) - } - - safeHtml(html: string): SafeHtml { - return this.htmlSanitizer.bypassSecurityTrustHtml(html) - } - - async onArticleClick(article: Article) { - await this.router.navigate(['subscription', article.subscriptionId, 'article', article._id]) - } - - markAsRead(article: Article, event: MouseEvent) { - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { - read: !article.read, - }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, read: !a.read } : a)) - } - return prev - }) - }) - } - } - - markManyAsRead({ - articleIds, - read, - all, - }: { - articleIds?: string[] - read: boolean - all?: boolean - }) { - this.feedService - .changeManyArticles({ - ids: articleIds, - article: { read }, - all, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe(() => { - this.getData() - }) - } - - onAddToBookmarks(article: Article, event: MouseEvent) { - const existingTag = article.tags.find((t) => t === this.favTagId()) - const tags = existingTag - ? [...article.tags].filter((t) => t !== this.favTagId()) - : [...article.tags, this.favTagId()] - - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { tags }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, tags } : a)) - } - return prev - }) - }) - } - } - - paginationHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getData() - } - - filterHandler(filter: 'read' | 'fav') { - if (filter === 'read') { - this.readFilter.update((prev) => !prev) - } else { - this.favFilter.update((prev) => !prev) - } - this.getData() - } - - tagHandler(article: Article, tag: Tag, event: MouseEvent) { - const existingTag = article.tags.find((t) => t === tag._id) - const tags = existingTag - ? [...article.tags].filter((t) => t !== tag._id) - : [...article.tags, tag._id] - - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { tags }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, tags } : a)) - } - return prev - }) - }) - } - } -} diff --git a/src/app/pages/not-found/not-found-page.component.css b/src/app/pages/not-found/not-found-page.component.css index e69de29..a9bc56f 100644 --- a/src/app/pages/not-found/not-found-page.component.css +++ b/src/app/pages/not-found/not-found-page.component.css @@ -0,0 +1,13 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 1rem +} + +.emoji { + font-size: 20cqw; + font-weight: bold; + color: var(--mat-sys-secondary); +} diff --git a/src/app/pages/not-found/not-found-page.component.html b/src/app/pages/not-found/not-found-page.component.html index 8071020..5d3bd69 100644 --- a/src/app/pages/not-found/not-found-page.component.html +++ b/src/app/pages/not-found/not-found-page.component.html @@ -1 +1,2 @@ -not-found works! +Page not found +\(o_o)/ diff --git a/src/app/pages/status-page/status-page.css b/src/app/pages/status-page/status-page.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/status-page/status-page.html b/src/app/pages/status-page/status-page.html new file mode 100644 index 0000000..d7ddc81 --- /dev/null +++ b/src/app/pages/status-page/status-page.html @@ -0,0 +1 @@ + diff --git a/src/app/pages/status-page/status-page.ts b/src/app/pages/status-page/status-page.ts new file mode 100644 index 0000000..851f3fe --- /dev/null +++ b/src/app/pages/status-page/status-page.ts @@ -0,0 +1,18 @@ +import { Component, inject, OnInit } from '@angular/core' +import { HealthStatus } from '../../components/health-status/health-status' +import { TitleService } from '../../services/title-service' + +@Component({ + selector: 'app-status-page', + imports: [HealthStatus], + templateUrl: './status-page.html', + styleUrl: './status-page.css', +}) +export class StatusPage implements OnInit { + private readonly titleService = inject(TitleService) + + ngOnInit() { + this.titleService.setTitle('User') + this.titleService.setSubtitle(null) + } +} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.css b/src/app/pages/subscriptions/subscriptions-page.component.css deleted file mode 100644 index 67cb9da..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.css +++ /dev/null @@ -1,9 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 1rem; -} - -:host .mat-mdc-paginator { - background: transparent; -} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.html b/src/app/pages/subscriptions/subscriptions-page.component.html deleted file mode 100644 index 10aabda..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - add - - - @if (feeds().length > 0) { - - } - - -@defer { - @for (f of feeds(); track f._id) { - - - {{ f.title }} - - - delete - - - - Link: {{ f.link }} - Articles: {{ f.articles.length }} - - - } @empty { - No subscriptions found - } -} @loading (minimum 0.5s) { - -} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.ts b/src/app/pages/subscriptions/subscriptions-page.component.ts deleted file mode 100644 index 720d0ed..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Component, DestroyRef, inject, OnInit, signal, viewChild } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { FeedService } from '../../services/feed-service' -import { catchError, of } from 'rxjs' -import { HttpErrorResponse } from '@angular/common/http' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatIconModule } from '@angular/material/icon' -import { MatIconButton } from '@angular/material/button' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { MatDialog, MatDialogModule } from '@angular/material/dialog' -import { SubscriptionAddForm } from '../../components/subscription-add-form/subscription-add-form' -import { Feed } from '../../entities/feed/feed.types' -import { MatProgressBar } from '@angular/material/progress-bar' -import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator' - -@Component({ - selector: 'app-subscriptions', - imports: [ - MatCardModule, - MatToolbarModule, - MatIconModule, - MatIconButton, - RowSpacer, - MatDialogModule, - MatProgressBar, - MatPaginatorModule, - ], - templateUrl: './subscriptions-page.component.html', - styleUrl: './subscriptions-page.component.css', -}) -export class SubscriptionsPage implements OnInit { - feedService = inject(FeedService) - readonly dialog = inject(MatDialog) - destroyRef = inject(DestroyRef) - - feeds = signal([]) - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - ngOnInit() { - this.getData() - } - - getData() { - this.feedService - .getAllSubscriptions({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.currentPage.set(1) - this.feeds.set(result.result) - this.totalResults.set(result.total) - } - }) - } - - onAdd() { - const dialogRef = this.dialog.open(SubscriptionAddForm) - - dialogRef.afterClosed().subscribe((result) => { - if (result) { - this.getData() - } - }) - } - - paginator = viewChild(MatPaginator) - - onRemove(id: string) { - this.feedService - .deleteOneSubscription({ subscriptionId: id }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - this.getData() - this.paginator()?.firstPage() - }) - } - - paginationHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getData() - } -} diff --git a/src/app/pages/tags-page/tags-page.css b/src/app/pages/tags-page/tags-page.css new file mode 100644 index 0000000..31b5e77 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.css @@ -0,0 +1,11 @@ +:host { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 1rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); +} + +input { + margin-top: 2rem; +} diff --git a/src/app/pages/tags-page/tags-page.html b/src/app/pages/tags-page/tags-page.html new file mode 100644 index 0000000..50596d6 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.html @@ -0,0 +1,47 @@ + + @defer { + User tags + + + @for (t of userTags(); track t._id) { + + + edit + + {{ t.name }} + + cancel + + + } + + + + + App tags + + @for (t of appTags(); track t._id) { + + {{ t.name }} + + } + + + + } + + + diff --git a/src/app/pages/tags-page/tags-page.ts b/src/app/pages/tags-page/tags-page.ts new file mode 100644 index 0000000..c376202 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.ts @@ -0,0 +1,153 @@ +import { Component, DestroyRef, inject, linkedSignal, OnInit, signal } from '@angular/core' +import { TagService } from '../../services/tag-service' +import { Tag } from '../../entities/tag/tag.types' +import { HttpErrorResponse } from '@angular/common/http' +import { catchError, combineLatest, of, switchMap } from 'rxjs' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { MatIconModule } from '@angular/material/icon' +import { MatChipEditedEvent, MatChipInputEvent, MatChipsModule } from '@angular/material/chips' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { TitleService } from '../../services/title-service' +import { Router } from '@angular/router' +import { MatFormFieldModule } from '@angular/material/form-field' +import { COMMA, ENTER } from '@angular/cdk/keycodes' +import { MatBottomSheet } from '@angular/material/bottom-sheet' +import { BottomErrorSheet } from '../../components/bottom-error-sheet/bottom-error-sheet' +import { MatDialog } from '@angular/material/dialog' +import { ConfirmationDialog } from '../../components/confirmation-dialog/confirmation-dialog' + +@Component({ + selector: 'app-tags-page', + imports: [MatIconModule, Paginator, MatFormFieldModule, MatChipsModule], + templateUrl: './tags-page.html', + styleUrl: './tags-page.css', +}) +export class TagsPage implements OnInit { + private readonly tagsService = inject(TagService) + private readonly pageService = inject(PageService) + private readonly destroyRef = inject(DestroyRef) + private readonly titleService = inject(TitleService) + private readonly router = inject(Router) + private readonly errorSheet = inject(MatBottomSheet) + private readonly dialog = inject(MatDialog) + + public readonly separatorKeysCodes = [ENTER, COMMA] as const + + private readonly tags = signal([]) + readonly userTags = linkedSignal(() => { + return this.tags().filter((t) => t.userId !== 'all') + }) + readonly appTags = linkedSignal(() => { + return this.tags().filter((t) => t.userId === 'all') + }) + + ngOnInit() { + combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]) + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(([perPage, pageNumber]) => { + return this.tagsService.getAllTags({ + pagination: { + perPage, + pageNumber, + }, + }) + }), + ) + .subscribe((result) => { + if (result) { + this.pageService.setTotalResults(result.total) + this.tags.set(result.result) + this.titleService.setTitle('Tags') + this.titleService.setSubtitle(null) + } + }) + } + + onAdd(e: MatChipInputEvent) { + const name = (e.value || '').trim() + + if (!name) { + return + } + + this.tagsService + .addOneTag({ name }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + this.errorSheet.open(BottomErrorSheet, { data: { error: error.error.message } }) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + e.chipInput.clear() + } + }) + } + + onEdit(tag: Tag, e: MatChipEditedEvent) { + const newName = (e.value || '').trim() + const currentName = tag.name + + if (!newName) { + return + } + + this.tagsService + .changeOneTag({ newName, currentName }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + this.errorSheet.open(BottomErrorSheet, { data: { error: error.error.message } }) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + } + }) + } + + onRemove(tag: Tag) { + const dialogRef = this.dialog.open(ConfirmationDialog, { + data: { + title: 'Delete tag', + message: `Are you sure you want to delete the tag "${tag.name}"?`, + confirmButtonText: 'Delete', + }, + }) + + dialogRef.afterClosed().subscribe((agree) => { + if (agree) { + this.tagsService + .deleteOneTag({ name: tag.name }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + } + }) + } + }) + } + + onClick(name: string) { + if (!name) { + return + } + this.router.navigate(['/articles'], { queryParams: { tag: name } }) + } +} diff --git a/src/app/pages/tags/tags-page.component.css b/src/app/pages/tags/tags-page.component.css deleted file mode 100644 index 1556b2c..0000000 --- a/src/app/pages/tags/tags-page.component.css +++ /dev/null @@ -1,5 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 1rem; -} diff --git a/src/app/pages/tags/tags-page.component.html b/src/app/pages/tags/tags-page.component.html deleted file mode 100644 index 1e851b0..0000000 --- a/src/app/pages/tags/tags-page.component.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - add - - - @if (tags().length > 0) { - - } - - -@defer { - - @for (t of tags(); track t._id) { - - {{ t.name }} - @if (t.userId !== 'all') { - - cancel - - } - - } - -} diff --git a/src/app/pages/tags/tags-page.component.ts b/src/app/pages/tags/tags-page.component.ts deleted file mode 100644 index d6866e4..0000000 --- a/src/app/pages/tags/tags-page.component.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Component, DestroyRef, inject, OnInit, signal, viewChild } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { TagService } from '../../services/tag-service' -import { Tag } from '../../entities/tag/tag.types' -import { HttpErrorResponse } from '@angular/common/http' -import { catchError, of } from 'rxjs' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { MatPaginator, PageEvent } from '@angular/material/paginator' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatIconButton } from '@angular/material/button' -import { MatIconModule } from '@angular/material/icon' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { MatDialog } from '@angular/material/dialog' -import { TagAddForm } from '../../components/tag-add-form/tag-add-form' -import { MatChipRemove, MatChipRow, MatChipSet } from '@angular/material/chips' - -@Component({ - selector: 'app-tags', - imports: [ - MatCardModule, - MatToolbarModule, - MatIconButton, - MatIconModule, - RowSpacer, - MatPaginator, - MatChipRow, - MatChipRemove, - MatChipSet, - ], - templateUrl: './tags-page.component.html', - styleUrl: './tags-page.component.css', -}) -export class TagsPage implements OnInit { - tagsService = inject(TagService) - destroyRef = inject(DestroyRef) - readonly dialog = inject(MatDialog) - - tags = signal([]) - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - getDate() { - this.tagsService - .getAllTags({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.error(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.currentPage.set(1) - this.tags.set(result.result) - this.totalResults.set(result.total) - } - }) - } - - ngOnInit() { - this.getDate() - } - - paginator = viewChild(MatPaginator) - - onAdd() { - const dialogRef = this.dialog.open(TagAddForm) - - dialogRef.afterClosed().subscribe((result) => { - if (result) { - this.getDate() - } - }) - } - - onRemove(tag: Tag) { - this.tagsService - .deleteOneTag({ name: tag.name }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.error(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe(() => { - this.getDate() - this.paginator()?.firstPage() - }) - } - - paginatorHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getDate() - } -} diff --git a/src/app/pages/user-page/user-page.css b/src/app/pages/user-page/user-page.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/user-page/user-page.html b/src/app/pages/user-page/user-page.html new file mode 100644 index 0000000..49a288a --- /dev/null +++ b/src/app/pages/user-page/user-page.html @@ -0,0 +1,8 @@ + + + Username: {{ ($currentUser | async)?.user?.login }} + + + Role: {{ ($currentUser | async)?.user?.role }} + + diff --git a/src/app/pages/user-page/user-page.ts b/src/app/pages/user-page/user-page.ts new file mode 100644 index 0000000..5aa3f70 --- /dev/null +++ b/src/app/pages/user-page/user-page.ts @@ -0,0 +1,22 @@ +import { Component, inject, OnInit } from '@angular/core' +import { AuthService } from '../../services/auth-service' +import { AsyncPipe } from '@angular/common' +import { TitleService } from '../../services/title-service' + +@Component({ + selector: 'app-user-page', + imports: [AsyncPipe], + templateUrl: './user-page.html', + styleUrl: './user-page.css', +}) +export class UserPage implements OnInit { + private readonly authService = inject(AuthService) + private readonly titleService = inject(TitleService) + + $currentUser = this.authService.$authStatus + + ngOnInit() { + this.titleService.setTitle('User') + this.titleService.setSubtitle(null) + } +} diff --git a/src/app/pipes/link-trim-pipe.ts b/src/app/pipes/link-trim-pipe.ts new file mode 100644 index 0000000..45aeb2b --- /dev/null +++ b/src/app/pipes/link-trim-pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core' + +@Pipe({ + name: 'linkTrim', +}) +export class LinkTrimPipe implements PipeTransform { + transform(value: string, length: number = Infinity): string { + try { + const parsed = URL.parse(value) + if (!parsed) { + throw new Error('Invalid URL') + } + const { host, pathname } = parsed + return `${host}${pathname.slice(0, length) + '...'}` + } catch (e) { + console.error(e) + return value.slice(0, length) + '...' + } + } +} diff --git a/src/app/pipes/safe-html-pipe.ts b/src/app/pipes/safe-html-pipe.ts new file mode 100644 index 0000000..51f0a54 --- /dev/null +++ b/src/app/pipes/safe-html-pipe.ts @@ -0,0 +1,16 @@ +import { inject, Pipe, PipeTransform } from '@angular/core' +import { DomSanitizer, SafeHtml } from '@angular/platform-browser' + +@Pipe({ + name: 'safeHtml', +}) +export class SafeHtmlPipe implements PipeTransform { + sanitizer = inject(DomSanitizer) + + transform(value: string): SafeHtml { + if (!value) { + return '' + } + return this.sanitizer.bypassSecurityTrustHtml(value) + } +} diff --git a/src/app/services/auth-service.ts b/src/app/services/auth-service.ts index 43d1908..1f18117 100644 --- a/src/app/services/auth-service.ts +++ b/src/app/services/auth-service.ts @@ -23,15 +23,58 @@ export class AuthService { $authStatus = this.$$authStatus.asObservable() login({ login, password }: UserDTO) { - return this.httpClient.post(`${environment.api}/auth/login`, { login, password }).pipe( - switchMap((response) => { - this.$$authStatus.next({ authenticated: true, user: response, error: null }) - return of(response) - }), + return this.httpClient + .post(`${environment.api}/auth/login`, { + login: login.trim(), + password: password.trim(), + }) + .pipe( + switchMap((response) => { + localStorage.setItem('user', response._id) + this.$$authStatus.next({ authenticated: true, user: response, error: null }) + return of(response) + }), + catchError((error: HttpErrorResponse) => { + this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) + return of(null) + }), + ) + } + + signup({ password }: { password: string }) { + return this.httpClient + .post(`${environment.api}/auth/signup`, { + password: password.trim(), + }) + .pipe( + switchMap((response) => { + localStorage.setItem('user', response._id) + this.$$authStatus.next({ authenticated: true, user: response, error: null }) + return of(response) + }), + catchError((error: HttpErrorResponse) => { + this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) + return of(null) + }), + ) + } + + logout() { + return this.httpClient.get<{ message: string }>(`${environment.api}/auth/logout`).pipe( catchError((error: HttpErrorResponse) => { - this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) - return of(null) + console.error(error) + return of(false) + }), + switchMap(() => { + localStorage.removeItem('user') + this.$$authStatus.next({ authenticated: false, user: null, error: null }) + return of(true) }), ) } + + updateAuth(user: User) { + this.$$authStatus.next({ authenticated: true, user, error: null }) + localStorage.setItem('user', user._id) + } } diff --git a/src/app/services/feed-service.ts b/src/app/services/feed-service.ts index eb563be..9b3aae7 100644 --- a/src/app/services/feed-service.ts +++ b/src/app/services/feed-service.ts @@ -4,17 +4,16 @@ import { environment } from '../../environments/environment' import { Feed, FeedDTO } from '../entities/feed/feed.types' import { Article, ArticleDTO } from '../entities/article/article.types' import { Paginated, Pagination } from '../entities/base/base.types' -import { TagService } from './tag-service' +import { SortOrder } from '../entities/base/base.enums' @Injectable({ providedIn: 'root', }) export class FeedService { - httpClient = inject(HttpClient) - tagService = inject(TagService) + readonly httpClient = inject(HttpClient) - getAllSubscriptions({ pagination }: { pagination?: Partial }) { - return this.httpClient.get>(`${environment.api}/subscription`, { + getAllFeeds({ pagination }: { pagination?: Partial }) { + return this.httpClient.get>(`${environment.api}/feed`, { params: pagination, }) } @@ -22,18 +21,27 @@ export class FeedService { getAllArticles({ pagination, filters, + sort, }: { pagination?: Partial filters?: { tags?: string; read?: boolean } + sort?: { date: SortOrder } }) { - this.tagService.getDefaultTags() return this.httpClient.get>(`${environment.api}/article`, { - params: { ...pagination, ...filters }, + params: { + ...pagination, + ...filters, + dateSort: sort?.date || SortOrder.Desc, + }, }) } - getOneSubscription({ subscriptionId }: { subscriptionId: string }) { - return this.httpClient.get(`${environment.api}/subscription/${subscriptionId}`) + getOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.get(`${environment.api}/feed/${feedId}`) + } + + changeOneFeed({ id, dto }: { id: string; dto: Partial }) { + return this.httpClient.patch(`${environment.api}/feed/${id}`, dto) } getOneArticle({ articleId }: { articleId: string }) { @@ -63,11 +71,23 @@ export class FeedService { }) } - addOneSubscription({ subscription }: { subscription: FeedDTO }) { - return this.httpClient.post(`${environment.api}/subscription`, subscription) + addOneFeed({ feed }: { feed: FeedDTO }) { + return this.httpClient.post(`${environment.api}/feed`, feed) + } + + deleteOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.delete(`${environment.api}/feed/${feedId}`) + } + + refreshAllFeeds() { + return this.httpClient.get(`${environment.api}/feed/refresh`) + } + + refreshOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.get(`${environment.api}/feed/${feedId}/refresh`) } - deleteOneSubscription({ subscriptionId }: { subscriptionId: string }) { - return this.httpClient.delete(`${environment.api}/subscription/${subscriptionId}`) + getFullText({ articleId }: { articleId: string }) { + return this.httpClient.get<{ fullText: string }>(`${environment.api}/article/${articleId}/full`) } } diff --git a/src/app/services/health-service.ts b/src/app/services/health-service.ts new file mode 100644 index 0000000..f50b578 --- /dev/null +++ b/src/app/services/health-service.ts @@ -0,0 +1,17 @@ +import { inject, Injectable } from '@angular/core' +import { HttpClient } from '@angular/common/http' +import { environment } from '../../environments/environment' + +@Injectable({ + providedIn: 'root', +}) +export class HealthService { + constructor() {} + httpClient = inject(HttpClient) + + getBackendStatus() { + return this.httpClient.get<{ status: string; version: string; uptime: string }>( + `${environment.api}/health`, + ) + } +} diff --git a/src/app/services/page-service.ts b/src/app/services/page-service.ts new file mode 100644 index 0000000..516dbc4 --- /dev/null +++ b/src/app/services/page-service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core' +import { PageDisplay } from '../entities/page/page.enums' +import { BehaviorSubject } from 'rxjs' + +@Injectable({ + providedIn: 'any', +}) +export class PageService { + private $$pageSize = new BehaviorSubject(5) + $pageSize = this.$$pageSize.asObservable() + + private $$currentPage = new BehaviorSubject(1) + $currentPage = this.$$currentPage.asObservable() + + private $$totalResults = new BehaviorSubject(0) + $totalResults = this.$$totalResults.asObservable() + + private $$display = new BehaviorSubject(PageDisplay.Title) + $display = this.$$display.asObservable() + + setCurrentPage(currentPage: number) { + this.$$currentPage.next(currentPage) + } + + setPageSize(pageSize: number) { + this.$$pageSize.next(pageSize) + } + + setTotalResults(totalResults: number) { + this.$$totalResults.next(totalResults) + } + + setDisplay(display: PageDisplay) { + this.$$display.next(display) + } +} diff --git a/src/app/services/tag-service.ts b/src/app/services/tag-service.ts index bc9f128..810f379 100644 --- a/src/app/services/tag-service.ts +++ b/src/app/services/tag-service.ts @@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http' import { Tag } from '../entities/tag/tag.types' import { environment } from '../../environments/environment' import { Paginated, Pagination } from '../entities/base/base.types' -import { BehaviorSubject } from 'rxjs' +import { BehaviorSubject, tap } from 'rxjs' @Injectable({ providedIn: 'root', @@ -18,9 +18,11 @@ export class TagService { $defaultTags = this.$$defaultTags.asObservable() getDefaultTags() { - return this.httpClient.get(`${environment.api}/tag?default=true`).subscribe((tags) => { - this.$$defaultTags.next(tags) - }) + return this.httpClient.get(`${environment.api}/tag?default=true`).pipe( + tap((tags) => { + this.$$defaultTags.next(tags) + }), + ) } getAllTags({ pagination }: { pagination?: Partial }) { @@ -29,6 +31,10 @@ export class TagService { }) } + getOne({ name }: { name: Tag['_id'] }) { + return this.httpClient.get(`${environment.api}/tag/${name}`) + } + addOneTag({ name }: { name: string }) { return this.httpClient.post(`${environment.api}/tag`, { name }) } diff --git a/src/app/services/title-service.ts b/src/app/services/title-service.ts index 8600424..701c846 100644 --- a/src/app/services/title-service.ts +++ b/src/app/services/title-service.ts @@ -11,8 +11,15 @@ export class TitleService { private $$currentTitle = new BehaviorSubject('') $currentTitle = this.$$currentTitle.asObservable() + private $$currentSubtitle = new BehaviorSubject(null) + $currentSubtitle = this.$$currentSubtitle.asObservable() + setTitle(title: string) { this.$$currentTitle.next(title) this.pageTitleService.setTitle(title) } + + setSubtitle(subtitle: string | null) { + this.$$currentSubtitle.next(subtitle) + } } diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 05fac53..3402093 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,5 +1,5 @@ import { Environment } from './environment.types' export const environment: Environment = { - api: 'http://rss-nest:3600/api', + api: 'api', } diff --git a/src/index.html b/src/index.html index 78c77ef..fedac66 100644 --- a/src/index.html +++ b/src/index.html @@ -1,18 +1,28 @@ - - - News - - - - - - - - - + + + News + + + + + + + + + diff --git a/src/styles.css b/src/styles.css index c784d4f..0609480 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,4 +1,5 @@ -/* You can add global styles to this file, and also import other style files */ +@import "@angular/material/prebuilt-themes/cyan-orange.css" (prefers-color-scheme: dark); +@import "@angular/material/prebuilt-themes/azure-blue.css" (prefers-color-scheme: light); html, body { @@ -20,3 +21,29 @@ body { mat-toolbar-row { height: auto !important; } + +.external { + display: flex; + flex-direction: column; +} + +.content_layout { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.content_layout img { + max-height: 20ch; + max-width: 70cqw; + align-self: center; + border-radius: 1rem; +} + +.content_layout a { + color: inherit; +} + +.highlighted { + color: var(--mat-sys-primary) !important; +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..f723ed7 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export { scrollUp } from './scrollUp' diff --git a/src/utils/scrollUp.ts b/src/utils/scrollUp.ts new file mode 100644 index 0000000..2d43ce8 --- /dev/null +++ b/src/utils/scrollUp.ts @@ -0,0 +1,7 @@ +export function scrollUp({ trigger }: { trigger: boolean }) { + if (!trigger) { + return + } + const page = document.querySelector('.page-content') + page?.scroll({ top: 0, behavior: 'smooth' }) +}
([]) + + favTagId = signal('') + userTags = signal([]) + + ngOnInit() { + this.tagService + .getDefaultTags() + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((tags) => { + const favTag = tags?.find((t) => t.name === 'fav')?._id + if (!favTag) { + return of(null) + } + return combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]).pipe( + switchMap(([perPage, pageNumber]) => { + this.favTagId.set(favTag) + const filters = { tags: favTag } + return this.feedService.getAllArticles({ + pagination: { + perPage, + pageNumber, + }, + filters, + }) + }), + ) + }), + ) + .subscribe((result) => { + if (result) { + this.articles.set(result.result) + this.pageService.setTotalResults(result.total) + this.titleService.setTitle(`Bookmarks: ${result.total}`) + } else { + this.titleService.setTitle('Bookmarks') + } + this.titleService.setSubtitle(null) + }) + + this.tagService + .getAllTags({}) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + ) + .subscribe((tags) => { + if (tags?.result) { + this.userTags.set(tags.result.filter((t) => t.userId !== 'all')) + } + }) + } +} diff --git a/src/app/pages/feeds-page/feeds-page.css b/src/app/pages/feeds-page/feeds-page.css new file mode 100644 index 0000000..b25979e --- /dev/null +++ b/src/app/pages/feeds-page/feeds-page.css @@ -0,0 +1,24 @@ +:host { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 1rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); +} + +:host .mat-mdc-paginator { + background: transparent; +} + +.card { + cursor: pointer; +} + +.link { + font-style: italic; + font-size: smaller; +} + +.description { + color: var(--mat-sys-secondary); +} diff --git a/src/app/pages/feeds-page/feeds-page.html b/src/app/pages/feeds-page/feeds-page.html new file mode 100644 index 0000000..ebf9186 --- /dev/null +++ b/src/app/pages/feeds-page/feeds-page.html @@ -0,0 +1,75 @@ + + + + add + + + + refresh + + + + + + @if (isRefreshingAll()) { + + } @else { + @defer { + @for (f of feeds(); track f._id) { + + + {{ f.title }} + + + delete + + + refresh + + + edit + + + + Link: {{ f.link | linkTrim:15 }} + {{ f.description }} + + library_books + + + + } @empty { + No feeds found + } + } @loading (minimum 0.5s) { + + } + } + + + diff --git a/src/app/pages/feeds-page/feeds-page.ts b/src/app/pages/feeds-page/feeds-page.ts new file mode 100644 index 0000000..7606309 --- /dev/null +++ b/src/app/pages/feeds-page/feeds-page.ts @@ -0,0 +1,182 @@ +import { Component, DestroyRef, effect, inject, OnInit, signal } from '@angular/core' +import { MatCardModule } from '@angular/material/card' +import { FeedService } from '../../services/feed-service' +import { catchError, combineLatest, of, switchMap } from 'rxjs' +import { HttpErrorResponse } from '@angular/common/http' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { MatToolbarModule } from '@angular/material/toolbar' +import { MatIconModule } from '@angular/material/icon' +import { MatIconButton } from '@angular/material/button' +import { RowSpacer } from '../../components/row-spacer/row-spacer' +import { MatDialog, MatDialogModule } from '@angular/material/dialog' +import { Feed } from '../../entities/feed/feed.types' +import { MatProgressBar } from '@angular/material/progress-bar' +import { MatPaginatorModule } from '@angular/material/paginator' +import { LinkTrimPipe } from '../../pipes/link-trim-pipe' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { TitleService } from '../../services/title-service' +import { scrollUp } from '../../../utils' +import { RouterLink } from '@angular/router' +import { MatBadgeModule } from '@angular/material/badge' +import { ConfirmationDialog } from '../../components/confirmation-dialog/confirmation-dialog' +import { FeedAddForm } from '../../components/feed-add-form/feed-add-form' +import { FeedEditForm } from '../../components/feed-edit-form/feed-edit-form' +import { MatBottomSheet } from '@angular/material/bottom-sheet' +import { BottomErrorSheet } from '../../components/bottom-error-sheet/bottom-error-sheet' + +@Component({ + selector: 'app-feed-page', + imports: [ + MatCardModule, + MatToolbarModule, + MatIconModule, + MatIconButton, + RowSpacer, + MatDialogModule, + MatProgressBar, + MatPaginatorModule, + LinkTrimPipe, + Paginator, + RouterLink, + MatBadgeModule, + ], + templateUrl: './feeds-page.html', + styleUrl: './feeds-page.css', +}) +export class FeedsPage implements OnInit { + constructor() { + effect(() => { + scrollUp({ trigger: !!this.feeds().length }) + }) + } + + private readonly feedService = inject(FeedService) + private readonly pageService = inject(PageService) + private readonly dialog = inject(MatDialog) + private readonly destroyRef = inject(DestroyRef) + private readonly titleService = inject(TitleService) + private readonly bottomError = inject(MatBottomSheet) + + readonly feeds = signal([]) + readonly isRefreshing = signal>({}) + readonly isRefreshingAll = signal(false) + + ngOnInit(): void { + combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]) + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(([perPage, pageNumber]) => { + return this.feedService.getAllFeeds({ + pagination: { + perPage, + pageNumber, + }, + }) + }), + ) + .subscribe((result) => { + if (result) { + this.pageService.setTotalResults(result.total) + this.feeds.set(result.result) + } + this.titleService.setTitle('Feeds') + this.titleService.setSubtitle(null) + }) + } + + onAdd(): void { + const dialogRef = this.dialog.open(FeedAddForm) + + dialogRef.afterClosed().subscribe((result) => { + console.log('The dialog was closed', result) + this.pageService.setCurrentPage(1) + }) + } + + onRefreshOne(e: MouseEvent, feedId: string): void { + e.stopPropagation() + this.isRefreshing.update((prev) => ({ + ...prev, + [feedId]: true, + })) + this.feedService + .refreshOneFeed({ feedId }) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + this.isRefreshing.update((prev) => ({ + ...prev, + [feedId]: false, + })) + this.bottomError.open(BottomErrorSheet, { data: { error: e.error.message } }) + console.error(e) + return of(null) + }), + ) + .subscribe(() => { + this.isRefreshing.update((prev) => ({ + ...prev, + [feedId]: false, + })) + }) + } + + onRefreshAll(): void { + this.isRefreshingAll.set(true) + this.feedService + .refreshAllFeeds() + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((e) => { + this.isRefreshingAll.set(false) + console.error(e) + return of(null) + }), + ) + .subscribe(() => { + this.isRefreshingAll.set(false) + }) + } + + onRemove(e: MouseEvent, id: string): void { + e.stopPropagation() + + const dialogRef = this.dialog.open(ConfirmationDialog, { + data: { + title: 'Delete feed?', + message: 'Are you sure you want to delete this feed?', + confirmButtonText: 'Delete', + }, + }) + + dialogRef.afterClosed().subscribe((agree) => { + if (agree) { + this.feedService + .deleteOneFeed({ feedId: id }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.log(error) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { + this.pageService.setCurrentPage(1) + }) + } + }) + } + + onEdit(e: MouseEvent, feed: Feed): void { + e.stopPropagation() + const dialogRef = this.dialog.open(FeedEditForm, { + data: { feed }, + }) + + dialogRef.afterClosed().subscribe((result) => { + console.log('The dialog was closed', result) + this.pageService.setCurrentPage(1) + }) + } +} diff --git a/src/app/pages/home/home-page.component.html b/src/app/pages/home/home-page.component.html deleted file mode 100644 index e2da575..0000000 --- a/src/app/pages/home/home-page.component.html +++ /dev/null @@ -1,103 +0,0 @@ - - - - view_day - - view_agenda - - - - {{ readFilter() ? 'mark_email_unread' : 'mark_email_read' }} - - bookmark - - - - -@defer { - @for (a of articles(); track a._id) { - - - {{ a.title }} - Published: {{ a.isoDate | date: 'short' }} - - @if (display() !== 'title') { - - - - } - - - link - - - open_in_new - - - {{ a.read ? 'mark_email_read' : 'mark_email_unread' }} - - - bookmark - - - - @for (t of userTags(); track t._id) { - {{ t.name }} - - } - - - - } @empty { - No articles found - } -} @loading (minimum 0.5s) { - -} - - - @if (articles().length > 0) { - - } - - - - check - Skip visible - - - done_all - Skip all - - - diff --git a/src/app/pages/home/home-page.component.ts b/src/app/pages/home/home-page.component.ts deleted file mode 100644 index 7a96830..0000000 --- a/src/app/pages/home/home-page.component.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { FeedService } from '../../services/feed-service' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { HttpErrorResponse } from '@angular/common/http' -import { catchError, of } from 'rxjs' -import { DatePipe } from '@angular/common' -import { MatButton, MatIconButton } from '@angular/material/button' -import { MatProgressBar } from '@angular/material/progress-bar' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatButtonToggleModule } from '@angular/material/button-toggle' -import { MatIconModule } from '@angular/material/icon' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { Router, RouterLink } from '@angular/router' -import { Article } from '../../entities/article/article.types' -import { MatPaginatorModule, PageEvent } from '@angular/material/paginator' -import { TagService } from '../../services/tag-service' -import { Tag } from '../../entities/tag/tag.types' -import { MatChipOption, MatChipSet } from '@angular/material/chips' -import { TitleService } from '../../services/title-service' -import { DomSanitizer, SafeHtml } from '@angular/platform-browser' - -@Component({ - selector: 'app-home', - imports: [ - MatCardModule, - DatePipe, - MatProgressBar, - MatToolbarModule, - MatButtonToggleModule, - MatIconModule, - RowSpacer, - MatIconButton, - RouterLink, - MatPaginatorModule, - MatChipOption, - MatChipSet, - MatButton, - ], - templateUrl: './home-page.component.html', - styleUrl: './home-page.component.css', -}) -export class HomePage implements OnInit { - feedService = inject(FeedService) - router = inject(Router) - destroyRef = inject(DestroyRef) - tagService = inject(TagService) - titleService = inject(TitleService) - htmlSanitizer = inject(DomSanitizer) - - articles = signal([]) - articleIds = computed(() => this.articles().map(({ _id }) => _id)) - display = signal<'title' | 'short'>('title') - - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - readFilter = signal(true) - favFilter = signal(false) - - favTagId = signal('') - userTags = signal([]) - - ngOnInit() { - this.getData() - this.tagService.$defaultTags - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((tags) => { - this.favTagId.set(tags?.find((t) => t.name === 'fav')?._id || '') - }) - this.tagService - .getAllTags({}) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((tags) => { - if (tags?.result) { - this.userTags.set(tags.result.filter((t) => t.userId !== 'all')) - } - }) - } - - getData() { - const filters: Record = {} - - if (this.readFilter()) { - filters['read'] = false - } - - if (this.favFilter()) { - filters['tags'] = this.favTagId() - } - - this.feedService - .getAllArticles({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - filters, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.articles.set(result.result) - this.totalResults.set(result.total) - this.titleService.setTitle(`News: ${result.total} articles`) - } else { - this.titleService.setTitle('News') - } - }) - } - - toggleDisplay(display: 'title' | 'short') { - this.display.set(display) - } - - safeHtml(html: string): SafeHtml { - return this.htmlSanitizer.bypassSecurityTrustHtml(html) - } - - async onArticleClick(article: Article) { - await this.router.navigate(['subscription', article.subscriptionId, 'article', article._id]) - } - - markAsRead(article: Article, event: MouseEvent) { - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { - read: !article.read, - }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, read: !a.read } : a)) - } - return prev - }) - }) - } - } - - markManyAsRead({ - articleIds, - read, - all, - }: { - articleIds?: string[] - read: boolean - all?: boolean - }) { - this.feedService - .changeManyArticles({ - ids: articleIds, - article: { read }, - all, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe(() => { - this.getData() - }) - } - - onAddToBookmarks(article: Article, event: MouseEvent) { - const existingTag = article.tags.find((t) => t === this.favTagId()) - const tags = existingTag - ? [...article.tags].filter((t) => t !== this.favTagId()) - : [...article.tags, this.favTagId()] - - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { tags }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, tags } : a)) - } - return prev - }) - }) - } - } - - paginationHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getData() - } - - filterHandler(filter: 'read' | 'fav') { - if (filter === 'read') { - this.readFilter.update((prev) => !prev) - } else { - this.favFilter.update((prev) => !prev) - } - this.getData() - } - - tagHandler(article: Article, tag: Tag, event: MouseEvent) { - const existingTag = article.tags.find((t) => t === tag._id) - const tags = existingTag - ? [...article.tags].filter((t) => t !== tag._id) - : [...article.tags, tag._id] - - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { tags }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, tags } : a)) - } - return prev - }) - }) - } - } -} diff --git a/src/app/pages/not-found/not-found-page.component.css b/src/app/pages/not-found/not-found-page.component.css index e69de29..a9bc56f 100644 --- a/src/app/pages/not-found/not-found-page.component.css +++ b/src/app/pages/not-found/not-found-page.component.css @@ -0,0 +1,13 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 1rem +} + +.emoji { + font-size: 20cqw; + font-weight: bold; + color: var(--mat-sys-secondary); +} diff --git a/src/app/pages/not-found/not-found-page.component.html b/src/app/pages/not-found/not-found-page.component.html index 8071020..5d3bd69 100644 --- a/src/app/pages/not-found/not-found-page.component.html +++ b/src/app/pages/not-found/not-found-page.component.html @@ -1 +1,2 @@ -not-found works! +Page not found +\(o_o)/ diff --git a/src/app/pages/status-page/status-page.css b/src/app/pages/status-page/status-page.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/status-page/status-page.html b/src/app/pages/status-page/status-page.html new file mode 100644 index 0000000..d7ddc81 --- /dev/null +++ b/src/app/pages/status-page/status-page.html @@ -0,0 +1 @@ + diff --git a/src/app/pages/status-page/status-page.ts b/src/app/pages/status-page/status-page.ts new file mode 100644 index 0000000..851f3fe --- /dev/null +++ b/src/app/pages/status-page/status-page.ts @@ -0,0 +1,18 @@ +import { Component, inject, OnInit } from '@angular/core' +import { HealthStatus } from '../../components/health-status/health-status' +import { TitleService } from '../../services/title-service' + +@Component({ + selector: 'app-status-page', + imports: [HealthStatus], + templateUrl: './status-page.html', + styleUrl: './status-page.css', +}) +export class StatusPage implements OnInit { + private readonly titleService = inject(TitleService) + + ngOnInit() { + this.titleService.setTitle('User') + this.titleService.setSubtitle(null) + } +} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.css b/src/app/pages/subscriptions/subscriptions-page.component.css deleted file mode 100644 index 67cb9da..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.css +++ /dev/null @@ -1,9 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 1rem; -} - -:host .mat-mdc-paginator { - background: transparent; -} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.html b/src/app/pages/subscriptions/subscriptions-page.component.html deleted file mode 100644 index 10aabda..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - add - - - @if (feeds().length > 0) { - - } - - -@defer { - @for (f of feeds(); track f._id) { - - - {{ f.title }} - - - delete - - - - Link: {{ f.link }} - Articles: {{ f.articles.length }} - - - } @empty { - No subscriptions found - } -} @loading (minimum 0.5s) { - -} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.ts b/src/app/pages/subscriptions/subscriptions-page.component.ts deleted file mode 100644 index 720d0ed..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Component, DestroyRef, inject, OnInit, signal, viewChild } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { FeedService } from '../../services/feed-service' -import { catchError, of } from 'rxjs' -import { HttpErrorResponse } from '@angular/common/http' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatIconModule } from '@angular/material/icon' -import { MatIconButton } from '@angular/material/button' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { MatDialog, MatDialogModule } from '@angular/material/dialog' -import { SubscriptionAddForm } from '../../components/subscription-add-form/subscription-add-form' -import { Feed } from '../../entities/feed/feed.types' -import { MatProgressBar } from '@angular/material/progress-bar' -import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator' - -@Component({ - selector: 'app-subscriptions', - imports: [ - MatCardModule, - MatToolbarModule, - MatIconModule, - MatIconButton, - RowSpacer, - MatDialogModule, - MatProgressBar, - MatPaginatorModule, - ], - templateUrl: './subscriptions-page.component.html', - styleUrl: './subscriptions-page.component.css', -}) -export class SubscriptionsPage implements OnInit { - feedService = inject(FeedService) - readonly dialog = inject(MatDialog) - destroyRef = inject(DestroyRef) - - feeds = signal([]) - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - ngOnInit() { - this.getData() - } - - getData() { - this.feedService - .getAllSubscriptions({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.currentPage.set(1) - this.feeds.set(result.result) - this.totalResults.set(result.total) - } - }) - } - - onAdd() { - const dialogRef = this.dialog.open(SubscriptionAddForm) - - dialogRef.afterClosed().subscribe((result) => { - if (result) { - this.getData() - } - }) - } - - paginator = viewChild(MatPaginator) - - onRemove(id: string) { - this.feedService - .deleteOneSubscription({ subscriptionId: id }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - this.getData() - this.paginator()?.firstPage() - }) - } - - paginationHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getData() - } -} diff --git a/src/app/pages/tags-page/tags-page.css b/src/app/pages/tags-page/tags-page.css new file mode 100644 index 0000000..31b5e77 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.css @@ -0,0 +1,11 @@ +:host { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 1rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); +} + +input { + margin-top: 2rem; +} diff --git a/src/app/pages/tags-page/tags-page.html b/src/app/pages/tags-page/tags-page.html new file mode 100644 index 0000000..50596d6 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.html @@ -0,0 +1,47 @@ + + @defer { + User tags + + + @for (t of userTags(); track t._id) { + + + edit + + {{ t.name }} + + cancel + + + } + + + + + App tags + + @for (t of appTags(); track t._id) { + + {{ t.name }} + + } + + + + } + + + diff --git a/src/app/pages/tags-page/tags-page.ts b/src/app/pages/tags-page/tags-page.ts new file mode 100644 index 0000000..c376202 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.ts @@ -0,0 +1,153 @@ +import { Component, DestroyRef, inject, linkedSignal, OnInit, signal } from '@angular/core' +import { TagService } from '../../services/tag-service' +import { Tag } from '../../entities/tag/tag.types' +import { HttpErrorResponse } from '@angular/common/http' +import { catchError, combineLatest, of, switchMap } from 'rxjs' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { MatIconModule } from '@angular/material/icon' +import { MatChipEditedEvent, MatChipInputEvent, MatChipsModule } from '@angular/material/chips' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { TitleService } from '../../services/title-service' +import { Router } from '@angular/router' +import { MatFormFieldModule } from '@angular/material/form-field' +import { COMMA, ENTER } from '@angular/cdk/keycodes' +import { MatBottomSheet } from '@angular/material/bottom-sheet' +import { BottomErrorSheet } from '../../components/bottom-error-sheet/bottom-error-sheet' +import { MatDialog } from '@angular/material/dialog' +import { ConfirmationDialog } from '../../components/confirmation-dialog/confirmation-dialog' + +@Component({ + selector: 'app-tags-page', + imports: [MatIconModule, Paginator, MatFormFieldModule, MatChipsModule], + templateUrl: './tags-page.html', + styleUrl: './tags-page.css', +}) +export class TagsPage implements OnInit { + private readonly tagsService = inject(TagService) + private readonly pageService = inject(PageService) + private readonly destroyRef = inject(DestroyRef) + private readonly titleService = inject(TitleService) + private readonly router = inject(Router) + private readonly errorSheet = inject(MatBottomSheet) + private readonly dialog = inject(MatDialog) + + public readonly separatorKeysCodes = [ENTER, COMMA] as const + + private readonly tags = signal([]) + readonly userTags = linkedSignal(() => { + return this.tags().filter((t) => t.userId !== 'all') + }) + readonly appTags = linkedSignal(() => { + return this.tags().filter((t) => t.userId === 'all') + }) + + ngOnInit() { + combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]) + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(([perPage, pageNumber]) => { + return this.tagsService.getAllTags({ + pagination: { + perPage, + pageNumber, + }, + }) + }), + ) + .subscribe((result) => { + if (result) { + this.pageService.setTotalResults(result.total) + this.tags.set(result.result) + this.titleService.setTitle('Tags') + this.titleService.setSubtitle(null) + } + }) + } + + onAdd(e: MatChipInputEvent) { + const name = (e.value || '').trim() + + if (!name) { + return + } + + this.tagsService + .addOneTag({ name }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + this.errorSheet.open(BottomErrorSheet, { data: { error: error.error.message } }) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + e.chipInput.clear() + } + }) + } + + onEdit(tag: Tag, e: MatChipEditedEvent) { + const newName = (e.value || '').trim() + const currentName = tag.name + + if (!newName) { + return + } + + this.tagsService + .changeOneTag({ newName, currentName }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + this.errorSheet.open(BottomErrorSheet, { data: { error: error.error.message } }) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + } + }) + } + + onRemove(tag: Tag) { + const dialogRef = this.dialog.open(ConfirmationDialog, { + data: { + title: 'Delete tag', + message: `Are you sure you want to delete the tag "${tag.name}"?`, + confirmButtonText: 'Delete', + }, + }) + + dialogRef.afterClosed().subscribe((agree) => { + if (agree) { + this.tagsService + .deleteOneTag({ name: tag.name }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + } + }) + } + }) + } + + onClick(name: string) { + if (!name) { + return + } + this.router.navigate(['/articles'], { queryParams: { tag: name } }) + } +} diff --git a/src/app/pages/tags/tags-page.component.css b/src/app/pages/tags/tags-page.component.css deleted file mode 100644 index 1556b2c..0000000 --- a/src/app/pages/tags/tags-page.component.css +++ /dev/null @@ -1,5 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 1rem; -} diff --git a/src/app/pages/tags/tags-page.component.html b/src/app/pages/tags/tags-page.component.html deleted file mode 100644 index 1e851b0..0000000 --- a/src/app/pages/tags/tags-page.component.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - add - - - @if (tags().length > 0) { - - } - - -@defer { - - @for (t of tags(); track t._id) { - - {{ t.name }} - @if (t.userId !== 'all') { - - cancel - - } - - } - -} diff --git a/src/app/pages/tags/tags-page.component.ts b/src/app/pages/tags/tags-page.component.ts deleted file mode 100644 index d6866e4..0000000 --- a/src/app/pages/tags/tags-page.component.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Component, DestroyRef, inject, OnInit, signal, viewChild } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { TagService } from '../../services/tag-service' -import { Tag } from '../../entities/tag/tag.types' -import { HttpErrorResponse } from '@angular/common/http' -import { catchError, of } from 'rxjs' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { MatPaginator, PageEvent } from '@angular/material/paginator' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatIconButton } from '@angular/material/button' -import { MatIconModule } from '@angular/material/icon' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { MatDialog } from '@angular/material/dialog' -import { TagAddForm } from '../../components/tag-add-form/tag-add-form' -import { MatChipRemove, MatChipRow, MatChipSet } from '@angular/material/chips' - -@Component({ - selector: 'app-tags', - imports: [ - MatCardModule, - MatToolbarModule, - MatIconButton, - MatIconModule, - RowSpacer, - MatPaginator, - MatChipRow, - MatChipRemove, - MatChipSet, - ], - templateUrl: './tags-page.component.html', - styleUrl: './tags-page.component.css', -}) -export class TagsPage implements OnInit { - tagsService = inject(TagService) - destroyRef = inject(DestroyRef) - readonly dialog = inject(MatDialog) - - tags = signal([]) - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - getDate() { - this.tagsService - .getAllTags({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.error(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.currentPage.set(1) - this.tags.set(result.result) - this.totalResults.set(result.total) - } - }) - } - - ngOnInit() { - this.getDate() - } - - paginator = viewChild(MatPaginator) - - onAdd() { - const dialogRef = this.dialog.open(TagAddForm) - - dialogRef.afterClosed().subscribe((result) => { - if (result) { - this.getDate() - } - }) - } - - onRemove(tag: Tag) { - this.tagsService - .deleteOneTag({ name: tag.name }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.error(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe(() => { - this.getDate() - this.paginator()?.firstPage() - }) - } - - paginatorHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getDate() - } -} diff --git a/src/app/pages/user-page/user-page.css b/src/app/pages/user-page/user-page.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/user-page/user-page.html b/src/app/pages/user-page/user-page.html new file mode 100644 index 0000000..49a288a --- /dev/null +++ b/src/app/pages/user-page/user-page.html @@ -0,0 +1,8 @@ + + + Username: {{ ($currentUser | async)?.user?.login }} + + + Role: {{ ($currentUser | async)?.user?.role }} + + diff --git a/src/app/pages/user-page/user-page.ts b/src/app/pages/user-page/user-page.ts new file mode 100644 index 0000000..5aa3f70 --- /dev/null +++ b/src/app/pages/user-page/user-page.ts @@ -0,0 +1,22 @@ +import { Component, inject, OnInit } from '@angular/core' +import { AuthService } from '../../services/auth-service' +import { AsyncPipe } from '@angular/common' +import { TitleService } from '../../services/title-service' + +@Component({ + selector: 'app-user-page', + imports: [AsyncPipe], + templateUrl: './user-page.html', + styleUrl: './user-page.css', +}) +export class UserPage implements OnInit { + private readonly authService = inject(AuthService) + private readonly titleService = inject(TitleService) + + $currentUser = this.authService.$authStatus + + ngOnInit() { + this.titleService.setTitle('User') + this.titleService.setSubtitle(null) + } +} diff --git a/src/app/pipes/link-trim-pipe.ts b/src/app/pipes/link-trim-pipe.ts new file mode 100644 index 0000000..45aeb2b --- /dev/null +++ b/src/app/pipes/link-trim-pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core' + +@Pipe({ + name: 'linkTrim', +}) +export class LinkTrimPipe implements PipeTransform { + transform(value: string, length: number = Infinity): string { + try { + const parsed = URL.parse(value) + if (!parsed) { + throw new Error('Invalid URL') + } + const { host, pathname } = parsed + return `${host}${pathname.slice(0, length) + '...'}` + } catch (e) { + console.error(e) + return value.slice(0, length) + '...' + } + } +} diff --git a/src/app/pipes/safe-html-pipe.ts b/src/app/pipes/safe-html-pipe.ts new file mode 100644 index 0000000..51f0a54 --- /dev/null +++ b/src/app/pipes/safe-html-pipe.ts @@ -0,0 +1,16 @@ +import { inject, Pipe, PipeTransform } from '@angular/core' +import { DomSanitizer, SafeHtml } from '@angular/platform-browser' + +@Pipe({ + name: 'safeHtml', +}) +export class SafeHtmlPipe implements PipeTransform { + sanitizer = inject(DomSanitizer) + + transform(value: string): SafeHtml { + if (!value) { + return '' + } + return this.sanitizer.bypassSecurityTrustHtml(value) + } +} diff --git a/src/app/services/auth-service.ts b/src/app/services/auth-service.ts index 43d1908..1f18117 100644 --- a/src/app/services/auth-service.ts +++ b/src/app/services/auth-service.ts @@ -23,15 +23,58 @@ export class AuthService { $authStatus = this.$$authStatus.asObservable() login({ login, password }: UserDTO) { - return this.httpClient.post(`${environment.api}/auth/login`, { login, password }).pipe( - switchMap((response) => { - this.$$authStatus.next({ authenticated: true, user: response, error: null }) - return of(response) - }), + return this.httpClient + .post(`${environment.api}/auth/login`, { + login: login.trim(), + password: password.trim(), + }) + .pipe( + switchMap((response) => { + localStorage.setItem('user', response._id) + this.$$authStatus.next({ authenticated: true, user: response, error: null }) + return of(response) + }), + catchError((error: HttpErrorResponse) => { + this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) + return of(null) + }), + ) + } + + signup({ password }: { password: string }) { + return this.httpClient + .post(`${environment.api}/auth/signup`, { + password: password.trim(), + }) + .pipe( + switchMap((response) => { + localStorage.setItem('user', response._id) + this.$$authStatus.next({ authenticated: true, user: response, error: null }) + return of(response) + }), + catchError((error: HttpErrorResponse) => { + this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) + return of(null) + }), + ) + } + + logout() { + return this.httpClient.get<{ message: string }>(`${environment.api}/auth/logout`).pipe( catchError((error: HttpErrorResponse) => { - this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) - return of(null) + console.error(error) + return of(false) + }), + switchMap(() => { + localStorage.removeItem('user') + this.$$authStatus.next({ authenticated: false, user: null, error: null }) + return of(true) }), ) } + + updateAuth(user: User) { + this.$$authStatus.next({ authenticated: true, user, error: null }) + localStorage.setItem('user', user._id) + } } diff --git a/src/app/services/feed-service.ts b/src/app/services/feed-service.ts index eb563be..9b3aae7 100644 --- a/src/app/services/feed-service.ts +++ b/src/app/services/feed-service.ts @@ -4,17 +4,16 @@ import { environment } from '../../environments/environment' import { Feed, FeedDTO } from '../entities/feed/feed.types' import { Article, ArticleDTO } from '../entities/article/article.types' import { Paginated, Pagination } from '../entities/base/base.types' -import { TagService } from './tag-service' +import { SortOrder } from '../entities/base/base.enums' @Injectable({ providedIn: 'root', }) export class FeedService { - httpClient = inject(HttpClient) - tagService = inject(TagService) + readonly httpClient = inject(HttpClient) - getAllSubscriptions({ pagination }: { pagination?: Partial }) { - return this.httpClient.get>(`${environment.api}/subscription`, { + getAllFeeds({ pagination }: { pagination?: Partial }) { + return this.httpClient.get>(`${environment.api}/feed`, { params: pagination, }) } @@ -22,18 +21,27 @@ export class FeedService { getAllArticles({ pagination, filters, + sort, }: { pagination?: Partial filters?: { tags?: string; read?: boolean } + sort?: { date: SortOrder } }) { - this.tagService.getDefaultTags() return this.httpClient.get>(`${environment.api}/article`, { - params: { ...pagination, ...filters }, + params: { + ...pagination, + ...filters, + dateSort: sort?.date || SortOrder.Desc, + }, }) } - getOneSubscription({ subscriptionId }: { subscriptionId: string }) { - return this.httpClient.get(`${environment.api}/subscription/${subscriptionId}`) + getOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.get(`${environment.api}/feed/${feedId}`) + } + + changeOneFeed({ id, dto }: { id: string; dto: Partial }) { + return this.httpClient.patch(`${environment.api}/feed/${id}`, dto) } getOneArticle({ articleId }: { articleId: string }) { @@ -63,11 +71,23 @@ export class FeedService { }) } - addOneSubscription({ subscription }: { subscription: FeedDTO }) { - return this.httpClient.post(`${environment.api}/subscription`, subscription) + addOneFeed({ feed }: { feed: FeedDTO }) { + return this.httpClient.post(`${environment.api}/feed`, feed) + } + + deleteOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.delete(`${environment.api}/feed/${feedId}`) + } + + refreshAllFeeds() { + return this.httpClient.get(`${environment.api}/feed/refresh`) + } + + refreshOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.get(`${environment.api}/feed/${feedId}/refresh`) } - deleteOneSubscription({ subscriptionId }: { subscriptionId: string }) { - return this.httpClient.delete(`${environment.api}/subscription/${subscriptionId}`) + getFullText({ articleId }: { articleId: string }) { + return this.httpClient.get<{ fullText: string }>(`${environment.api}/article/${articleId}/full`) } } diff --git a/src/app/services/health-service.ts b/src/app/services/health-service.ts new file mode 100644 index 0000000..f50b578 --- /dev/null +++ b/src/app/services/health-service.ts @@ -0,0 +1,17 @@ +import { inject, Injectable } from '@angular/core' +import { HttpClient } from '@angular/common/http' +import { environment } from '../../environments/environment' + +@Injectable({ + providedIn: 'root', +}) +export class HealthService { + constructor() {} + httpClient = inject(HttpClient) + + getBackendStatus() { + return this.httpClient.get<{ status: string; version: string; uptime: string }>( + `${environment.api}/health`, + ) + } +} diff --git a/src/app/services/page-service.ts b/src/app/services/page-service.ts new file mode 100644 index 0000000..516dbc4 --- /dev/null +++ b/src/app/services/page-service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core' +import { PageDisplay } from '../entities/page/page.enums' +import { BehaviorSubject } from 'rxjs' + +@Injectable({ + providedIn: 'any', +}) +export class PageService { + private $$pageSize = new BehaviorSubject(5) + $pageSize = this.$$pageSize.asObservable() + + private $$currentPage = new BehaviorSubject(1) + $currentPage = this.$$currentPage.asObservable() + + private $$totalResults = new BehaviorSubject(0) + $totalResults = this.$$totalResults.asObservable() + + private $$display = new BehaviorSubject(PageDisplay.Title) + $display = this.$$display.asObservable() + + setCurrentPage(currentPage: number) { + this.$$currentPage.next(currentPage) + } + + setPageSize(pageSize: number) { + this.$$pageSize.next(pageSize) + } + + setTotalResults(totalResults: number) { + this.$$totalResults.next(totalResults) + } + + setDisplay(display: PageDisplay) { + this.$$display.next(display) + } +} diff --git a/src/app/services/tag-service.ts b/src/app/services/tag-service.ts index bc9f128..810f379 100644 --- a/src/app/services/tag-service.ts +++ b/src/app/services/tag-service.ts @@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http' import { Tag } from '../entities/tag/tag.types' import { environment } from '../../environments/environment' import { Paginated, Pagination } from '../entities/base/base.types' -import { BehaviorSubject } from 'rxjs' +import { BehaviorSubject, tap } from 'rxjs' @Injectable({ providedIn: 'root', @@ -18,9 +18,11 @@ export class TagService { $defaultTags = this.$$defaultTags.asObservable() getDefaultTags() { - return this.httpClient.get(`${environment.api}/tag?default=true`).subscribe((tags) => { - this.$$defaultTags.next(tags) - }) + return this.httpClient.get(`${environment.api}/tag?default=true`).pipe( + tap((tags) => { + this.$$defaultTags.next(tags) + }), + ) } getAllTags({ pagination }: { pagination?: Partial }) { @@ -29,6 +31,10 @@ export class TagService { }) } + getOne({ name }: { name: Tag['_id'] }) { + return this.httpClient.get(`${environment.api}/tag/${name}`) + } + addOneTag({ name }: { name: string }) { return this.httpClient.post(`${environment.api}/tag`, { name }) } diff --git a/src/app/services/title-service.ts b/src/app/services/title-service.ts index 8600424..701c846 100644 --- a/src/app/services/title-service.ts +++ b/src/app/services/title-service.ts @@ -11,8 +11,15 @@ export class TitleService { private $$currentTitle = new BehaviorSubject('') $currentTitle = this.$$currentTitle.asObservable() + private $$currentSubtitle = new BehaviorSubject(null) + $currentSubtitle = this.$$currentSubtitle.asObservable() + setTitle(title: string) { this.$$currentTitle.next(title) this.pageTitleService.setTitle(title) } + + setSubtitle(subtitle: string | null) { + this.$$currentSubtitle.next(subtitle) + } } diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 05fac53..3402093 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,5 +1,5 @@ import { Environment } from './environment.types' export const environment: Environment = { - api: 'http://rss-nest:3600/api', + api: 'api', } diff --git a/src/index.html b/src/index.html index 78c77ef..fedac66 100644 --- a/src/index.html +++ b/src/index.html @@ -1,18 +1,28 @@ - - - News - - - - - - - - - + + + News + + + + + + + + + diff --git a/src/styles.css b/src/styles.css index c784d4f..0609480 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,4 +1,5 @@ -/* You can add global styles to this file, and also import other style files */ +@import "@angular/material/prebuilt-themes/cyan-orange.css" (prefers-color-scheme: dark); +@import "@angular/material/prebuilt-themes/azure-blue.css" (prefers-color-scheme: light); html, body { @@ -20,3 +21,29 @@ body { mat-toolbar-row { height: auto !important; } + +.external { + display: flex; + flex-direction: column; +} + +.content_layout { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.content_layout img { + max-height: 20ch; + max-width: 70cqw; + align-self: center; + border-radius: 1rem; +} + +.content_layout a { + color: inherit; +} + +.highlighted { + color: var(--mat-sys-primary) !important; +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..f723ed7 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export { scrollUp } from './scrollUp' diff --git a/src/utils/scrollUp.ts b/src/utils/scrollUp.ts new file mode 100644 index 0000000..2d43ce8 --- /dev/null +++ b/src/utils/scrollUp.ts @@ -0,0 +1,7 @@ +export function scrollUp({ trigger }: { trigger: boolean }) { + if (!trigger) { + return + } + const page = document.querySelector('.page-content') + page?.scroll({ top: 0, behavior: 'smooth' }) +}
([]) - articleIds = computed(() => this.articles().map(({ _id }) => _id)) - display = signal<'title' | 'short'>('title') - - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - readFilter = signal(true) - favFilter = signal(false) - - favTagId = signal('') - userTags = signal([]) - - ngOnInit() { - this.getData() - this.tagService.$defaultTags - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((tags) => { - this.favTagId.set(tags?.find((t) => t.name === 'fav')?._id || '') - }) - this.tagService - .getAllTags({}) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((tags) => { - if (tags?.result) { - this.userTags.set(tags.result.filter((t) => t.userId !== 'all')) - } - }) - } - - getData() { - const filters: Record = {} - - if (this.readFilter()) { - filters['read'] = false - } - - if (this.favFilter()) { - filters['tags'] = this.favTagId() - } - - this.feedService - .getAllArticles({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - filters, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.articles.set(result.result) - this.totalResults.set(result.total) - this.titleService.setTitle(`News: ${result.total} articles`) - } else { - this.titleService.setTitle('News') - } - }) - } - - toggleDisplay(display: 'title' | 'short') { - this.display.set(display) - } - - safeHtml(html: string): SafeHtml { - return this.htmlSanitizer.bypassSecurityTrustHtml(html) - } - - async onArticleClick(article: Article) { - await this.router.navigate(['subscription', article.subscriptionId, 'article', article._id]) - } - - markAsRead(article: Article, event: MouseEvent) { - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { - read: !article.read, - }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, read: !a.read } : a)) - } - return prev - }) - }) - } - } - - markManyAsRead({ - articleIds, - read, - all, - }: { - articleIds?: string[] - read: boolean - all?: boolean - }) { - this.feedService - .changeManyArticles({ - ids: articleIds, - article: { read }, - all, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe(() => { - this.getData() - }) - } - - onAddToBookmarks(article: Article, event: MouseEvent) { - const existingTag = article.tags.find((t) => t === this.favTagId()) - const tags = existingTag - ? [...article.tags].filter((t) => t !== this.favTagId()) - : [...article.tags, this.favTagId()] - - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { tags }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, tags } : a)) - } - return prev - }) - }) - } - } - - paginationHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getData() - } - - filterHandler(filter: 'read' | 'fav') { - if (filter === 'read') { - this.readFilter.update((prev) => !prev) - } else { - this.favFilter.update((prev) => !prev) - } - this.getData() - } - - tagHandler(article: Article, tag: Tag, event: MouseEvent) { - const existingTag = article.tags.find((t) => t === tag._id) - const tags = existingTag - ? [...article.tags].filter((t) => t !== tag._id) - : [...article.tags, tag._id] - - event.stopPropagation() - if (article) { - this.feedService - .changeOneArticle({ - articleId: article._id, - article: { tags }, - }) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - ) - .subscribe((result) => { - if (result === null) { - return - } - this.articles.update((prev) => { - if (prev !== null) { - return prev.map((a) => (a._id === result._id ? { ...a, tags } : a)) - } - return prev - }) - }) - } - } -} diff --git a/src/app/pages/not-found/not-found-page.component.css b/src/app/pages/not-found/not-found-page.component.css index e69de29..a9bc56f 100644 --- a/src/app/pages/not-found/not-found-page.component.css +++ b/src/app/pages/not-found/not-found-page.component.css @@ -0,0 +1,13 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 1rem +} + +.emoji { + font-size: 20cqw; + font-weight: bold; + color: var(--mat-sys-secondary); +} diff --git a/src/app/pages/not-found/not-found-page.component.html b/src/app/pages/not-found/not-found-page.component.html index 8071020..5d3bd69 100644 --- a/src/app/pages/not-found/not-found-page.component.html +++ b/src/app/pages/not-found/not-found-page.component.html @@ -1 +1,2 @@ -not-found works! +Page not found +\(o_o)/ diff --git a/src/app/pages/status-page/status-page.css b/src/app/pages/status-page/status-page.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/status-page/status-page.html b/src/app/pages/status-page/status-page.html new file mode 100644 index 0000000..d7ddc81 --- /dev/null +++ b/src/app/pages/status-page/status-page.html @@ -0,0 +1 @@ + diff --git a/src/app/pages/status-page/status-page.ts b/src/app/pages/status-page/status-page.ts new file mode 100644 index 0000000..851f3fe --- /dev/null +++ b/src/app/pages/status-page/status-page.ts @@ -0,0 +1,18 @@ +import { Component, inject, OnInit } from '@angular/core' +import { HealthStatus } from '../../components/health-status/health-status' +import { TitleService } from '../../services/title-service' + +@Component({ + selector: 'app-status-page', + imports: [HealthStatus], + templateUrl: './status-page.html', + styleUrl: './status-page.css', +}) +export class StatusPage implements OnInit { + private readonly titleService = inject(TitleService) + + ngOnInit() { + this.titleService.setTitle('User') + this.titleService.setSubtitle(null) + } +} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.css b/src/app/pages/subscriptions/subscriptions-page.component.css deleted file mode 100644 index 67cb9da..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.css +++ /dev/null @@ -1,9 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 1rem; -} - -:host .mat-mdc-paginator { - background: transparent; -} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.html b/src/app/pages/subscriptions/subscriptions-page.component.html deleted file mode 100644 index 10aabda..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - add - - - @if (feeds().length > 0) { - - } - - -@defer { - @for (f of feeds(); track f._id) { - - - {{ f.title }} - - - delete - - - - Link: {{ f.link }} - Articles: {{ f.articles.length }} - - - } @empty { - No subscriptions found - } -} @loading (minimum 0.5s) { - -} diff --git a/src/app/pages/subscriptions/subscriptions-page.component.ts b/src/app/pages/subscriptions/subscriptions-page.component.ts deleted file mode 100644 index 720d0ed..0000000 --- a/src/app/pages/subscriptions/subscriptions-page.component.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Component, DestroyRef, inject, OnInit, signal, viewChild } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { FeedService } from '../../services/feed-service' -import { catchError, of } from 'rxjs' -import { HttpErrorResponse } from '@angular/common/http' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatIconModule } from '@angular/material/icon' -import { MatIconButton } from '@angular/material/button' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { MatDialog, MatDialogModule } from '@angular/material/dialog' -import { SubscriptionAddForm } from '../../components/subscription-add-form/subscription-add-form' -import { Feed } from '../../entities/feed/feed.types' -import { MatProgressBar } from '@angular/material/progress-bar' -import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator' - -@Component({ - selector: 'app-subscriptions', - imports: [ - MatCardModule, - MatToolbarModule, - MatIconModule, - MatIconButton, - RowSpacer, - MatDialogModule, - MatProgressBar, - MatPaginatorModule, - ], - templateUrl: './subscriptions-page.component.html', - styleUrl: './subscriptions-page.component.css', -}) -export class SubscriptionsPage implements OnInit { - feedService = inject(FeedService) - readonly dialog = inject(MatDialog) - destroyRef = inject(DestroyRef) - - feeds = signal([]) - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - ngOnInit() { - this.getData() - } - - getData() { - this.feedService - .getAllSubscriptions({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.currentPage.set(1) - this.feeds.set(result.result) - this.totalResults.set(result.total) - } - }) - } - - onAdd() { - const dialogRef = this.dialog.open(SubscriptionAddForm) - - dialogRef.afterClosed().subscribe((result) => { - if (result) { - this.getData() - } - }) - } - - paginator = viewChild(MatPaginator) - - onRemove(id: string) { - this.feedService - .deleteOneSubscription({ subscriptionId: id }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.log(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - this.getData() - this.paginator()?.firstPage() - }) - } - - paginationHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getData() - } -} diff --git a/src/app/pages/tags-page/tags-page.css b/src/app/pages/tags-page/tags-page.css new file mode 100644 index 0000000..31b5e77 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.css @@ -0,0 +1,11 @@ +:host { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 1rem; + padding: 1rem 0.5rem; + height: calc(100% - 2rem); +} + +input { + margin-top: 2rem; +} diff --git a/src/app/pages/tags-page/tags-page.html b/src/app/pages/tags-page/tags-page.html new file mode 100644 index 0000000..50596d6 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.html @@ -0,0 +1,47 @@ + + @defer { + User tags + + + @for (t of userTags(); track t._id) { + + + edit + + {{ t.name }} + + cancel + + + } + + + + + App tags + + @for (t of appTags(); track t._id) { + + {{ t.name }} + + } + + + + } + + + diff --git a/src/app/pages/tags-page/tags-page.ts b/src/app/pages/tags-page/tags-page.ts new file mode 100644 index 0000000..c376202 --- /dev/null +++ b/src/app/pages/tags-page/tags-page.ts @@ -0,0 +1,153 @@ +import { Component, DestroyRef, inject, linkedSignal, OnInit, signal } from '@angular/core' +import { TagService } from '../../services/tag-service' +import { Tag } from '../../entities/tag/tag.types' +import { HttpErrorResponse } from '@angular/common/http' +import { catchError, combineLatest, of, switchMap } from 'rxjs' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { MatIconModule } from '@angular/material/icon' +import { MatChipEditedEvent, MatChipInputEvent, MatChipsModule } from '@angular/material/chips' +import { Paginator } from '../../components/paginator/paginator' +import { PageService } from '../../services/page-service' +import { TitleService } from '../../services/title-service' +import { Router } from '@angular/router' +import { MatFormFieldModule } from '@angular/material/form-field' +import { COMMA, ENTER } from '@angular/cdk/keycodes' +import { MatBottomSheet } from '@angular/material/bottom-sheet' +import { BottomErrorSheet } from '../../components/bottom-error-sheet/bottom-error-sheet' +import { MatDialog } from '@angular/material/dialog' +import { ConfirmationDialog } from '../../components/confirmation-dialog/confirmation-dialog' + +@Component({ + selector: 'app-tags-page', + imports: [MatIconModule, Paginator, MatFormFieldModule, MatChipsModule], + templateUrl: './tags-page.html', + styleUrl: './tags-page.css', +}) +export class TagsPage implements OnInit { + private readonly tagsService = inject(TagService) + private readonly pageService = inject(PageService) + private readonly destroyRef = inject(DestroyRef) + private readonly titleService = inject(TitleService) + private readonly router = inject(Router) + private readonly errorSheet = inject(MatBottomSheet) + private readonly dialog = inject(MatDialog) + + public readonly separatorKeysCodes = [ENTER, COMMA] as const + + private readonly tags = signal([]) + readonly userTags = linkedSignal(() => { + return this.tags().filter((t) => t.userId !== 'all') + }) + readonly appTags = linkedSignal(() => { + return this.tags().filter((t) => t.userId === 'all') + }) + + ngOnInit() { + combineLatest([this.pageService.$pageSize, this.pageService.$currentPage]) + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(([perPage, pageNumber]) => { + return this.tagsService.getAllTags({ + pagination: { + perPage, + pageNumber, + }, + }) + }), + ) + .subscribe((result) => { + if (result) { + this.pageService.setTotalResults(result.total) + this.tags.set(result.result) + this.titleService.setTitle('Tags') + this.titleService.setSubtitle(null) + } + }) + } + + onAdd(e: MatChipInputEvent) { + const name = (e.value || '').trim() + + if (!name) { + return + } + + this.tagsService + .addOneTag({ name }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + this.errorSheet.open(BottomErrorSheet, { data: { error: error.error.message } }) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + e.chipInput.clear() + } + }) + } + + onEdit(tag: Tag, e: MatChipEditedEvent) { + const newName = (e.value || '').trim() + const currentName = tag.name + + if (!newName) { + return + } + + this.tagsService + .changeOneTag({ newName, currentName }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + this.errorSheet.open(BottomErrorSheet, { data: { error: error.error.message } }) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + } + }) + } + + onRemove(tag: Tag) { + const dialogRef = this.dialog.open(ConfirmationDialog, { + data: { + title: 'Delete tag', + message: `Are you sure you want to delete the tag "${tag.name}"?`, + confirmButtonText: 'Delete', + }, + }) + + dialogRef.afterClosed().subscribe((agree) => { + if (agree) { + this.tagsService + .deleteOneTag({ name: tag.name }) + .pipe( + catchError((error: HttpErrorResponse) => { + console.error(error) + return of(null) + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((res) => { + if (res) { + this.pageService.setCurrentPage(1) + } + }) + } + }) + } + + onClick(name: string) { + if (!name) { + return + } + this.router.navigate(['/articles'], { queryParams: { tag: name } }) + } +} diff --git a/src/app/pages/tags/tags-page.component.css b/src/app/pages/tags/tags-page.component.css deleted file mode 100644 index 1556b2c..0000000 --- a/src/app/pages/tags/tags-page.component.css +++ /dev/null @@ -1,5 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 1rem; -} diff --git a/src/app/pages/tags/tags-page.component.html b/src/app/pages/tags/tags-page.component.html deleted file mode 100644 index 1e851b0..0000000 --- a/src/app/pages/tags/tags-page.component.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - add - - - @if (tags().length > 0) { - - } - - -@defer { - - @for (t of tags(); track t._id) { - - {{ t.name }} - @if (t.userId !== 'all') { - - cancel - - } - - } - -} diff --git a/src/app/pages/tags/tags-page.component.ts b/src/app/pages/tags/tags-page.component.ts deleted file mode 100644 index d6866e4..0000000 --- a/src/app/pages/tags/tags-page.component.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Component, DestroyRef, inject, OnInit, signal, viewChild } from '@angular/core' -import { MatCardModule } from '@angular/material/card' -import { TagService } from '../../services/tag-service' -import { Tag } from '../../entities/tag/tag.types' -import { HttpErrorResponse } from '@angular/common/http' -import { catchError, of } from 'rxjs' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { MatPaginator, PageEvent } from '@angular/material/paginator' -import { MatToolbarModule } from '@angular/material/toolbar' -import { MatIconButton } from '@angular/material/button' -import { MatIconModule } from '@angular/material/icon' -import { RowSpacer } from '../../components/row-spacer/row-spacer' -import { MatDialog } from '@angular/material/dialog' -import { TagAddForm } from '../../components/tag-add-form/tag-add-form' -import { MatChipRemove, MatChipRow, MatChipSet } from '@angular/material/chips' - -@Component({ - selector: 'app-tags', - imports: [ - MatCardModule, - MatToolbarModule, - MatIconButton, - MatIconModule, - RowSpacer, - MatPaginator, - MatChipRow, - MatChipRemove, - MatChipSet, - ], - templateUrl: './tags-page.component.html', - styleUrl: './tags-page.component.css', -}) -export class TagsPage implements OnInit { - tagsService = inject(TagService) - destroyRef = inject(DestroyRef) - readonly dialog = inject(MatDialog) - - tags = signal([]) - currentPage = signal(1) - pageSize = signal(10) - totalResults = signal(0) - - getDate() { - this.tagsService - .getAllTags({ - pagination: { - perPage: this.pageSize(), - pageNumber: this.currentPage(), - }, - }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.error(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((result) => { - if (result) { - this.currentPage.set(1) - this.tags.set(result.result) - this.totalResults.set(result.total) - } - }) - } - - ngOnInit() { - this.getDate() - } - - paginator = viewChild(MatPaginator) - - onAdd() { - const dialogRef = this.dialog.open(TagAddForm) - - dialogRef.afterClosed().subscribe((result) => { - if (result) { - this.getDate() - } - }) - } - - onRemove(tag: Tag) { - this.tagsService - .deleteOneTag({ name: tag.name }) - .pipe( - catchError((error: HttpErrorResponse) => { - console.error(error) - return of(null) - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe(() => { - this.getDate() - this.paginator()?.firstPage() - }) - } - - paginatorHandler(event: PageEvent) { - this.currentPage.set(event.pageIndex + 1) - this.pageSize.set(event.pageSize) - this.getDate() - } -} diff --git a/src/app/pages/user-page/user-page.css b/src/app/pages/user-page/user-page.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/user-page/user-page.html b/src/app/pages/user-page/user-page.html new file mode 100644 index 0000000..49a288a --- /dev/null +++ b/src/app/pages/user-page/user-page.html @@ -0,0 +1,8 @@ + + + Username: {{ ($currentUser | async)?.user?.login }} + + + Role: {{ ($currentUser | async)?.user?.role }} + + diff --git a/src/app/pages/user-page/user-page.ts b/src/app/pages/user-page/user-page.ts new file mode 100644 index 0000000..5aa3f70 --- /dev/null +++ b/src/app/pages/user-page/user-page.ts @@ -0,0 +1,22 @@ +import { Component, inject, OnInit } from '@angular/core' +import { AuthService } from '../../services/auth-service' +import { AsyncPipe } from '@angular/common' +import { TitleService } from '../../services/title-service' + +@Component({ + selector: 'app-user-page', + imports: [AsyncPipe], + templateUrl: './user-page.html', + styleUrl: './user-page.css', +}) +export class UserPage implements OnInit { + private readonly authService = inject(AuthService) + private readonly titleService = inject(TitleService) + + $currentUser = this.authService.$authStatus + + ngOnInit() { + this.titleService.setTitle('User') + this.titleService.setSubtitle(null) + } +} diff --git a/src/app/pipes/link-trim-pipe.ts b/src/app/pipes/link-trim-pipe.ts new file mode 100644 index 0000000..45aeb2b --- /dev/null +++ b/src/app/pipes/link-trim-pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core' + +@Pipe({ + name: 'linkTrim', +}) +export class LinkTrimPipe implements PipeTransform { + transform(value: string, length: number = Infinity): string { + try { + const parsed = URL.parse(value) + if (!parsed) { + throw new Error('Invalid URL') + } + const { host, pathname } = parsed + return `${host}${pathname.slice(0, length) + '...'}` + } catch (e) { + console.error(e) + return value.slice(0, length) + '...' + } + } +} diff --git a/src/app/pipes/safe-html-pipe.ts b/src/app/pipes/safe-html-pipe.ts new file mode 100644 index 0000000..51f0a54 --- /dev/null +++ b/src/app/pipes/safe-html-pipe.ts @@ -0,0 +1,16 @@ +import { inject, Pipe, PipeTransform } from '@angular/core' +import { DomSanitizer, SafeHtml } from '@angular/platform-browser' + +@Pipe({ + name: 'safeHtml', +}) +export class SafeHtmlPipe implements PipeTransform { + sanitizer = inject(DomSanitizer) + + transform(value: string): SafeHtml { + if (!value) { + return '' + } + return this.sanitizer.bypassSecurityTrustHtml(value) + } +} diff --git a/src/app/services/auth-service.ts b/src/app/services/auth-service.ts index 43d1908..1f18117 100644 --- a/src/app/services/auth-service.ts +++ b/src/app/services/auth-service.ts @@ -23,15 +23,58 @@ export class AuthService { $authStatus = this.$$authStatus.asObservable() login({ login, password }: UserDTO) { - return this.httpClient.post(`${environment.api}/auth/login`, { login, password }).pipe( - switchMap((response) => { - this.$$authStatus.next({ authenticated: true, user: response, error: null }) - return of(response) - }), + return this.httpClient + .post(`${environment.api}/auth/login`, { + login: login.trim(), + password: password.trim(), + }) + .pipe( + switchMap((response) => { + localStorage.setItem('user', response._id) + this.$$authStatus.next({ authenticated: true, user: response, error: null }) + return of(response) + }), + catchError((error: HttpErrorResponse) => { + this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) + return of(null) + }), + ) + } + + signup({ password }: { password: string }) { + return this.httpClient + .post(`${environment.api}/auth/signup`, { + password: password.trim(), + }) + .pipe( + switchMap((response) => { + localStorage.setItem('user', response._id) + this.$$authStatus.next({ authenticated: true, user: response, error: null }) + return of(response) + }), + catchError((error: HttpErrorResponse) => { + this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) + return of(null) + }), + ) + } + + logout() { + return this.httpClient.get<{ message: string }>(`${environment.api}/auth/logout`).pipe( catchError((error: HttpErrorResponse) => { - this.$$authStatus.next({ authenticated: false, user: null, error: error.error.message }) - return of(null) + console.error(error) + return of(false) + }), + switchMap(() => { + localStorage.removeItem('user') + this.$$authStatus.next({ authenticated: false, user: null, error: null }) + return of(true) }), ) } + + updateAuth(user: User) { + this.$$authStatus.next({ authenticated: true, user, error: null }) + localStorage.setItem('user', user._id) + } } diff --git a/src/app/services/feed-service.ts b/src/app/services/feed-service.ts index eb563be..9b3aae7 100644 --- a/src/app/services/feed-service.ts +++ b/src/app/services/feed-service.ts @@ -4,17 +4,16 @@ import { environment } from '../../environments/environment' import { Feed, FeedDTO } from '../entities/feed/feed.types' import { Article, ArticleDTO } from '../entities/article/article.types' import { Paginated, Pagination } from '../entities/base/base.types' -import { TagService } from './tag-service' +import { SortOrder } from '../entities/base/base.enums' @Injectable({ providedIn: 'root', }) export class FeedService { - httpClient = inject(HttpClient) - tagService = inject(TagService) + readonly httpClient = inject(HttpClient) - getAllSubscriptions({ pagination }: { pagination?: Partial }) { - return this.httpClient.get>(`${environment.api}/subscription`, { + getAllFeeds({ pagination }: { pagination?: Partial }) { + return this.httpClient.get>(`${environment.api}/feed`, { params: pagination, }) } @@ -22,18 +21,27 @@ export class FeedService { getAllArticles({ pagination, filters, + sort, }: { pagination?: Partial filters?: { tags?: string; read?: boolean } + sort?: { date: SortOrder } }) { - this.tagService.getDefaultTags() return this.httpClient.get>(`${environment.api}/article`, { - params: { ...pagination, ...filters }, + params: { + ...pagination, + ...filters, + dateSort: sort?.date || SortOrder.Desc, + }, }) } - getOneSubscription({ subscriptionId }: { subscriptionId: string }) { - return this.httpClient.get(`${environment.api}/subscription/${subscriptionId}`) + getOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.get(`${environment.api}/feed/${feedId}`) + } + + changeOneFeed({ id, dto }: { id: string; dto: Partial }) { + return this.httpClient.patch(`${environment.api}/feed/${id}`, dto) } getOneArticle({ articleId }: { articleId: string }) { @@ -63,11 +71,23 @@ export class FeedService { }) } - addOneSubscription({ subscription }: { subscription: FeedDTO }) { - return this.httpClient.post(`${environment.api}/subscription`, subscription) + addOneFeed({ feed }: { feed: FeedDTO }) { + return this.httpClient.post(`${environment.api}/feed`, feed) + } + + deleteOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.delete(`${environment.api}/feed/${feedId}`) + } + + refreshAllFeeds() { + return this.httpClient.get(`${environment.api}/feed/refresh`) + } + + refreshOneFeed({ feedId }: { feedId: string }) { + return this.httpClient.get(`${environment.api}/feed/${feedId}/refresh`) } - deleteOneSubscription({ subscriptionId }: { subscriptionId: string }) { - return this.httpClient.delete(`${environment.api}/subscription/${subscriptionId}`) + getFullText({ articleId }: { articleId: string }) { + return this.httpClient.get<{ fullText: string }>(`${environment.api}/article/${articleId}/full`) } } diff --git a/src/app/services/health-service.ts b/src/app/services/health-service.ts new file mode 100644 index 0000000..f50b578 --- /dev/null +++ b/src/app/services/health-service.ts @@ -0,0 +1,17 @@ +import { inject, Injectable } from '@angular/core' +import { HttpClient } from '@angular/common/http' +import { environment } from '../../environments/environment' + +@Injectable({ + providedIn: 'root', +}) +export class HealthService { + constructor() {} + httpClient = inject(HttpClient) + + getBackendStatus() { + return this.httpClient.get<{ status: string; version: string; uptime: string }>( + `${environment.api}/health`, + ) + } +} diff --git a/src/app/services/page-service.ts b/src/app/services/page-service.ts new file mode 100644 index 0000000..516dbc4 --- /dev/null +++ b/src/app/services/page-service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core' +import { PageDisplay } from '../entities/page/page.enums' +import { BehaviorSubject } from 'rxjs' + +@Injectable({ + providedIn: 'any', +}) +export class PageService { + private $$pageSize = new BehaviorSubject(5) + $pageSize = this.$$pageSize.asObservable() + + private $$currentPage = new BehaviorSubject(1) + $currentPage = this.$$currentPage.asObservable() + + private $$totalResults = new BehaviorSubject(0) + $totalResults = this.$$totalResults.asObservable() + + private $$display = new BehaviorSubject(PageDisplay.Title) + $display = this.$$display.asObservable() + + setCurrentPage(currentPage: number) { + this.$$currentPage.next(currentPage) + } + + setPageSize(pageSize: number) { + this.$$pageSize.next(pageSize) + } + + setTotalResults(totalResults: number) { + this.$$totalResults.next(totalResults) + } + + setDisplay(display: PageDisplay) { + this.$$display.next(display) + } +} diff --git a/src/app/services/tag-service.ts b/src/app/services/tag-service.ts index bc9f128..810f379 100644 --- a/src/app/services/tag-service.ts +++ b/src/app/services/tag-service.ts @@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http' import { Tag } from '../entities/tag/tag.types' import { environment } from '../../environments/environment' import { Paginated, Pagination } from '../entities/base/base.types' -import { BehaviorSubject } from 'rxjs' +import { BehaviorSubject, tap } from 'rxjs' @Injectable({ providedIn: 'root', @@ -18,9 +18,11 @@ export class TagService { $defaultTags = this.$$defaultTags.asObservable() getDefaultTags() { - return this.httpClient.get(`${environment.api}/tag?default=true`).subscribe((tags) => { - this.$$defaultTags.next(tags) - }) + return this.httpClient.get(`${environment.api}/tag?default=true`).pipe( + tap((tags) => { + this.$$defaultTags.next(tags) + }), + ) } getAllTags({ pagination }: { pagination?: Partial }) { @@ -29,6 +31,10 @@ export class TagService { }) } + getOne({ name }: { name: Tag['_id'] }) { + return this.httpClient.get(`${environment.api}/tag/${name}`) + } + addOneTag({ name }: { name: string }) { return this.httpClient.post(`${environment.api}/tag`, { name }) } diff --git a/src/app/services/title-service.ts b/src/app/services/title-service.ts index 8600424..701c846 100644 --- a/src/app/services/title-service.ts +++ b/src/app/services/title-service.ts @@ -11,8 +11,15 @@ export class TitleService { private $$currentTitle = new BehaviorSubject('') $currentTitle = this.$$currentTitle.asObservable() + private $$currentSubtitle = new BehaviorSubject(null) + $currentSubtitle = this.$$currentSubtitle.asObservable() + setTitle(title: string) { this.$$currentTitle.next(title) this.pageTitleService.setTitle(title) } + + setSubtitle(subtitle: string | null) { + this.$$currentSubtitle.next(subtitle) + } } diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 05fac53..3402093 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,5 +1,5 @@ import { Environment } from './environment.types' export const environment: Environment = { - api: 'http://rss-nest:3600/api', + api: 'api', } diff --git a/src/index.html b/src/index.html index 78c77ef..fedac66 100644 --- a/src/index.html +++ b/src/index.html @@ -1,18 +1,28 @@ - - - News - - - - - - - - - + + + News + + + + + + + + + diff --git a/src/styles.css b/src/styles.css index c784d4f..0609480 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,4 +1,5 @@ -/* You can add global styles to this file, and also import other style files */ +@import "@angular/material/prebuilt-themes/cyan-orange.css" (prefers-color-scheme: dark); +@import "@angular/material/prebuilt-themes/azure-blue.css" (prefers-color-scheme: light); html, body { @@ -20,3 +21,29 @@ body { mat-toolbar-row { height: auto !important; } + +.external { + display: flex; + flex-direction: column; +} + +.content_layout { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.content_layout img { + max-height: 20ch; + max-width: 70cqw; + align-self: center; + border-radius: 1rem; +} + +.content_layout a { + color: inherit; +} + +.highlighted { + color: var(--mat-sys-primary) !important; +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..f723ed7 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export { scrollUp } from './scrollUp' diff --git a/src/utils/scrollUp.ts b/src/utils/scrollUp.ts new file mode 100644 index 0000000..2d43ce8 --- /dev/null +++ b/src/utils/scrollUp.ts @@ -0,0 +1,7 @@ +export function scrollUp({ trigger }: { trigger: boolean }) { + if (!trigger) { + return + } + const page = document.querySelector('.page-content') + page?.scroll({ top: 0, behavior: 'smooth' }) +}