Multicolumn Bluesky client powered by Angular

feat: image viewer

kbenlloch afd560a9 7bc240ea

+4 -2
src/app/components/cards/notification-card/notification-card.component.html
··· 173 173 <div 174 174 class="flex gap-2" 175 175 > 176 - @for (image of attachedPost.embed.images; track image.thumb) { 176 + @for (image of attachedPost.embed.images; track $index) { 177 177 <img 178 178 [src]="image.thumb" 179 179 [alt]="image.alt" 180 + (click)="openImage($event, attachedPost.embed.images, $index)" 180 181 class="h-16 w-16 object-cover" 181 182 /> 182 183 } ··· 196 197 <div 197 198 class="flex gap-2" 198 199 > 199 - @for (image of attachedPost.embed.media.images; track image.thumb) { 200 + @for (image of attachedPost.embed.media.images; track $index) { 200 201 <img 201 202 [src]="image.thumb" 202 203 [alt]="image.alt" 204 + (click)="openImage($event, attachedPost.embed.media.images, $index)" 203 205 class="h-16 w-16 object-cover" 204 206 /> 205 207 }
+8 -1
src/app/components/cards/notification-card/notification-card.component.ts
··· 15 15 import {IsStarterPackNotificationPipe} from '@shared/pipes/type-guards/notifications/is-starterpack-notification.pipe'; 16 16 import {NgTemplateOutlet, SlicePipe} from '@angular/common'; 17 17 import {DisplayNamePipe} from '@shared/pipes/display-name.pipe'; 18 - import {AppBskyFeedDefs} from '@atproto/api'; 18 + import {AppBskyEmbedImages, AppBskyFeedDefs} from '@atproto/api'; 19 19 import {IsEmbedImagesViewPipe} from '@shared/pipes/type-guards/is-embed-images-view.pipe'; 20 20 import {IsEmbedVideoViewPipe} from '@shared/pipes/type-guards/is-embed-video-view.pipe'; 21 21 import {IsEmbedRecordWithMediaViewPipe} from '@shared/pipes/type-guards/is-embed-recordwithmedia-view.pipe'; 22 + import {DialogService} from '@services/dialog.service'; 22 23 23 24 @Component({ 24 25 selector: 'notification-card', ··· 44 45 post: WritableSignal<AppBskyFeedDefs.PostView>; 45 46 46 47 constructor( 48 + private dialogService: DialogService, 47 49 private cdRef: ChangeDetectorRef 48 50 ) {} 49 51 ··· 54 56 55 57 openAuthor(event: Event, did: string) { 56 58 //TODO: OpenAuthor 59 + } 60 + 61 + openImage(event: Event, images: AppBskyEmbedImages.ViewImage[], index: number) { 62 + event.stopPropagation(); 63 + this.dialogService.openImage(images, index); 57 64 } 58 65 }
+4
src/app/components/cards/post-card/post-card.component.html
··· 133 133 @if (embed | isEmbedRecordView) { 134 134 <record-embed 135 135 [record]="embed.record" 136 + (onImgClick)="openImage($event.images, $event.index)" 136 137 class="mt-2 p-2 hover:bg-primary/2" 137 138 /> 138 139 } ··· 140 141 @if (embed | isEmbedImagesView) { 141 142 <images-embed 142 143 [images]="embed.images" 144 + (onClick)="openImage(embed.images, $event)" 143 145 class="mb-1" 144 146 [class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'" 145 147 /> ··· 165 167 [images]="embed.media.images" 166 168 class="mb-1" 167 169 [class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'" 170 + (onClick)="openImage(embed.media.images, $event)" 168 171 /> 169 172 } 170 173 ··· 184 187 185 188 <record-embed 186 189 [record]="embed.record.record" 190 + (onImgClick)="openImage($event.images, $event.index)" 187 191 class="mt-2 p-2 hover:bg-primary/2" 188 192 /> 189 193 }
+7 -1
src/app/components/cards/post-card/post-card.component.ts
··· 9 9 OnDestroy, 10 10 OnInit 11 11 } from '@angular/core'; 12 - import {AppBskyFeedDefs} from '@atproto/api'; 12 + import {AppBskyEmbedImages, AppBskyFeedDefs} from '@atproto/api'; 13 13 import {AvatarComponent} from '@components/shared/avatar/avatar.component'; 14 14 import {DisplayNamePipe} from '@shared/pipes/display-name.pipe'; 15 15 import {IsFeedPostRecordPipe} from '@shared/pipes/type-guards/is-feed-post-record'; ··· 32 32 import {ExternalEmbedComponent} from '@components/embeds/external-embed/external-embed.component'; 33 33 import {IsEmbedExternalViewPipe} from '@shared/pipes/type-guards/is-embed-external-view.pipe'; 34 34 import {MessageService} from '@services/message.service'; 35 + import {DialogService} from '@services/dialog.service'; 35 36 36 37 @Component({ 37 38 selector: 'post-card', ··· 74 75 constructor( 75 76 private postService: PostService, 76 77 private messageService: MessageService, 78 + private dialogService: DialogService, 77 79 private cdRef: ChangeDetectorRef 78 80 ) { 79 81 effect(() => { ··· 152 154 quotePost() { 153 155 this.postService.quotePost(this.post().uri); 154 156 this.rtMenuVisible = false; 157 + } 158 + 159 + openImage(images: AppBskyEmbedImages.ViewImage[], index: number) { 160 + this.dialogService.openImage(images, index); 155 161 } 156 162 }
+48
src/app/components/dialogs/gallery/gallery.component.html
··· 1 + <div 2 + class="flex flex-col gap-4 items-center justify-center absolute top-[1rem] left-[5rem] h-[calc(100%_-_4rem)] w-[calc(100%_-_10rem)] pointer-events-none" 3 + [class]="{'h-[calc(100%_-_4rem)]': images.length > 1, 'h-[calc(100%_-_2rem)]': images.length == 1}" 4 + > 5 + <img 6 + [src]="images[index].fullsize" 7 + class="flex-1 min-h-0 min-w-0 w-fit pointer-events-auto" 8 + /> 9 + 10 + @if (images[index].alt) { 11 + <span 12 + class="bg-primary text-bg py-2 px-3 pointer-events-auto" 13 + >{{ images[index].alt }}</span> 14 + } 15 + </div> 16 + 17 + @if (images.length > 1) { 18 + <button 19 + class="flex items-center justify-center absolute h-12 w-12 left-[1rem] top-[50%] -translate-y-[50%] btn-primary" 20 + (click)="prevImage()" 21 + > 22 + <span 23 + class="material-icons !text-4xl" 24 + >chevron_left</span> 25 + </button> 26 + 27 + <button 28 + class="flex items-center justify-center absolute h-12 w-12 right-[1rem] top-[50%] -translate-y-[50%] btn-primary" 29 + (click)="nextImage()" 30 + > 31 + <span 32 + class="material-icons !text-4xl" 33 + >chevron_right</span> 34 + </button> 35 + 36 + <div 37 + class="absolute bottom-[1rem] left-[50%] -translate-x-[50%] flex gap-2" 38 + > 39 + @for (image of images; track $index) { 40 + <button 41 + class="h-4 w-4 p-0 bg-bg border border-primary" 42 + [class.bg-primary]="$index == index" 43 + [class.cursor-pointer]="$index !== index" 44 + (click)="index = $index" 45 + ></button> 46 + } 47 + </div> 48 + }
+39
src/app/components/dialogs/gallery/gallery.component.ts
··· 1 + import {ChangeDetectionStrategy, Component, HostListener, inject} from '@angular/core'; 2 + import {AppBskyEmbedImages} from '@atproto/api'; 3 + import {DIALOG_DATA, DialogRef} from '@angular/cdk/dialog'; 4 + 5 + @Component({ 6 + selector: 'gallery', 7 + templateUrl: './gallery.component.html', 8 + changeDetection: ChangeDetectionStrategy.OnPush 9 + }) 10 + export class GalleryComponent { 11 + images: AppBskyEmbedImages.ViewImage[]; 12 + index: number; 13 + 14 + dialogRef = inject<DialogRef<string>>(DialogRef<string>); 15 + data = inject(DIALOG_DATA); 16 + 17 + constructor() { 18 + this.images = this.data.images; 19 + this.index = this.data.index; 20 + } 21 + 22 + @HostListener('keydown.arrowLeft', ['$event']) 23 + prevImage() { 24 + if (this.index == 0) { 25 + this.index = this.images.length - 1; 26 + } else { 27 + this.index = this.index - 1; 28 + } 29 + } 30 + 31 + @HostListener('keydown.arrowRight', ['$event']) 32 + nextImage() { 33 + if (this.index + 1 == this.images.length) { 34 + this.index = 0; 35 + } else { 36 + this.index = this.index + 1; 37 + } 38 + } 39 + }
+2 -1
src/app/components/embeds/images-embed/images-embed.component.ts
··· 15 15 onClick = output<number>(); 16 16 17 17 imgClick(index: number, event: Event) { 18 - 18 + event.stopPropagation(); 19 + this.onClick.emit(index); 19 20 } 20 21 }
+3
src/app/components/embeds/record-embed/record-embed.component.html
··· 2 2 3 3 <div 4 4 class="flex" 5 + (click)="recordClick($event)" 5 6 > 6 7 <div 7 8 class="overflow-hidden shrink-0 h-5 w-9 flex items-center justify-center" ··· 104 105 <images-embed 105 106 [images]="media.images" 106 107 [class]="margin" 108 + (onClick)="onImgClick.emit({images: media.images, index: $event})" 107 109 /> 108 110 } 109 111 ··· 126 128 <images-embed 127 129 [images]="media.media.images" 128 130 [class]="margin" 131 + (onClick)="onImgClick.emit({images: media.media.images, index: $event})" 129 132 /> 130 133 } 131 134
+17 -2
src/app/components/embeds/record-embed/record-embed.component.ts
··· 1 - import {ChangeDetectionStrategy, Component, input} from '@angular/core'; 2 - import {$Typed, AppBskyEmbedRecord, AppBskyFeedDefs, AppBskyGraphDefs, AppBskyLabelerDefs} from '@atproto/api'; 1 + import {ChangeDetectionStrategy, Component, input, output} from '@angular/core'; 2 + import { 3 + $Typed, 4 + AppBskyEmbedImages, 5 + AppBskyEmbedRecord, 6 + AppBskyFeedDefs, 7 + AppBskyGraphDefs, 8 + AppBskyLabelerDefs 9 + } from '@atproto/api'; 3 10 import {DisplayNamePipe} from '@shared/pipes/display-name.pipe'; 4 11 import {IsEmbedRecordViewRecordPipe} from '@shared/pipes/type-guards/is-embed-record-viewrecord.pipe'; 5 12 import {NgTemplateOutlet} from '@angular/common'; ··· 60 67 >(); 61 68 protected readonly AppBskyFeedDefs = AppBskyFeedDefs; 62 69 protected readonly AppBskyGraphDefs = AppBskyGraphDefs; 70 + 71 + onClick = output(); 72 + onImgClick = output<{images: AppBskyEmbedImages.ViewImage[], index: number}>(); 73 + 74 + recordClick(event: Event) { 75 + event.stopPropagation(); 76 + this.onClick.emit(); 77 + } 63 78 }
+2 -1
src/app/components/shared/logger/logger.component.ts
··· 1 - import { Component } from '@angular/core'; 1 + import {ChangeDetectionStrategy, Component} from '@angular/core'; 2 2 import {MessageService} from '@services/message.service'; 3 3 import {NgClass} from '@angular/common'; 4 4 ··· 8 8 NgClass 9 9 ], 10 10 templateUrl: './logger.component.html', 11 + changeDetection: ChangeDetectionStrategy.OnPush 11 12 }) 12 13 export class LoggerComponent { 13 14
+21
src/app/services/dialog.service.ts
··· 1 + import {Injectable} from '@angular/core'; 2 + import {Dialog} from '@angular/cdk/dialog'; 3 + import {GalleryComponent} from '@components/dialogs/gallery/gallery.component'; 4 + import {AppBskyEmbedImages} from '@atproto/api'; 5 + 6 + @Injectable({ 7 + providedIn: 'root' 8 + }) 9 + export class DialogService { 10 + 11 + constructor( 12 + private dialog: Dialog 13 + ) {} 14 + 15 + openImage(images: AppBskyEmbedImages.ViewImage[], index: number) { 16 + const dialogRef = this.dialog.open(GalleryComponent, { 17 + data: {images: images, index: index}, 18 + hasBackdrop: true 19 + }); 20 + } 21 + }
-3
src/app/views/dashboard/dashboard.component.ts
··· 4 4 import {PostComposerComponent} from '@components/navigation/post-composer/post-composer.component'; 5 5 import {PostService} from '@services/post.service'; 6 6 import {AuxbarComponent} from '@components/navigation/auxbar/auxbar.component'; 7 - // import {MskyDialogService} from '@services/msky-dialog.service'; 8 - // import {PostService} from '@services/post.service'; 9 7 10 8 @Component({ 11 9 selector: 'app-dashboard', ··· 20 18 }) 21 19 export class DashboardComponent { 22 20 constructor( 23 - // protected dialogService: MskyDialogService, 24 21 protected postService: PostService 25 22 ) {} 26 23 }
+1 -1
src/styles.css
··· 14 14 color: var(--color-primary); 15 15 } 16 16 17 - * { 17 + app-root * { 18 18 box-sizing: border-box; 19 19 scrollbar-width: thin; 20 20 scrollbar-gutter: stable;