Coves frontend - a photon fork
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>