Coves frontend - a photon fork
at main 385 lines 10 kB view raw
1<script lang="ts" module> 2 import type { AuthorView, CommunityRef } from '$lib/api/coves/types' 3 import { profile } from '$lib/app/auth.svelte' 4 import { t } from '$lib/app/i18n' 5 import Markdown from '$lib/app/markdown/Markdown.svelte' 6 import { type View, settings } from '$lib/app/settings.svelte' 7 import Avatar from '$lib/ui/generic/Avatar.svelte' 8 import { publishedToDate } from '$lib/ui/util/date' 9 import { Badge, Material, modal, Popover } from 'mono-svelte' 10 import RelativeDate, { 11 formatRelativeDate, 12 } from 'mono-svelte/util/RelativeDate.svelte' 13 import { 14 type IconSource, 15 Bookmark, 16 Icon, 17 Megaphone, 18 PaperAirplane, 19 Pencil, 20 Tag, 21 } from 'svelte-hero-icons/dist' 22 import { SvelteMap } from 'svelte/reactivity' 23 import CommunityLink from '../community/CommunityLink.svelte' 24 import UserLink from '../user/UserLink.svelte' 25 26 type BadgeType = 'saved' | 'featured' 27 export interface MetaTag { 28 content: string 29 color?: string 30 icon?: IconSource | null 31 textColor?: string 32 type: 'flair' | 'custom' 33 } 34 35 // Re-export as Tag for backward compat 36 export type { MetaTag as Tag } 37 38 export const textToTag: Map<string, MetaTag> = new Map<string, MetaTag>([ 39 ['OC', { content: 'OC', color: '#03A8F240', type: 'custom' }], 40 ['NSFL', { content: 'NSFL', color: '#ff000040', type: 'custom' }], 41 ['CW', { content: 'CW', color: '#ff000040', type: 'custom' }], 42 ]) 43 44 export const parseTags = ( 45 title?: string, 46 ): { tags: MetaTag[]; title?: string } => { 47 if (!title) return { tags: [] } 48 49 let extracted: MetaTag[] = [] 50 51 const newTitle = title 52 .toString() 53 .replace(/^(\[.[^\]]+\])|(\[.[^\]]+\])$/g, (match) => { 54 const contents = match.split(',').map((part: string) => part.trim()) 55 56 contents 57 .map((i) => i.replaceAll(/(\[|\])/g, '')) 58 .forEach((content: string) => { 59 extracted.push( 60 textToTag.get(content) ?? { 61 content: content, 62 type: 'custom', 63 }, 64 ) 65 }) 66 return '' 67 }) 68 69 return { 70 tags: extracted, 71 title: newTitle, 72 } 73 } 74</script> 75 76<script lang="ts"> 77 interface Props { 78 community?: CommunityRef 79 showCommunity?: boolean 80 user?: AuthorView 81 published?: Date 82 title?: string 83 uri?: string 84 edited?: string 85 view?: View 86 badges?: Record<BadgeType, boolean> 87 tags?: MetaTag[] 88 style?: string 89 titleClass?: string 90 extraBadges?: import('svelte').Snippet 91 postUrl?: string 92 } 93 94 let { 95 community = $bindable(undefined), 96 showCommunity = true, 97 user, 98 published, 99 title, 100 uri, 101 edited, 102 view = 'cozy', 103 badges = { 104 saved: false, 105 featured: false, 106 }, 107 tags = [], 108 postUrl, 109 style = '', 110 titleClass = '', 111 extraBadges, 112 }: Props = $props() 113 114 const badgeToData: Map< 115 BadgeType, 116 { 117 icon: IconSource 118 color: 'red-subtle' | 'yellow-subtle' | 'green-subtle' 119 label: string 120 } 121 > = new SvelteMap([ 122 [ 123 'saved', 124 { 125 icon: Bookmark, 126 color: 'yellow-subtle', 127 label: $t('post.badges.saved'), 128 }, 129 ], 130 [ 131 'featured', 132 { 133 icon: Megaphone, 134 color: 'green-subtle', 135 label: $t('post.badges.featured'), 136 }, 137 ], 138 ]) 139</script> 140 141<!-- 142 @component 143 This component will build two different things: a post's meta block and the title. 144--> 145<header 146 class={[ 147 'grid w-full meta', 148 community ? 'grid-rows-2' : 'grid-rows-1 minimal', 149 'text-xs min-w-0 max-w-full text-slate-600 dark:text-zinc-400', 150 ]} 151 class:compact={view == 'compact'} 152 {style} 153> 154 {#if showCommunity && community} 155 <Popover> 156 {#snippet target(attachment)} 157 <button 158 {@attach attachment} 159 class={[ 160 'row-span-2 shrink-0 mr-2 self-center group/btn', 161 'bg-slate-200 dark:bg-zinc-800 rounded-lg cursor-pointer', 162 ]} 163 > 164 <Avatar 165 url={community?.avatar} 166 width={view == 'compact' ? 24 : 32} 167 alt={community?.name} 168 circle={false} 169 class="group-hover/btn:scale-90 group-active/btn:scale-[.85] transition-transform" 170 /> 171 </button> 172 {/snippet} 173 {#snippet popover(open)} 174 {#if open && community} 175 <Material 176 color="uniform" 177 rounding="2xl" 178 elevation="high" 179 class="max-w-sm p-4" 180 data-autoclose="false" 181 > 182 <div class="flex items-center gap-3"> 183 <Avatar 184 url={community.avatar} 185 width={48} 186 alt={community.name} 187 circle={false} 188 /> 189 <div class="flex flex-col"> 190 <span class="font-medium text-base">{community.name}</span> 191 {#if community.handle} 192 <span class="text-xs text-slate-500 dark:text-zinc-400"> 193 @{community.handle} 194 </span> 195 {/if} 196 </div> 197 </div> 198 </Material> 199 {/if} 200 {/snippet} 201 </Popover> 202 {/if} 203 {#if showCommunity && community} 204 <CommunityLink 205 {community} 206 style="grid-area: community;" 207 class="shrink no-list-margin" 208 /> 209 {/if} 210 <div 211 class="flex flex-row gap-1.5 items-center 212 no-list-margin {view == 'compact' && showCommunity ? 'min-sm:mx-2' : ''}" 213 style="grid-area: stats;" 214 > 215 {#if user} 216 <address class="contents not-italic"> 217 {#if view == 'compact' && showCommunity} 218 <Icon 219 src={PaperAirplane} 220 size="12" 221 micro 222 class="rotate-180 text-slate-400 dark:text-zinc-600 max-sm:hidden" 223 /> 224 {/if} 225 <UserLink avatarSize={20} {user} avatar={!showCommunity} class="shrink" 226 ></UserLink> 227 </address> 228 {/if} 229 {#if published} 230 <RelativeDate date={published} class="shrink-0" /> 231 {/if} 232 {#if edited} 233 <button 234 title={$t('post.meta.lastEdited', { 235 default: formatRelativeDate(publishedToDate(edited), { 236 style: 'long', 237 }), 238 })} 239 onclick={() => 240 modal({ 241 title: $t('common.info'), 242 body: $t('post.meta.lastEdited', { 243 default: formatRelativeDate(publishedToDate(edited), { 244 style: 'long', 245 }), 246 }), 247 })} 248 > 249 <Icon src={Pencil} micro size="14" /> 250 </button> 251 {/if} 252 </div> 253 <div 254 class="flex flex-row min-sm:justify-end items-center self-center flex-wrap gap-2 *:shrink-0 badges min-sm:ml-2" 255 style="grid-area: badges;" 256 > 257 {#if tags} 258 {#each tags as tag} 259 {@const href = 260 tag.type == 'flair' ? null : `/search?q=[${tag.content}]&type=Posts`} 261 <svelte:element 262 this={href ? 'a' : 'div'} 263 {href} 264 class="hover:brightness-110" 265 style="{tag.color ? `--tag-color: ${tag.color};` : ''} {tag.textColor 266 ? `--tag-text-color: ${tag.textColor}` 267 : ''}" 268 > 269 <Badge class={tag.color ? 'badge-tag-color' : ''}> 270 {#snippet icon()} 271 {#if tag.icon} 272 <Icon src={tag.icon} micro size="14" /> 273 {:else if tag === undefined} 274 <Icon src={Tag} micro size="14" /> 275 {/if} 276 {/snippet} 277 {tag.content} 278 </Badge> 279 </svelte:element> 280 {/each} 281 {/if} 282 {#each Object.keys(badges) 283 // filter by ones that are true 284 .filter((i) => badges[i as BadgeType] == true) 285 // get from predetermined map 286 .map((i) => badgeToData.get(i as BadgeType)) 287 // remove null 288 .filter((i) => i != undefined) as badge} 289 <Badge label={badge.label} color={badge.color} allowIconOnly> 290 {#snippet icon()} 291 <Icon src={badge.icon} micro size="14" />{/snippet}{badge.label} 292 </Badge> 293 {/each} 294 {@render extraBadges?.()} 295 </div> 296</header> 297{#if title && uri} 298 {@const useAttachedUrl = settings.posts.titleOpensUrl && postUrl} 299 <h3 300 class={[ 301 'font-medium max-sm:mt-0! font-display', 302 titleClass, 303 view == 'compact' ? 'text-base' : 'text-lg', 304 ]} 305 style="grid-area: title;" 306 > 307 <a 308 href={useAttachedUrl 309 ? postUrl 310 : `/post/${encodeURIComponent(profile.current.instance)}/${encodeURIComponent(uri)}`} 311 target={useAttachedUrl ? '_blank' : undefined} 312 rel={useAttachedUrl ? 'noopener noreferrer' : undefined} 313 class="inline-block hover:underline hover:text-primary-900 dark:hover:text-primary-100 transition-colors" 314 > 315 <Markdown 316 inline 317 source={title} 318 class={view != 'compact' ? '' : 'leading-[1.3]'} 319 /> 320 </a> 321 </h3> 322{:else} 323 <div style="grid-area: title; margin: 0;"></div> 324{/if} 325 326<style> 327 @reference '../../../app.css'; 328 329 .meta { 330 display: grid; 331 grid-template-areas: 332 'avatar community badges' 333 'avatar stats badges'; 334 gap: 0; 335 grid-template-rows: auto auto auto; 336 grid-template-columns: 40px minmax(0, auto); 337 } 338 339 .meta.minimal { 340 grid-template-columns: 0fr; 341 } 342 343 @media screen and (max-width: 40rem) { 344 .meta.compact { 345 grid-template-areas: 346 'avatar community' 347 'avatar stats' 348 'badges badges'; 349 gap: 0; 350 grid-template-columns: 32px minmax(0, auto); 351 } 352 .meta.minimal { 353 grid-template-columns: 0fr; 354 } 355 } 356 357 @media screen and (min-width: 40rem) { 358 .meta.compact { 359 display: flex; 360 flex-direction: row; 361 align-items: center; 362 } 363 .meta.minimal { 364 grid-template-columns: 0fr; 365 } 366 } 367 368 :global(.badge-tag-color) { 369 background-color: var(--tag-color, #fff) !important; 370 color: var(--tag-text-color, #000) !important; 371 372 @variant dark { 373 background-color: color-mix( 374 in oklab, 375 #222, 376 var(--tag-color, #fff) 377 ) !important; 378 color: color-mix( 379 in oklab, 380 #fff 80%, 381 var(--tag-text-color, #fff) 382 ) !important; 383 } 384 } 385</style>