Multicolumn Bluesky client powered by Angular

feat: post-composer drag and drop

Changed files
+307 -69
src
app
components
shared
pipes
type-guards
+203 -62
src/app/components/navigation/post-composer/post-composer.component.html
··· 1 + @let mediaEmbed = postService.postCompose().mediaEmbed(); 2 + @let recordEmbed = postService.postCompose().recordEmbed(); 3 + @let reply = postService.postCompose().reply(); 4 + @let suggestion = embedSuggestions().length ? (embedSuggestions() | slice : 0 : 1)[0] : undefined; 5 + 1 6 <div 2 - class="flex w-full border-b border-primary box-border" 7 + class="flex relative flex-col w-full border-b border-primary" 3 8 > 4 - <div 5 - class="relative flex-1" 6 - > 9 + @if (showDragOver()) { 7 10 <div 8 - #text autofocus 9 - contenteditable="plaintext-only" 10 - spellcheck="false" 11 - class="absolute top-0 left-0 z-1 w-full h-full p-2 bg-transparent text-transparent outline-0 caret-black" 12 - (input)="formatText($event)" 13 - (paste)="postService.attachMedia($any($event.clipboardData.files))" 14 - (keydown.control.enter)="postBtn.click()" 15 - [mention]="mentionItems" 16 - [mentionConfig]="{ 17 - triggerChar: '@', 18 - labelKey: 'value', 19 - disableSearch: true, 20 - dropUp: true 21 - }" 22 - (searchTerm)="searchMentions($event)" 23 - ></div> 11 + class="absolute top-0 left-0 flex items-center justify-center w-full h-full bg-primary/5 z-1 backdrop-blur-[1px]" 12 + > 13 + <span 14 + class="text-xl underline text-primary/70" 15 + >Drop here</span> 16 + </div> 17 + } 18 + 19 + @if (reply) { 24 20 <div 25 - [innerHTML]="text.textContent.length ? formattedText : undefined" 26 - class="w-full h-full p-2 bg-white text-black empty:text-primary/50 outline-0 break-words whitespace-pre-wrap empty:before:content-['user@consolesky:/$_\_']" 27 - ></div> 28 - </div> 21 + class="relative w-full h-7 flex items-center px-2 bg-primary text-bg font-semibold cursor-default" 22 + > 23 + <span 24 + >Replying to</span> 25 + 26 + <span 27 + class="ml-2" 28 + >{{reply.author | displayName}}</span> 29 + 30 + <span 31 + class="ml-auto font-semibold cursor-pointer hover:underline" 32 + (click)="showReply.set(!showReply())" 33 + >{{ showReply() ? 'hide post' : 'view post' }}</span> 34 + 35 + <span 36 + class="ml-6 font-semibold cursor-pointer hover:underline" 37 + (click)="showReply.set(false); postService.postCompose().reply.set(undefined)" 38 + >cancel</span> 39 + 40 + @if (showReply()) { 41 + <div 42 + class="absolute bg-bg border border-primary bottom-7 right-[-1px]" 43 + > 44 + <post-card 45 + [post]="reply" 46 + class="block w-[30rem] text-primary font-normal" 47 + /> 48 + </div> 49 + } 50 + </div> 51 + } 29 52 30 53 <div 31 - class="flex items-end shrink-0 p-[0.35rem]" 54 + class="flex w-full" 32 55 > 33 - <button 34 - class="btn-secondary h-22 w-22 flex flex-col justify-center items-center" 56 + <div 57 + class="flex-1 min-w-0" 58 + [class.relative]="!showDragOver()" 35 59 > 36 60 <div 37 - class="flex items-center justify-center h-10" 61 + #text autofocus 62 + contenteditable="plaintext-only" 63 + spellcheck="false" 64 + class="absolute top-0 left-0 z-1 w-full h-full p-2 bg-transparent text-transparent outline-0 caret-black" 65 + (input)="formatText($event)" 66 + (paste)="postService.attachMedia($any($event.clipboardData.files))" 67 + (keydown.control.enter)="postBtn.click()" 68 + [mention]="mentionItems" 69 + [mentionConfig]="{ 70 + triggerChar: '@', 71 + labelKey: 'value', 72 + disableSearch: true, 73 + dropUp: true 74 + }" 75 + (searchTerm)="searchMentions($event)" 76 + ></div> 77 + <div 78 + [innerHTML]="text.textContent.length ? formattedText : undefined" 79 + class="w-full h-full p-2 bg-white text-black empty:text-primary/50 outline-0 break-words whitespace-pre-wrap empty:before:content-['user@consolesky:/$_\_']" 80 + ></div> 81 + 82 + 83 + <div 84 + class="relative w-full" 38 85 > 39 86 <span 40 - class="material-icons !text-6xl" 41 - >format_quote</span> 87 + class="absolute bottom-1 right-2 text-primary/50" 88 + [class.text-repost]="(300 - text.textContent.length) > 50" 89 + >{{300 - text.textContent.length}}</span> 42 90 </div> 91 + </div> 43 92 44 - Quote 45 - </button> 46 - </div> 93 + <div 94 + class="flex items-end shrink-0 p-[0.35rem] empty:hidden" 95 + > 96 + 97 + @if (mediaEmbed) { 98 + <button 99 + class="btn-secondary h-22 w-22 flex flex-col justify-center items-center" 100 + > 101 + <div 102 + class="flex items-center justify-center h-10" 103 + > 104 + <span 105 + class="material-icons !text-5xl -translate-y-0.5" 106 + >attachment</span> 107 + </div> 108 + 109 + @if (mediaEmbed | isMediaEmbedImage) { 110 + images 111 + } 112 + @else if (mediaEmbed | isMediaEmbedVideo) { 113 + video 114 + } 115 + @else if (mediaEmbed | isMediaEmbedExternal) { 116 + link 117 + } 118 + </button> 119 + } @else if (suggestion | isMediaEmbedExternal) { 120 + <button 121 + class="btn-secondary h-22 w-22 flex flex-col justify-center items-center border-dashed" 122 + (click)="embedLink()" 123 + > 124 + <div 125 + class="flex items-center justify-center h-8" 126 + > 127 + <span 128 + class="material-icons !text-5xl" 129 + >attachment</span> 130 + </div> 131 + 132 + add link? 133 + </button> 134 + } 47 135 48 - <div 49 - class="w-28 flex flex-col shrink-0 justify-end" 50 - > 136 + @if (recordEmbed) { 137 + <button 138 + class="btn-secondary h-22 w-22 ml-2 flex flex-col justify-center items-center" 139 + > 140 + <div 141 + class="flex items-center justify-center h-10" 142 + > 143 + <span 144 + class="material-icons !text-6xl" 145 + >format_quote</span> 146 + </div> 147 + 148 + quote 149 + </button> 150 + } @else if (suggestion | isRecordEmbed) { 151 + <button 152 + class="btn-secondary h-22 w-22 flex flex-col justify-center items-center border-dashed" 153 + (click)="embedRecord()" 154 + > 155 + <div 156 + class="flex items-center justify-center h-10" 157 + > 158 + <span 159 + class="material-icons !text-6xl" 160 + >format_quote</span> 161 + </div> 162 + 163 + add quote? 164 + </button> 165 + } 166 + </div> 167 + 51 168 <div 52 - class="flex h-fit w-full border-primary" 53 - [class.border-t]="text | postComposerHeight" 169 + class="w-28 flex flex-col shrink-0 justify-end" 54 170 > 55 - <button 56 - class="btn-secondary h-9 flex-1 border-r-0 border-t-0 p-0 flex items-center justify-center" 171 + <div 172 + class="flex h-fit w-full border-primary border-l border-b" 173 + [class.border-t]="text | postComposerHeight" 57 174 > 58 - <span 59 - class="material-icons-outlined" 60 - >mode_comment</span> 61 - </button> 175 + <button 176 + class="btn-secondary h-9 flex-1 border-0 p-0 flex items-center justify-center" 177 + (click)="uploader.click()" 178 + > 179 + <span 180 + class="material-icons-outlined !text-xl" 181 + >image</span> 182 + 183 + <input 184 + #uploader 185 + type="file" 186 + class="hidden" 187 + (change)="postService.attachMedia($any(uploader.files))" 188 + /> 189 + </button> 62 190 63 - <button 64 - class="btn-secondary h-9 flex-1 border-r-0 border-t-0 p-0 flex items-center justify-center" 65 - > 66 - <span 67 - class="material-icons-outlined" 68 - >mode_comment</span> 69 - </button> 191 + <div 192 + class="h-full border-l border-primary" 193 + ></div> 194 + 195 + <button 196 + disabled 197 + class="btn-secondary h-9 flex-1 border-0 p-0 flex items-center justify-center" 198 + > 199 + <span 200 + class="material-icons-outlined !text-xl" 201 + >sentiment_satisfied_alt</span> 202 + </button> 203 + 204 + <div 205 + class="h-full border-l border-primary" 206 + ></div> 207 + 208 + <button 209 + disabled 210 + class="btn-secondary h-9 flex-1 border-0 p-0 flex items-center justify-center" 211 + > 212 + <span 213 + class="material-icons-outlined !text-[2em]" 214 + >gif</span> 215 + </button> 216 + </div> 70 217 71 218 <button 72 - class="btn-secondary h-9 flex-1 border-r-0 border-t-0 p-0 flex items-center justify-center" 73 - > 74 - <span 75 - class="material-icons-outlined" 76 - >mode_comment</span> 77 - </button> 219 + #postBtn 220 + class="btn-primary font-semibold h-16 w-full border-0 border-l" 221 + [disabled]="loading || text.innerText.length > 300 || (!text.innerText.length && !mediaEmbed && !recordEmbed)" 222 + (click)="publishPost()" 223 + >post</button> 78 224 </div> 79 - <button 80 - #postBtn 81 - class="btn-primary h-16 w-full border-r-0 border-b-0" 82 - (click)="publishPost()" 83 - >Post</button> 84 225 </div> 85 226 </div>
+73 -3
src/app/components/navigation/post-composer/post-composer.component.ts
··· 1 - import {ChangeDetectionStrategy, ChangeDetectorRef, Component, signal, WritableSignal} from '@angular/core'; 1 + import { 2 + ChangeDetectionStrategy, 3 + ChangeDetectorRef, 4 + Component, ElementRef, 5 + HostListener, 6 + signal, 7 + WritableSignal 8 + } from '@angular/core'; 2 9 import {$Typed, AppBskyFeedDefs, AppBskyGraphDefs, RichText} from '@atproto/api'; 3 - import {ExternalEmbed, ImageEmbed, RecordEmbed} from '@models/embed'; 10 + import {ExternalEmbed, ImageEmbed, RecordEmbed, RecordEmbedType} from '@models/embed'; 4 11 import {EmbedUtils} from '@shared/utils/embed-utils'; 5 12 import {PostService} from '@services/post.service'; 6 13 import {EmbedService} from '@services/embed.service'; ··· 10 17 import {SnippetUtils} from '@shared/utils/snippet-utils'; 11 18 import {MentionModule} from 'angular-mentions'; 12 19 import {PostComposerHeightPipe} from '@shared/pipes/post-composer-height.pipe'; 20 + import {DisplayNamePipe} from '@shared/pipes/display-name.pipe'; 21 + import {PostCardComponent} from '@components/cards/post-card/post-card.component'; 22 + import {IsMediaEmbedImagePipe} from '@shared/pipes/type-guards/is-media-embed-image'; 23 + import {IsMediaEmbedVideoPipe} from '@shared/pipes/type-guards/is-media-embed-video'; 24 + import {IsMediaEmbedExternalPipe} from '@shared/pipes/type-guards/is-media-embed-external'; 25 + import {SlicePipe} from '@angular/common'; 26 + import {IsRecordEmbedPipe} from '@shared/pipes/type-guards/is-record-embed'; 13 27 14 28 @Component({ 15 29 selector: 'post-composer', 16 30 imports: [ 17 31 MentionModule, 18 - PostComposerHeightPipe 32 + PostComposerHeightPipe, 33 + DisplayNamePipe, 34 + PostCardComponent, 35 + IsMediaEmbedImagePipe, 36 + IsMediaEmbedVideoPipe, 37 + IsMediaEmbedExternalPipe, 38 + SlicePipe, 39 + IsRecordEmbedPipe 19 40 ], 20 41 templateUrl: './post-composer.component.html', 21 42 styles: ` ··· 46 67 mentionItems = []; 47 68 loading = false; 48 69 embedSuggestions = signal<Array<RecordEmbed | ExternalEmbed>>([]); 70 + showReply = signal(false); 71 + showMedia = signal(false); 72 + showRecord = signal(false); 73 + showDragOver = signal(false); 49 74 50 75 constructor( 51 76 protected postService: PostService, 52 77 private embedService: EmbedService, 78 + private elementRef: ElementRef, 53 79 private cdRef: ChangeDetectorRef 54 80 ) {} 55 81 ··· 124 150 } 125 151 } 126 152 153 + embedRecord() { 154 + const embed = this.embedSuggestions()[0] as RecordEmbed; 155 + switch (embed.recordType) { 156 + case RecordEmbedType.POST: 157 + this.embedQuote(); 158 + break; 159 + case RecordEmbedType.FEED: 160 + this.embedFeed(); 161 + break; 162 + case RecordEmbedType.LIST: 163 + this.embedList(); 164 + break; 165 + case RecordEmbedType.STARTER_PACK: 166 + this.embedStarterPack(); 167 + break; 168 + } 169 + } 170 + 127 171 embedQuote() { 128 172 const embed = this.embedSuggestions()[0] as RecordEmbed; 129 173 agent.resolveHandle({ ··· 186 230 //TODO: MessageService 187 231 err => console.log(err.message) 188 232 ).finally(() => this.loading = false); 233 + } 234 + 235 + @HostListener('dragenter', ['$event']) 236 + onDragEnter(event: Event) { 237 + event.preventDefault(); 238 + 239 + if (this.elementRef.nativeElement.contains((event as any).currentTarget)) { 240 + this.showDragOver.set(true); 241 + } 242 + } 243 + 244 + @HostListener('dragleave', ['$event']) 245 + onDragLeave(event: Event) { 246 + event.preventDefault(); 247 + 248 + if (!this.elementRef.nativeElement.contains((event as any).relatedTarget)) { 249 + this.showDragOver.set(false); 250 + } 251 + } 252 + 253 + @HostListener('drop', ['$event']) 254 + onDrop(event: Event) { 255 + event.preventDefault(); 256 + 257 + this.showDragOver.set(false); 258 + this.postService.attachMedia((event as any).dataTransfer.files); 189 259 } 190 260 }
+1 -1
src/app/components/navigation/sidebar/sidebar.component.html
··· 23 23 > 24 24 {{ postService.postCompose() ? 'X' : '>_' }} 25 25 </span> 26 - {{ postService.postCompose() ? 'Cancel post' : 'Write post' }} 26 + {{ postService.postCompose() ? 'cancel post' : 'write post' }} 27 27 </button> 28 28 </div>
+11
src/app/shared/pipes/type-guards/is-record-embed.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {EmbedType, RecordEmbed} from "@models/embed"; 3 + 4 + @Pipe({ 5 + name: 'isRecordEmbed' 6 + }) 7 + export class IsRecordEmbedPipe implements PipeTransform { 8 + transform(value: unknown): value is RecordEmbed { 9 + return (value as RecordEmbed)?.type == EmbedType.RECORD; 10 + } 11 + }
+19 -3
src/styles.css
··· 192 192 @layer components { 193 193 .btn-primary { 194 194 box-sizing: border-box; 195 - border: 1px solid var(--color-primary); 195 + border: 1px solid; 196 + border-color: var(--color-primary); 196 197 background-color: var(--color-primary); 197 198 color: var(--color-bg); 198 199 width: fit-content; ··· 205 206 background-color: var(--color-bg); 206 207 color: var(--color-primary); 207 208 } 209 + 210 + &:disabled { 211 + pointer-events: none; 212 + opacity: 0.3; 213 + } 208 214 } 209 215 210 216 .btn-secondary { 211 217 box-sizing: border-box; 212 - border: 1px solid var(--color-primary); 218 + border: 1px solid; 219 + border-color: var(--color-primary); 213 220 background-color: var(--color-bg); 214 221 color: var(--color-primary); 215 222 width: fit-content; ··· 219 226 min-height: 2em; 220 227 221 228 &:hover { 222 - background-color: color-mix(in oklab, var(--color-primary) /* #000 = #000000 */ 15%, transparent); 229 + background-color: color-mix(in oklab, var(--color-primary) /* #000 = #000000 */ 10%, transparent); 223 230 color: var(--color-primary); 231 + } 232 + 233 + &:disabled { 234 + pointer-events: none; 235 + opacity: 0.3; 224 236 } 225 237 } 226 238 ··· 238 250 &:hover { 239 251 background-color: var(--color-primary); 240 252 color: var(--color-bg); 253 + } 254 + 255 + &:disabled { 256 + pointer-events: none; 241 257 } 242 258 } 243 259 }