Coves frontend - a photon fork
at main 285 lines 8.2 kB view raw
1<script lang="ts"> 2 import { page } from '$app/state' 3 import type { StrongRef } from '$lib/api/coves/types' 4 import type { DID } from '$lib/types/atproto' 5 import { profile } from '$lib/app/auth.svelte' 6 import { t } from '$lib/app/i18n' 7 import Markdown from '$lib/app/markdown/Markdown.svelte' 8 import { settings } from '$lib/app/settings.svelte' 9 import { publishedToDate } from '$lib/ui/util/date' 10 import { Button, Modal, toast } from 'mono-svelte' 11 import RelativeDate from 'mono-svelte/util/RelativeDate.svelte' 12 import { Icon, Microphone, Minus, Plus, Trash } from 'svelte-hero-icons/dist' 13 import { expoOut } from 'svelte/easing' 14 import type { ClassValue } from 'svelte/elements' 15 import { slide } from 'svelte/transition' 16 import UserLink from '../user/UserLink.svelte' 17 import CommentActions from './CommentActions.svelte' 18 import CommentForm from './CommentForm.svelte' 19 import { 20 type CommentNodeI, 21 createOptimisticCommentView, 22 } from './comments.svelte' 23 24 interface Props { 25 node: CommentNodeI 26 postRef: StrongRef 27 postAuthorDid?: DID 28 actions?: boolean 29 meta?: boolean 30 open?: boolean 31 replying?: boolean 32 contentClass?: ClassValue 33 class?: ClassValue 34 metaSuffix?: import('svelte').Snippet 35 children?: import('svelte').Snippet 36 } 37 38 let { 39 node = $bindable(), 40 postRef, 41 postAuthorDid, 42 actions = true, 43 meta = true, 44 replying = $bindable(false), 45 open = $bindable(true), 46 contentClass = '', 47 class: clazz = '', 48 metaSuffix, 49 children, 50 }: Props = $props() 51 52 let editing = $state(false) 53 let newComment = $state(node.comment.record.content) 54 let editingLoad = $state(false) 55 56 async function save() { 57 if (!profile.current?.jwt || newComment.length <= 0) return 58 59 editingLoad = true 60 61 try { 62 // TODO(coves-migration): Implement edit comment when backend API is available 63 toast({ 64 content: 'Editing comments is not yet supported.', 65 type: 'warning', 66 }) 67 editing = false 68 } catch (err) { 69 toast({ 70 content: err instanceof Error ? err.message : String(err), 71 type: 'error', 72 }) 73 } 74 75 editingLoad = false 76 } 77</script> 78 79{#if editing} 80 <Modal bind:open={editing}> 81 {#snippet customTitle()} 82 <div>{$t('form.edit')}</div> 83 {/snippet} 84 <form 85 onsubmit={(e) => { 86 e.preventDefault() 87 save() 88 }} 89 class="contents" 90 > 91 <CommentForm 92 bind:value={newComment} 93 {postRef} 94 actions={false} 95 preview={true} 96 /> 97 <Button 98 submit 99 color="primary" 100 size="lg" 101 loading={editingLoad} 102 disabled={editingLoad} 103 class="w-full" 104 > 105 {$t('form.submit')} 106 </Button> 107 </form> 108 </Modal> 109{/if} 110 111<li class={['py-3 relative', clazz]} id={node.comment.uri as string}> 112 {#if meta} 113 {@const creatorIsOp = 114 postAuthorDid !== undefined && node.comment.author.did === postAuthorDid} 115 <label 116 for="comment-expand-{node.comment.uri}" 117 class="flex flex-row cursor-pointer gap-2 items-center group text-sm flex-wrap w-full z-0 group relative" 118 > 119 <div 120 class={[ 121 'absolute -inset-0.5 right-1 group-hover:right-0 group-hover:-inset-1.5 opacity-0 group-hover:opacity-100 transition-all', 122 'bg-slate-100 dark:bg-zinc-900 -z-10 rounded-full inline-flex items-center justify-end', 123 ]} 124 > 125 {#if node.comment.stats.replyCount > 0} 126 {@const replyCount = node.comment.stats.replyCount} 127 <div 128 aria-label={$t('aria.comments.children', { 129 childCount: replyCount, 130 })} 131 class="font-medium" 132 > 133 {replyCount} 134 </div> 135 {/if} 136 <div 137 class={[ 138 !open && 'rotate-90', 139 'transition-all duration-500 ease-out my-auto h-full w-8 grid place-items-center', 140 ]} 141 > 142 <Icon src={open ? Minus : Plus} size="16" micro /> 143 </div> 144 </div> 145 {@render metaSuffix?.()} 146 <span 147 class={[ 148 'flex flex-row gap-1 items-center', 149 creatorIsOp && 'text-blue-600 dark:text-blue-400 font-bold', 150 ]} 151 > 152 <UserLink inComment avatarSize={20} avatar user={node.comment.author} /> 153 </span> 154 {#if creatorIsOp} 155 <Icon 156 mini 157 size="16" 158 src={Microphone} 159 class="text-blue-500 dark:text-blue-400" 160 /> 161 {/if} 162 <RelativeDate 163 class="text-slate-600 dark:text-zinc-400" 164 date={publishedToDate(node.comment.createdAt)} 165 /> 166 <span class="text-slate-600 dark:text-zinc-400 flex flex-row gap-2 ml-1"> 167 {#if node.comment.isDeleted} 168 <Icon 169 src={Trash} 170 solid 171 size="12" 172 aria-label={$t('post.badges.deleted')} 173 class="text-red-600 dark:text-red-500" 174 /> 175 {/if} 176 {#if node.comment.deletionReason} 177 <Icon 178 src={Trash} 179 solid 180 size="12" 181 aria-label={$t('post.badges.removed')} 182 class="text-green-600 dark:text-green-500" 183 /> 184 {/if} 185 </span> 186 {#if settings.debugInfo} 187 <span class="text-slate-600 dark:text-zinc-400 font-mono ml-auto"> 188 {node.comment.uri} 189 </span> 190 {/if} 191 </label> 192 {/if} 193 <input 194 class="appearance-none absolute top-0 left-0 h-8 w-full pointer-events-none comment-expand" 195 type="checkbox" 196 id="comment-expand-{node.comment.uri}" 197 bind:checked={open} 198 /> 199 <div class={['expand max-w-full', contentClass]} inert={!open}> 200 <div id="comment-content"> 201 <div 202 class={[ 203 'flex flex-col whitespace-pre-wrap max-w-full gap-1 mt-1 relative w-full', 204 ]} 205 > 206 <Markdown 207 source={node.comment.record.content} 208 noStyle 209 class={[ 210 'text-[15px] sm:text-base text-slate-700 dark:text-zinc-300 *:leading-[1.6] break-words space-y-3', 211 page.url.hash.slice(1) === (node.comment.uri as string) && 212 'material-info px-3 py-1.5 rounded-xl max-w-max', 213 ]} 214 /> 215 {#if actions} 216 <!-- TODO(coves-migration): Re-enable ban/lock checking when API provides banned_from_community and post.locked fields --> 217 <CommentActions 218 comment={node.comment} 219 bind:replying 220 onedit={() => (editing = true)} 221 disabled={false} 222 /> 223 {/if} 224 </div> 225 226 {#if replying} 227 <div transition:slide={{ duration: 600, easing: expoOut }}> 228 <CommentForm 229 label={$t('comment.reply')} 230 {postRef} 231 parentRef={{ uri: node.comment.uri, cid: node.comment.cid }} 232 oncomment={(output, content) => { 233 const currentProfile = profile.current 234 if (!currentProfile || currentProfile.type !== 'authenticated') { 235 replying = false 236 return 237 } 238 const comment = createOptimisticCommentView( 239 output, 240 content, 241 postRef, 242 { uri: node.comment.uri, cid: node.comment.cid }, 243 { 244 did: currentProfile.did, 245 handle: currentProfile.handle, 246 avatar: currentProfile.avatar, 247 }, 248 ) 249 node.children = [ 250 { 251 children: [], 252 comment, 253 depth: node.depth + 1, 254 expanded: true, 255 }, 256 ...node.children, 257 ] 258 replying = false 259 }} 260 oncancel={() => (replying = false)} 261 /> 262 </div> 263 {/if} 264 {@render children?.()} 265 </div> 266 </div> 267</li> 268 269<style> 270 .expand { 271 display: grid; 272 grid-template-rows: 0fr; 273 grid-template-columns: 100%; 274 overflow: hidden; 275 transition: grid-template-rows 0.5s cubic-bezier(0.19, 1, 0.22, 1); 276 } 277 278 .comment-expand:checked + .expand { 279 grid-template-rows: 1fr; 280 } 281 282 .expand > * { 283 min-height: 0; 284 } 285</style>