@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.
Files changed (69) hide show
  1. package/dist/components/ActionBar.svelte +85 -0
  2. package/dist/components/ActionBar.svelte.d.ts +13 -0
  3. package/dist/components/Avatar.svelte +104 -0
  4. package/dist/components/Avatar.svelte.d.ts +19 -0
  5. package/dist/components/Comment.svelte +172 -0
  6. package/dist/components/Comment.svelte.d.ts +22 -0
  7. package/dist/components/CommentsSection.svelte +89 -0
  8. package/dist/components/DocumentCard.svelte +126 -56
  9. package/dist/components/DocumentCard.svelte.d.ts +51 -0
  10. package/dist/components/Footnotes.svelte +72 -0
  11. package/dist/components/Footnotes.svelte.d.ts +13 -0
  12. package/dist/components/RecommendButton.svelte +153 -0
  13. package/dist/components/RecommendButton.svelte.d.ts +17 -0
  14. package/dist/components/ThemeProvider.svelte +92 -0
  15. package/dist/components/ThemeProvider.svelte.d.ts +13 -0
  16. package/dist/components/Toast.svelte +177 -0
  17. package/dist/components/Toast.svelte.d.ts +32 -0
  18. package/dist/components/Watermark.svelte +100 -0
  19. package/dist/components/Watermark.svelte.d.ts +17 -0
  20. package/dist/components/common/ThemedCard.svelte +15 -15
  21. package/dist/components/common/ThemedCard.svelte.d.ts +5 -0
  22. package/dist/components/document/BlockRenderer.svelte +3 -0
  23. package/dist/components/document/DocumentRenderer.svelte +41 -1
  24. package/dist/components/document/RichText.svelte +87 -2
  25. package/dist/components/document/RichText.svelte.d.ts +2 -0
  26. package/dist/components/document/blocks/OrderedListBlock.svelte +152 -0
  27. package/dist/components/document/blocks/UnorderedListBlock.svelte +1 -1
  28. package/dist/components/index.d.ts +28 -0
  29. package/dist/components/index.js +30 -0
  30. package/dist/index.d.ts +5 -4
  31. package/dist/index.js +6 -4
  32. package/dist/publisher.d.ts +73 -0
  33. package/dist/publisher.js +185 -0
  34. package/dist/schemas.d.ts +1162 -2
  35. package/dist/schemas.js +316 -0
  36. package/dist/types.d.ts +393 -2
  37. package/dist/types.js +1 -1
  38. package/dist/utils/native-comments.d.ts +68 -0
  39. package/dist/utils/native-comments.js +149 -0
  40. package/dist/utils/theme-helpers.d.ts +41 -1
  41. package/dist/utils/theme-helpers.js +98 -1
  42. package/dist/utils/theme.d.ts +48 -1
  43. package/dist/utils/theme.js +158 -0
  44. package/package.json +62 -65
  45. package/src/lib/components/ActionBar.svelte +85 -0
  46. package/src/lib/components/Avatar.svelte +104 -0
  47. package/src/lib/components/Comment.svelte +172 -0
  48. package/src/lib/components/CommentsSection.svelte +89 -0
  49. package/src/lib/components/DocumentCard.svelte +126 -56
  50. package/src/lib/components/Footnotes.svelte +72 -0
  51. package/src/lib/components/RecommendButton.svelte +153 -0
  52. package/src/lib/components/ThemeProvider.svelte +92 -0
  53. package/src/lib/components/Toast.svelte +177 -0
  54. package/src/lib/components/Watermark.svelte +100 -0
  55. package/src/lib/components/common/ThemedCard.svelte +15 -15
  56. package/src/lib/components/document/BlockRenderer.svelte +3 -0
  57. package/src/lib/components/document/DocumentRenderer.svelte +41 -1
  58. package/src/lib/components/document/RichText.svelte +87 -2
  59. package/src/lib/components/document/blocks/OrderedListBlock.svelte +152 -0
  60. package/src/lib/components/document/blocks/UnorderedListBlock.svelte +1 -1
  61. package/src/lib/components/index.ts +32 -0
  62. package/src/lib/index.ts +119 -5
  63. package/src/lib/publisher.ts +251 -0
  64. package/src/lib/schemas.ts +411 -0
  65. package/src/lib/types.ts +506 -2
  66. package/src/lib/utils/native-comments.ts +197 -0
  67. package/src/lib/utils/theme-helpers.ts +136 -3
  68. package/src/lib/utils/theme.ts +189 -1
  69. 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 showCover && value.coverImage}
36
- <img
37
- src={value.coverImage}
38
- alt="{value.title} cover"
39
- class="h-48 w-32 shrink-0 rounded-lg object-cover shadow-sm"
40
- />
41
- {/if}
42
-
43
- <div class="flex-1">
44
- <ThemedText
45
- {hasTheme}
46
- element="h3"
47
- class="group-hover:text-primary-600 dark:group-hover:text-primary-400 mb-2 text-2xl font-bold transition-colors"
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="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
64
- <DateDisplay
65
- date={value.publishedAt}
66
- class="font-medium"
67
- style={hasTheme
68
- ? `color: ${hasTheme ? 'color-mix(in srgb, var(--theme-foreground) 80%, transparent)' : ''}`
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
- <span
79
- class="h-1 w-1 rounded-full"
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
- {#if value.tags && value.tags.length > 0}
92
- <TagList tags={value.tags} {hasTheme} />
93
- {/if}
94
- </div>
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>