@ewanc26/svelte-standard-site 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/ActionBar.svelte +85 -0
- package/dist/components/ActionBar.svelte.d.ts +13 -0
- package/dist/components/Avatar.svelte +104 -0
- package/dist/components/Avatar.svelte.d.ts +19 -0
- package/dist/components/Comment.svelte +172 -0
- package/dist/components/Comment.svelte.d.ts +22 -0
- package/dist/components/CommentsSection.svelte +89 -0
- package/dist/components/DocumentCard.svelte +126 -56
- package/dist/components/DocumentCard.svelte.d.ts +51 -0
- package/dist/components/Footnotes.svelte +72 -0
- package/dist/components/Footnotes.svelte.d.ts +13 -0
- package/dist/components/RecommendButton.svelte +153 -0
- package/dist/components/RecommendButton.svelte.d.ts +17 -0
- package/dist/components/ThemeProvider.svelte +92 -0
- package/dist/components/ThemeProvider.svelte.d.ts +13 -0
- package/dist/components/Toast.svelte +177 -0
- package/dist/components/Toast.svelte.d.ts +32 -0
- package/dist/components/Watermark.svelte +100 -0
- package/dist/components/Watermark.svelte.d.ts +17 -0
- package/dist/components/common/ThemedCard.svelte +15 -15
- package/dist/components/common/ThemedCard.svelte.d.ts +5 -0
- package/dist/components/document/BlockRenderer.svelte +3 -0
- package/dist/components/document/DocumentRenderer.svelte +41 -1
- package/dist/components/document/RichText.svelte +87 -2
- package/dist/components/document/RichText.svelte.d.ts +2 -0
- package/dist/components/document/blocks/OrderedListBlock.svelte +152 -0
- package/dist/components/document/blocks/UnorderedListBlock.svelte +1 -1
- package/dist/components/index.d.ts +28 -0
- package/dist/components/index.js +30 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +6 -4
- package/dist/publisher.d.ts +73 -0
- package/dist/publisher.js +185 -0
- package/dist/schemas.d.ts +1162 -2
- package/dist/schemas.js +316 -0
- package/dist/types.d.ts +393 -2
- package/dist/types.js +1 -1
- package/dist/utils/native-comments.d.ts +68 -0
- package/dist/utils/native-comments.js +149 -0
- package/dist/utils/theme-helpers.d.ts +41 -1
- package/dist/utils/theme-helpers.js +98 -1
- package/dist/utils/theme.d.ts +48 -1
- package/dist/utils/theme.js +158 -0
- package/package.json +62 -65
- package/src/lib/components/ActionBar.svelte +85 -0
- package/src/lib/components/Avatar.svelte +104 -0
- package/src/lib/components/Comment.svelte +172 -0
- package/src/lib/components/CommentsSection.svelte +89 -0
- package/src/lib/components/DocumentCard.svelte +126 -56
- package/src/lib/components/Footnotes.svelte +72 -0
- package/src/lib/components/RecommendButton.svelte +153 -0
- package/src/lib/components/ThemeProvider.svelte +92 -0
- package/src/lib/components/Toast.svelte +177 -0
- package/src/lib/components/Watermark.svelte +100 -0
- package/src/lib/components/common/ThemedCard.svelte +15 -15
- package/src/lib/components/document/BlockRenderer.svelte +3 -0
- package/src/lib/components/document/DocumentRenderer.svelte +41 -1
- package/src/lib/components/document/RichText.svelte +87 -2
- package/src/lib/components/document/blocks/OrderedListBlock.svelte +152 -0
- package/src/lib/components/document/blocks/UnorderedListBlock.svelte +1 -1
- package/src/lib/components/index.ts +32 -0
- package/src/lib/index.ts +119 -5
- package/src/lib/publisher.ts +251 -0
- package/src/lib/schemas.ts +411 -0
- package/src/lib/types.ts +506 -2
- package/src/lib/utils/native-comments.ts +197 -0
- package/src/lib/utils/theme-helpers.ts +136 -3
- package/src/lib/utils/theme.ts +189 -1
- package/dist/components/document/blocks/UnorderedListBlock.svelte.d.ts +0 -9
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import RichText from './document/RichText.svelte';
|
|
3
|
+
import Comment from './Comment.svelte';
|
|
4
|
+
import type { CommentRecord, LinearDocumentQuote } from '$lib/types.js';
|
|
5
|
+
|
|
6
|
+
interface CommentProps extends CommentRecord {
|
|
7
|
+
uri: string;
|
|
8
|
+
cid: string;
|
|
9
|
+
author: {
|
|
10
|
+
did: string;
|
|
11
|
+
handle?: string;
|
|
12
|
+
displayName?: string;
|
|
13
|
+
avatar?: string;
|
|
14
|
+
};
|
|
15
|
+
replies?: CommentProps[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
comment: CommentProps;
|
|
20
|
+
hasTheme?: boolean;
|
|
21
|
+
/** Whether to show the attachment/quote */
|
|
22
|
+
showQuote?: boolean;
|
|
23
|
+
/** Depth in the thread (for styling) */
|
|
24
|
+
depth?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { comment, hasTheme = false, showQuote = true, depth = 0 }: Props = $props();
|
|
28
|
+
|
|
29
|
+
// Format date
|
|
30
|
+
const formattedDate = $derived(
|
|
31
|
+
new Date(comment.createdAt).toLocaleDateString(undefined, {
|
|
32
|
+
year: 'numeric',
|
|
33
|
+
month: 'short',
|
|
34
|
+
day: 'numeric'
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Author display name
|
|
39
|
+
const authorName = $derived(
|
|
40
|
+
comment.author.displayName || comment.author.handle || comment.author.did
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Author profile URL
|
|
44
|
+
const authorUrl = $derived(`https://leaflet.pub/p/${comment.author.did}`);
|
|
45
|
+
|
|
46
|
+
// Quote text extraction
|
|
47
|
+
const quoteText = $derived(
|
|
48
|
+
comment.attachment?.quote ? extractQuotePreview(comment.attachment) : null
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
function extractQuotePreview(quote: LinearDocumentQuote): string {
|
|
52
|
+
if (quote.quote) {
|
|
53
|
+
return `Quote from block ${quote.quote.start.block.join('.')}`;
|
|
54
|
+
}
|
|
55
|
+
return 'Document quote';
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<article
|
|
60
|
+
class="comment"
|
|
61
|
+
class:themed={hasTheme}
|
|
62
|
+
class:reply={depth > 0}
|
|
63
|
+
style:padding-left={depth > 0 ? '1rem' : undefined}
|
|
64
|
+
>
|
|
65
|
+
<header class="comment-header flex items-center gap-3 mb-2">
|
|
66
|
+
{#if comment.author.avatar}
|
|
67
|
+
<img
|
|
68
|
+
src={comment.author.avatar}
|
|
69
|
+
alt={authorName}
|
|
70
|
+
class="w-8 h-8 rounded-full"
|
|
71
|
+
/>
|
|
72
|
+
{:else}
|
|
73
|
+
<div
|
|
74
|
+
class="avatar-placeholder w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
|
|
75
|
+
class:themed={hasTheme}
|
|
76
|
+
>
|
|
77
|
+
{authorName.charAt(0).toUpperCase()}
|
|
78
|
+
</div>
|
|
79
|
+
{/if}
|
|
80
|
+
|
|
81
|
+
<div class="flex-1 min-w-0">
|
|
82
|
+
<a
|
|
83
|
+
href={authorUrl}
|
|
84
|
+
target="_blank"
|
|
85
|
+
rel="noopener noreferrer"
|
|
86
|
+
class="author-name font-medium hover:underline"
|
|
87
|
+
class:themed={hasTheme}
|
|
88
|
+
>
|
|
89
|
+
{authorName}
|
|
90
|
+
</a>
|
|
91
|
+
{#if comment.author.handle}
|
|
92
|
+
<span class="author-handle text-sm opacity-60">@{comment.author.handle}</span>
|
|
93
|
+
{/if}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<time datetime={comment.createdAt} class="text-sm opacity-60">
|
|
97
|
+
{formattedDate}
|
|
98
|
+
</time>
|
|
99
|
+
</header>
|
|
100
|
+
|
|
101
|
+
<div class="comment-body">
|
|
102
|
+
{#if showQuote && comment.attachment}
|
|
103
|
+
<blockquote class="quote-attachment mb-2 px-3 py-2 border-l-2 text-sm opacity-80">
|
|
104
|
+
{quoteText}
|
|
105
|
+
</blockquote>
|
|
106
|
+
{/if}
|
|
107
|
+
|
|
108
|
+
<div class="comment-text">
|
|
109
|
+
<RichText plaintext={comment.plaintext} facets={comment.facets} {hasTheme} />
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{#if comment.replies && comment.replies.length > 0}
|
|
114
|
+
<div class="replies mt-4 space-y-4 border-l-2 pl-4">
|
|
115
|
+
{#each comment.replies as reply}
|
|
116
|
+
<Comment comment={reply} {hasTheme} {showQuote} depth={depth + 1} />
|
|
117
|
+
{/each}
|
|
118
|
+
</div>
|
|
119
|
+
{/if}
|
|
120
|
+
</article>
|
|
121
|
+
|
|
122
|
+
<style>
|
|
123
|
+
.comment {
|
|
124
|
+
padding: 1rem;
|
|
125
|
+
border-radius: 0.5rem;
|
|
126
|
+
background-color: rgb(249 250 251);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.comment.themed {
|
|
130
|
+
background-color: color-mix(in srgb, var(--theme-foreground) 5%, transparent);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.comment.reply {
|
|
134
|
+
background-color: transparent;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.avatar-placeholder {
|
|
138
|
+
background-color: rgb(229 231 235);
|
|
139
|
+
color: rgb(107 114 128);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.avatar-placeholder.themed {
|
|
143
|
+
background-color: color-mix(in srgb, var(--theme-accent) 20%, transparent);
|
|
144
|
+
color: var(--theme-accent);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.author-name {
|
|
148
|
+
color: rgb(0 0 0);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.author-name.themed {
|
|
152
|
+
color: var(--theme-foreground);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.quote-attachment {
|
|
156
|
+
border-color: rgb(209 213 219);
|
|
157
|
+
background-color: rgb(255 255 255);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.comment:global(.themed) .quote-attachment {
|
|
161
|
+
border-color: color-mix(in srgb, var(--theme-foreground) 20%, transparent);
|
|
162
|
+
background-color: color-mix(in srgb, var(--theme-background) 50%, transparent);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.replies {
|
|
166
|
+
border-color: rgb(229 231 235);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.replies:global(.themed) {
|
|
170
|
+
border-color: color-mix(in srgb, var(--theme-foreground) 10%, transparent);
|
|
171
|
+
}
|
|
172
|
+
</style>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Comment from './Comment.svelte';
|
|
3
|
+
import { organizeCommentsIntoThreads } from '$lib/utils/native-comments.js';
|
|
4
|
+
|
|
5
|
+
interface CommentProps {
|
|
6
|
+
uri: string;
|
|
7
|
+
cid: string;
|
|
8
|
+
author: {
|
|
9
|
+
did: string;
|
|
10
|
+
handle?: string;
|
|
11
|
+
displayName?: string;
|
|
12
|
+
avatar?: string;
|
|
13
|
+
};
|
|
14
|
+
replies?: CommentProps[];
|
|
15
|
+
$type: 'pub.leaflet.comment';
|
|
16
|
+
subject: string;
|
|
17
|
+
plaintext: string;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
facets?: any[];
|
|
20
|
+
attachment?: any;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
/** AT-URI of the document */
|
|
25
|
+
subject: string;
|
|
26
|
+
/** Raw comments from API */
|
|
27
|
+
comments?: Array<any>;
|
|
28
|
+
/** Pre-organized comment threads */
|
|
29
|
+
threads?: CommentProps[];
|
|
30
|
+
hasTheme?: boolean;
|
|
31
|
+
/** Show quote attachments */
|
|
32
|
+
showQuotes?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { subject, comments = [], threads: propThreads, hasTheme = false, showQuotes = true }: Props = $props();
|
|
36
|
+
|
|
37
|
+
// Organize comments into threads if not provided
|
|
38
|
+
const commentThreads = $derived(
|
|
39
|
+
propThreads ?? organizeCommentsIntoThreads(comments)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// Total comment count
|
|
43
|
+
const totalComments = $derived(
|
|
44
|
+
commentThreads.reduce((sum, thread) => sum + countThread(thread), 0)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
function countThread(thread: any): number {
|
|
48
|
+
let count = 1;
|
|
49
|
+
if (thread.replies) {
|
|
50
|
+
for (const reply of thread.replies) {
|
|
51
|
+
count += countThread(reply);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return count;
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<section class="comments-section" class:themed={hasTheme}>
|
|
59
|
+
<header class="comments-header mb-6">
|
|
60
|
+
<h2 class="text-xl font-semibold">
|
|
61
|
+
Comments
|
|
62
|
+
{#if totalComments > 0}
|
|
63
|
+
<span class="text-sm font-normal opacity-60">({totalComments})</span>
|
|
64
|
+
{/if}
|
|
65
|
+
</h2>
|
|
66
|
+
</header>
|
|
67
|
+
|
|
68
|
+
{#if commentThreads.length === 0}
|
|
69
|
+
<p class="text-sm opacity-60">No comments yet.</p>
|
|
70
|
+
{:else}
|
|
71
|
+
<div class="comments-list space-y-4">
|
|
72
|
+
{#each commentThreads as thread}
|
|
73
|
+
<Comment comment={thread} {hasTheme} showQuote={showQuotes} />
|
|
74
|
+
{/each}
|
|
75
|
+
</div>
|
|
76
|
+
{/if}
|
|
77
|
+
</section>
|
|
78
|
+
|
|
79
|
+
<style>
|
|
80
|
+
.comments-section {
|
|
81
|
+
margin-top: 2rem;
|
|
82
|
+
padding-top: 2rem;
|
|
83
|
+
border-top: 1px solid rgb(229 231 235);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.comments-section.themed {
|
|
87
|
+
border-top-color: color-mix(in srgb, var(--theme-foreground) 10%, transparent);
|
|
88
|
+
}
|
|
89
|
+
</style>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { Document, Publication, AtProtoRecord } from '../types.js';
|
|
3
3
|
import { extractRkey } from '$lib/index.js';
|
|
4
4
|
import { ThemedCard, ThemedText, DateDisplay, TagList } from './index.js';
|
|
5
|
+
import type { Snippet } from 'svelte';
|
|
5
6
|
|
|
6
7
|
interface Props {
|
|
7
8
|
document: AtProtoRecord<Document>;
|
|
@@ -9,6 +10,45 @@
|
|
|
9
10
|
class?: string;
|
|
10
11
|
showCover?: boolean;
|
|
11
12
|
href?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Custom render snippet for the entire card content.
|
|
15
|
+
* Receives { document, publication, href }.
|
|
16
|
+
* If provided, default rendering is replaced entirely.
|
|
17
|
+
*/
|
|
18
|
+
layout?: Snippet<
|
|
19
|
+
[
|
|
20
|
+
{
|
|
21
|
+
document: AtProtoRecord<Document>;
|
|
22
|
+
publication?: AtProtoRecord<Publication>;
|
|
23
|
+
href: string;
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
>;
|
|
27
|
+
/**
|
|
28
|
+
* Custom render snippet for the cover image.
|
|
29
|
+
* Receives { src, alt }.
|
|
30
|
+
*/
|
|
31
|
+
cover?: Snippet<[{ src: string; alt: string }]>;
|
|
32
|
+
/**
|
|
33
|
+
* Custom render snippet for the title.
|
|
34
|
+
* Receives { title, href }.
|
|
35
|
+
*/
|
|
36
|
+
title?: Snippet<[{ title: string; href: string }]>;
|
|
37
|
+
/**
|
|
38
|
+
* Custom render snippet for the description.
|
|
39
|
+
* Receives { description }.
|
|
40
|
+
*/
|
|
41
|
+
description?: Snippet<[{ description: string }]>;
|
|
42
|
+
/**
|
|
43
|
+
* Custom render snippet for the metadata section.
|
|
44
|
+
* Receives { publishedAt, updatedAt }.
|
|
45
|
+
*/
|
|
46
|
+
metadata?: Snippet<[{ publishedAt: string; updatedAt?: string }]>;
|
|
47
|
+
/**
|
|
48
|
+
* Custom render snippet for tags.
|
|
49
|
+
* Receives { tags }.
|
|
50
|
+
*/
|
|
51
|
+
tags?: Snippet<[{ tags: string[] }]>;
|
|
12
52
|
}
|
|
13
53
|
|
|
14
54
|
const {
|
|
@@ -16,7 +56,13 @@
|
|
|
16
56
|
publication,
|
|
17
57
|
class: className = '',
|
|
18
58
|
showCover = true,
|
|
19
|
-
href: customHref
|
|
59
|
+
href: customHref,
|
|
60
|
+
layout,
|
|
61
|
+
cover,
|
|
62
|
+
title,
|
|
63
|
+
description,
|
|
64
|
+
metadata,
|
|
65
|
+
tags
|
|
20
66
|
}: Props = $props();
|
|
21
67
|
|
|
22
68
|
const value = $derived(document.value);
|
|
@@ -32,64 +78,88 @@
|
|
|
32
78
|
{href}
|
|
33
79
|
class="flex gap-6 duration-200 focus-within:shadow-md hover:shadow-md {className}"
|
|
34
80
|
>
|
|
35
|
-
{#if
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
>
|
|
49
|
-
{value.title}
|
|
50
|
-
</ThemedText>
|
|
51
|
-
|
|
52
|
-
{#if value.description}
|
|
53
|
-
<ThemedText
|
|
54
|
-
{hasTheme}
|
|
55
|
-
opacity={70}
|
|
56
|
-
element="p"
|
|
57
|
-
class="mb-4 line-clamp-3 text-sm leading-relaxed"
|
|
58
|
-
>
|
|
59
|
-
{value.description}
|
|
60
|
-
</ThemedText>
|
|
81
|
+
{#if layout}
|
|
82
|
+
{@render layout({ document, publication, href })}
|
|
83
|
+
{:else}
|
|
84
|
+
{#if showCover && value.coverImage}
|
|
85
|
+
{#if cover}
|
|
86
|
+
{@render cover({ src: value.coverImage, alt: `${value.title} cover` })}
|
|
87
|
+
{:else}
|
|
88
|
+
<img
|
|
89
|
+
src={value.coverImage}
|
|
90
|
+
alt="{value.title} cover"
|
|
91
|
+
class="h-48 w-32 shrink-0 rounded-lg object-cover shadow-sm"
|
|
92
|
+
/>
|
|
93
|
+
{/if}
|
|
61
94
|
{/if}
|
|
62
95
|
|
|
63
|
-
<div class="
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
{#if value.updatedAt}
|
|
72
|
-
<span
|
|
73
|
-
class="flex items-center gap-1"
|
|
74
|
-
style:color={hasTheme
|
|
75
|
-
? 'color-mix(in srgb, var(--theme-foreground) 60%, transparent)'
|
|
76
|
-
: undefined}
|
|
96
|
+
<div class="flex-1">
|
|
97
|
+
{#if title}
|
|
98
|
+
{@render title({ title: value.title, href })}
|
|
99
|
+
{:else}
|
|
100
|
+
<ThemedText
|
|
101
|
+
{hasTheme}
|
|
102
|
+
element="h3"
|
|
103
|
+
class="group-hover:text-primary-600 dark:group-hover:text-primary-400 mb-2 text-2xl font-bold transition-colors"
|
|
77
104
|
>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
class:bg-ink-400={!hasTheme}
|
|
81
|
-
class:dark:bg-ink-600={!hasTheme}
|
|
82
|
-
style:background-color={hasTheme
|
|
83
|
-
? 'color-mix(in srgb, var(--theme-foreground) 40%, transparent)'
|
|
84
|
-
: undefined}
|
|
85
|
-
></span>
|
|
86
|
-
Updated <DateDisplay date={value.updatedAt} />
|
|
87
|
-
</span>
|
|
105
|
+
{value.title}
|
|
106
|
+
</ThemedText>
|
|
88
107
|
{/if}
|
|
89
|
-
</div>
|
|
90
108
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
{#if value.description}
|
|
110
|
+
{#if description}
|
|
111
|
+
{@render description({ description: value.description })}
|
|
112
|
+
{:else}
|
|
113
|
+
<ThemedText
|
|
114
|
+
{hasTheme}
|
|
115
|
+
opacity={70}
|
|
116
|
+
element="p"
|
|
117
|
+
class="mb-4 line-clamp-3 text-sm leading-relaxed"
|
|
118
|
+
>
|
|
119
|
+
{value.description}
|
|
120
|
+
</ThemedText>
|
|
121
|
+
{/if}
|
|
122
|
+
{/if}
|
|
123
|
+
|
|
124
|
+
{#if metadata}
|
|
125
|
+
{@render metadata({ publishedAt: value.publishedAt, updatedAt: value.updatedAt })}
|
|
126
|
+
{:else}
|
|
127
|
+
<div class="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
|
128
|
+
<DateDisplay
|
|
129
|
+
date={value.publishedAt}
|
|
130
|
+
class="font-medium"
|
|
131
|
+
style={hasTheme
|
|
132
|
+
? `color: ${hasTheme ? 'color-mix(in srgb, var(--theme-foreground) 80%, transparent)' : ''}`
|
|
133
|
+
: ''}
|
|
134
|
+
/>
|
|
135
|
+
{#if value.updatedAt}
|
|
136
|
+
<span
|
|
137
|
+
class="flex items-center gap-1"
|
|
138
|
+
style:color={hasTheme
|
|
139
|
+
? 'color-mix(in srgb, var(--theme-foreground) 60%, transparent)'
|
|
140
|
+
: undefined}
|
|
141
|
+
>
|
|
142
|
+
<span
|
|
143
|
+
class="h-1 w-1 rounded-full"
|
|
144
|
+
class:bg-ink-400={!hasTheme}
|
|
145
|
+
class:dark:bg-ink-600={!hasTheme}
|
|
146
|
+
style:background-color={hasTheme
|
|
147
|
+
? 'color-mix(in srgb, var(--theme-foreground) 40%, transparent)'
|
|
148
|
+
: undefined}
|
|
149
|
+
></span>
|
|
150
|
+
Updated <DateDisplay date={value.updatedAt} />
|
|
151
|
+
</span>
|
|
152
|
+
{/if}
|
|
153
|
+
</div>
|
|
154
|
+
{/if}
|
|
155
|
+
|
|
156
|
+
{#if value.tags && value.tags.length > 0}
|
|
157
|
+
{#if tags}
|
|
158
|
+
{@render tags({ tags: value.tags })}
|
|
159
|
+
{:else}
|
|
160
|
+
<TagList tags={value.tags} {hasTheme} />
|
|
161
|
+
{/if}
|
|
162
|
+
{/if}
|
|
163
|
+
</div>
|
|
164
|
+
{/if}
|
|
95
165
|
</ThemedCard>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import RichText from './document/RichText.svelte';
|
|
3
|
+
|
|
4
|
+
export interface Footnote {
|
|
5
|
+
footnoteId: string;
|
|
6
|
+
contentPlaintext: string;
|
|
7
|
+
contentFacets?: any[];
|
|
8
|
+
number: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
footnotes: Footnote[];
|
|
13
|
+
hasTheme?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { footnotes, hasTheme = false }: Props = $props();
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
{#if footnotes.length > 0}
|
|
20
|
+
<section class="footnotes mt-12 pt-6 border-t border-gray-200 dark:border-gray-700">
|
|
21
|
+
<h2 class="text-lg font-semibold mb-4">Footnotes</h2>
|
|
22
|
+
<ol class="footnotes-list space-y-3">
|
|
23
|
+
{#each footnotes as footnote}
|
|
24
|
+
<li id={footnote.footnoteId} class="footnote-item flex gap-3">
|
|
25
|
+
<span class="footnote-number shrink-0 w-6 text-right text-sm text-gray-500 dark:text-gray-400">
|
|
26
|
+
{footnote.number}.
|
|
27
|
+
</span>
|
|
28
|
+
<div class="flex-1 text-sm">
|
|
29
|
+
<RichText plaintext={footnote.contentPlaintext} facets={footnote.contentFacets} {hasTheme} />
|
|
30
|
+
<a
|
|
31
|
+
href="#{footnote.footnoteId}-ref"
|
|
32
|
+
class="footnote-backref ml-1 text-xs"
|
|
33
|
+
class:themed={hasTheme}
|
|
34
|
+
aria-label="Back to reference"
|
|
35
|
+
>
|
|
36
|
+
↩
|
|
37
|
+
</a>
|
|
38
|
+
</div>
|
|
39
|
+
</li>
|
|
40
|
+
{/each}
|
|
41
|
+
</ol>
|
|
42
|
+
</section>
|
|
43
|
+
{/if}
|
|
44
|
+
|
|
45
|
+
<style>
|
|
46
|
+
.footnotes-list {
|
|
47
|
+
list-style: none;
|
|
48
|
+
padding-left: 0;
|
|
49
|
+
counter-reset: footnote;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.footnote-item {
|
|
53
|
+
scroll-margin-top: 2rem;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.footnote-number {
|
|
57
|
+
color: rgb(107 114 128);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.footnote-backref {
|
|
61
|
+
color: rgb(0 0 225);
|
|
62
|
+
text-decoration: none;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.footnote-backref.themed {
|
|
66
|
+
color: var(--theme-accent);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.footnote-backref:hover {
|
|
70
|
+
text-decoration: underline;
|
|
71
|
+
}
|
|
72
|
+
</style>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
/** AT-URI of the document */
|
|
6
|
+
documentUri: string;
|
|
7
|
+
/** Initial recommend count */
|
|
8
|
+
count?: number;
|
|
9
|
+
/** Whether current user has recommended */
|
|
10
|
+
recommended?: boolean;
|
|
11
|
+
/** Has theme applied */
|
|
12
|
+
hasTheme?: boolean;
|
|
13
|
+
/** On recommend callback */
|
|
14
|
+
onRecommend?: () => Promise<void>;
|
|
15
|
+
/** On unrecommend callback */
|
|
16
|
+
onUnrecommend?: () => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
documentUri,
|
|
21
|
+
count = 0,
|
|
22
|
+
recommended = false,
|
|
23
|
+
hasTheme = false,
|
|
24
|
+
onRecommend,
|
|
25
|
+
onUnrecommend
|
|
26
|
+
}: Props = $props();
|
|
27
|
+
|
|
28
|
+
let isRecommended = $state(recommended);
|
|
29
|
+
let recommendCount = $state(count);
|
|
30
|
+
let isLoading = $state(false);
|
|
31
|
+
|
|
32
|
+
async function toggleRecommend() {
|
|
33
|
+
if (isLoading) return;
|
|
34
|
+
isLoading = true;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
if (isRecommended) {
|
|
38
|
+
if (onUnrecommend) {
|
|
39
|
+
await onUnrecommend();
|
|
40
|
+
}
|
|
41
|
+
isRecommended = false;
|
|
42
|
+
recommendCount = Math.max(0, recommendCount - 1);
|
|
43
|
+
} else {
|
|
44
|
+
if (onRecommend) {
|
|
45
|
+
await onRecommend();
|
|
46
|
+
}
|
|
47
|
+
isRecommended = true;
|
|
48
|
+
recommendCount += 1;
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error('Failed to toggle recommend:', error);
|
|
52
|
+
} finally {
|
|
53
|
+
isLoading = false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<button
|
|
59
|
+
class="recommend-button"
|
|
60
|
+
class:recommended={isRecommended}
|
|
61
|
+
class:themed={hasTheme}
|
|
62
|
+
class:loading={isLoading}
|
|
63
|
+
onclick={toggleRecommend}
|
|
64
|
+
disabled={isLoading}
|
|
65
|
+
aria-label={isRecommended ? 'Remove recommendation' : 'Recommend'}
|
|
66
|
+
aria-pressed={isRecommended}
|
|
67
|
+
>
|
|
68
|
+
<span class="icon">
|
|
69
|
+
{#if isRecommended}
|
|
70
|
+
<svg viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
|
71
|
+
<path
|
|
72
|
+
d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z"
|
|
73
|
+
/>
|
|
74
|
+
</svg>
|
|
75
|
+
{:else}
|
|
76
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
|
|
77
|
+
<path
|
|
78
|
+
stroke-linecap="round"
|
|
79
|
+
stroke-linejoin="round"
|
|
80
|
+
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"
|
|
81
|
+
/>
|
|
82
|
+
</svg>
|
|
83
|
+
{/if}
|
|
84
|
+
</span>
|
|
85
|
+
{#if recommendCount > 0}
|
|
86
|
+
<span class="count">{recommendCount}</span>
|
|
87
|
+
{/if}
|
|
88
|
+
</button>
|
|
89
|
+
|
|
90
|
+
<style>
|
|
91
|
+
.recommend-button {
|
|
92
|
+
display: inline-flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
gap: 0.25rem;
|
|
95
|
+
padding: 0.5rem 0.75rem;
|
|
96
|
+
border-radius: 9999px;
|
|
97
|
+
border: 1px solid rgb(229 231 235);
|
|
98
|
+
background-color: transparent;
|
|
99
|
+
color: rgb(107 114 128);
|
|
100
|
+
font-size: 0.875rem;
|
|
101
|
+
cursor: pointer;
|
|
102
|
+
transition: all 0.15s;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.recommend-button:hover:not(:disabled) {
|
|
106
|
+
background-color: rgb(249 250 251);
|
|
107
|
+
border-color: rgb(209 213 219);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.recommend-button.recommended {
|
|
111
|
+
color: rgb(239 68 68);
|
|
112
|
+
border-color: rgb(254 202 202);
|
|
113
|
+
background-color: rgb(254 242 242);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.recommend-button.recommended:hover:not(:disabled) {
|
|
117
|
+
background-color: rgb(254 226 226);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.recommend-button.themed {
|
|
121
|
+
border-color: color-mix(in srgb, var(--theme-foreground) 20%, transparent);
|
|
122
|
+
color: var(--theme-foreground);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.recommend-button.themed:hover:not(:disabled) {
|
|
126
|
+
background-color: color-mix(in srgb, var(--theme-foreground) 5%, transparent);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.recommend-button.themed.recommended {
|
|
130
|
+
color: var(--theme-accent);
|
|
131
|
+
border-color: var(--theme-accent);
|
|
132
|
+
background-color: color-mix(in srgb, var(--theme-accent) 10%, transparent);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.recommend-button.themed.recommended:hover:not(:disabled) {
|
|
136
|
+
background-color: color-mix(in srgb, var(--theme-accent) 20%, transparent);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.recommend-button:disabled {
|
|
140
|
+
opacity: 0.6;
|
|
141
|
+
cursor: not-allowed;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.icon {
|
|
145
|
+
display: flex;
|
|
146
|
+
align-items: center;
|
|
147
|
+
justify-content: center;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.count {
|
|
151
|
+
font-weight: 500;
|
|
152
|
+
}
|
|
153
|
+
</style>
|