nuxt-bluesky-comments 0.1.0
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/README.md +163 -0
- package/dist/module.d.mts +7 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +31 -0
- package/dist/runtime/components/BlueskyComment.d.vue.ts +14 -0
- package/dist/runtime/components/BlueskyComment.vue +388 -0
- package/dist/runtime/components/BlueskyComment.vue.d.ts +14 -0
- package/dist/runtime/components/BlueskyComments.d.vue.ts +21 -0
- package/dist/runtime/components/BlueskyComments.vue +165 -0
- package/dist/runtime/components/BlueskyComments.vue.d.ts +21 -0
- package/dist/runtime/composables/blueskyComments.logic.d.ts +26 -0
- package/dist/runtime/composables/blueskyComments.logic.js +74 -0
- package/dist/runtime/composables/useBlueskyComments.d.ts +6 -0
- package/dist/runtime/composables/useBlueskyComments.js +89 -0
- package/dist/runtime/types.d.ts +104 -0
- package/dist/runtime/types.js +0 -0
- package/dist/types.d.mts +3 -0
- package/package.json +66 -0
package/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# nuxt-bluesky-comments
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/nuxt-bluesky-comments)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
A Nuxt module to display Bluesky post comments on your website. Perfect for adding a comments section to your blog powered by Bluesky discussions.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- π¦ Display comments from any public Bluesky post
|
|
11
|
+
- π¨ Customizable via CSS variables
|
|
12
|
+
- π Dark mode support
|
|
13
|
+
- π§΅ Smart thread flattening for same-author replies
|
|
14
|
+
- π Engagement stats (likes, reposts, replies)
|
|
15
|
+
- βΎοΈ Full thread depth support
|
|
16
|
+
- π± Responsive design
|
|
17
|
+
- πΌοΈ Avatar fallback with author initials
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm add nuxt-bluesky-comments
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
// nuxt.config.ts
|
|
27
|
+
export default defineNuxtConfig({
|
|
28
|
+
modules: ['nuxt-bluesky-comments'],
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```vue
|
|
33
|
+
<BlueskyComments url="https://bsky.app/profile/user.bsky.social/post/abc123" />
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# pnpm
|
|
40
|
+
pnpm add nuxt-bluesky-comments
|
|
41
|
+
|
|
42
|
+
# npm
|
|
43
|
+
npm install nuxt-bluesky-comments
|
|
44
|
+
|
|
45
|
+
# yarn
|
|
46
|
+
yarn add nuxt-bluesky-comments
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Props
|
|
50
|
+
|
|
51
|
+
| Prop | Type | Default | Description |
|
|
52
|
+
| -------------------------- | --------- | ------- | ------------------------------------------------- |
|
|
53
|
+
| `url` | `string` | - | Bluesky web URL of the post |
|
|
54
|
+
| `uri` | `string` | - | AT Protocol URI (alternative to url) |
|
|
55
|
+
| `limit` | `number` | `5` | Top-level comments shown initially |
|
|
56
|
+
| `flattenSameAuthorThreads` | `boolean` | `true` | Keep consecutive same-author replies at one level |
|
|
57
|
+
|
|
58
|
+
## Composable
|
|
59
|
+
|
|
60
|
+
For custom implementations:
|
|
61
|
+
|
|
62
|
+
```vue
|
|
63
|
+
<script setup>
|
|
64
|
+
const { loading, error, comments, stats, postUrl, refresh } = useBlueskyComments(
|
|
65
|
+
'https://bsky.app/profile/user.bsky.social/post/abc123'
|
|
66
|
+
);
|
|
67
|
+
</script>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Returns:** `loading`, `error`, `comments`, `stats`, `postUrl`, `refresh()`
|
|
71
|
+
|
|
72
|
+
## Styling
|
|
73
|
+
|
|
74
|
+
### CSS Variables
|
|
75
|
+
|
|
76
|
+
Text colors inherit from your page automatically. Configure these variables:
|
|
77
|
+
|
|
78
|
+
| Variable | Description | Light | Dark |
|
|
79
|
+
| --------------- | ----------------------------- | --------- | ----------------------- |
|
|
80
|
+
| `--bsky-bg` | Background (for thread lines) | `#ffffff` | `#0a0a0a` |
|
|
81
|
+
| `--bsky-border` | Border/divider color | `#e5e5e5` | `rgba(255,255,255,0.1)` |
|
|
82
|
+
| `--bsky-link` | Link color | `#2563eb` | `#38bdf8` |
|
|
83
|
+
|
|
84
|
+
> **Important:** `--bsky-bg` must match your page background for thread lines to render correctly.
|
|
85
|
+
|
|
86
|
+
### Example
|
|
87
|
+
|
|
88
|
+
```css
|
|
89
|
+
/* Light theme */
|
|
90
|
+
.bluesky-comments-wrapper {
|
|
91
|
+
--bsky-bg: #ffffff;
|
|
92
|
+
--bsky-border: #e5e7eb;
|
|
93
|
+
--bsky-link: #2563eb;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Dark theme */
|
|
97
|
+
.dark .bluesky-comments-wrapper {
|
|
98
|
+
--bsky-bg: #0a0a0a;
|
|
99
|
+
--bsky-border: rgba(255, 255, 255, 0.1);
|
|
100
|
+
--bsky-link: #38bdf8;
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Inline Styles
|
|
105
|
+
|
|
106
|
+
```vue
|
|
107
|
+
<BlueskyComments
|
|
108
|
+
url="https://bsky.app/profile/user.bsky.social/post/abc123"
|
|
109
|
+
:style="{
|
|
110
|
+
'--bsky-bg': '#fafafa',
|
|
111
|
+
'--bsky-border': '#e5e5e5',
|
|
112
|
+
'--bsky-link': '#0284c7',
|
|
113
|
+
}"
|
|
114
|
+
/>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Thread Flattening
|
|
118
|
+
|
|
119
|
+
Consecutive replies from the same author stay at one visual level:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
UserA: "Great post!"
|
|
123
|
+
ββ UserB: "Thanks!"
|
|
124
|
+
ββ UserB: "Also wanted to add..." β Same level, not nested deeper
|
|
125
|
+
ββ UserB: "One more thing..."
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Blog Integration
|
|
129
|
+
|
|
130
|
+
Add `blueskyUrl` to your frontmatter:
|
|
131
|
+
|
|
132
|
+
```markdown
|
|
133
|
+
---
|
|
134
|
+
title: My Blog Post
|
|
135
|
+
blueskyUrl: https://bsky.app/profile/user.bsky.social/post/abc123
|
|
136
|
+
---
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
```vue
|
|
140
|
+
<template>
|
|
141
|
+
<article>
|
|
142
|
+
<ContentRenderer :value="page" />
|
|
143
|
+
<BlueskyComments v-if="page.blueskyUrl" :url="page.blueskyUrl" />
|
|
144
|
+
</article>
|
|
145
|
+
</template>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
pnpm install # Install dependencies
|
|
152
|
+
pnpm dev:prepare # Prepare development
|
|
153
|
+
pnpm dev # Run playground
|
|
154
|
+
pnpm prepack # Build module
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Credits
|
|
158
|
+
|
|
159
|
+
Inspired by Emily Liu's blog post [Using Bluesky posts as blog comments](https://emilyliu.me/blog/comments) β the idea that Bluesky's open network could power comment sections across the web.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT License Β© Patryk Tomczyk
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as _nuxt_schema from '@nuxt/schema';
|
|
2
|
+
import { ModuleOptions } from '../dist/runtime/types.js';
|
|
3
|
+
export { ModuleOptions } from '../dist/runtime/types.js';
|
|
4
|
+
|
|
5
|
+
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
6
|
+
|
|
7
|
+
export { _default as default };
|
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { defineNuxtModule, createResolver, addComponentsDir, addImports } from '@nuxt/kit';
|
|
2
|
+
|
|
3
|
+
const module$1 = defineNuxtModule({
|
|
4
|
+
meta: {
|
|
5
|
+
name: "nuxt-bluesky-comments",
|
|
6
|
+
configKey: "blueskyComments",
|
|
7
|
+
compatibility: {
|
|
8
|
+
nuxt: ">=3.0.0"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
defaults: {
|
|
12
|
+
apiService: "https://public.api.bsky.app"
|
|
13
|
+
},
|
|
14
|
+
async setup(_options, nuxt) {
|
|
15
|
+
const resolver = createResolver(import.meta.url);
|
|
16
|
+
addComponentsDir({
|
|
17
|
+
path: resolver.resolve("./runtime/components"),
|
|
18
|
+
pathPrefix: false,
|
|
19
|
+
prefix: "",
|
|
20
|
+
global: true
|
|
21
|
+
});
|
|
22
|
+
addImports({
|
|
23
|
+
name: "useBlueskyComments",
|
|
24
|
+
as: "useBlueskyComments",
|
|
25
|
+
from: resolver.resolve("./runtime/composables/useBlueskyComments")
|
|
26
|
+
});
|
|
27
|
+
nuxt.options.build.transpile.push("@atproto/api");
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export { module$1 as default };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FlattenedComment } from "../types.js";
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
comment: FlattenedComment;
|
|
4
|
+
depth?: number;
|
|
5
|
+
parentAuthorDid?: string;
|
|
6
|
+
/**
|
|
7
|
+
* True when this comment is the last *indented* sibling at its depth.
|
|
8
|
+
* Used to visually terminate the parent thread line (so it doesn't look like it continues).
|
|
9
|
+
*/
|
|
10
|
+
isLastSibling?: boolean;
|
|
11
|
+
};
|
|
12
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
13
|
+
declare const _default: typeof __VLS_export;
|
|
14
|
+
export default _default;
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, provide, inject } from "vue";
|
|
3
|
+
const props = defineProps({
|
|
4
|
+
comment: { type: Object, required: true },
|
|
5
|
+
depth: { type: Number, required: false, default: 0 },
|
|
6
|
+
parentAuthorDid: { type: String, required: false },
|
|
7
|
+
isLastSibling: { type: Boolean, required: false, default: false }
|
|
8
|
+
});
|
|
9
|
+
const collapsed = ref(false);
|
|
10
|
+
const avatarError = ref(false);
|
|
11
|
+
function handleAvatarError() {
|
|
12
|
+
avatarError.value = true;
|
|
13
|
+
}
|
|
14
|
+
const avatarLetter = computed(() => {
|
|
15
|
+
const handle = props.comment.author.handle || props.comment.author.displayName || "?";
|
|
16
|
+
return handle.charAt(0).toUpperCase();
|
|
17
|
+
});
|
|
18
|
+
const isSameAuthor = computed(
|
|
19
|
+
() => props.parentAuthorDid === props.comment.author.did
|
|
20
|
+
);
|
|
21
|
+
const shouldIndent = computed(() => props.depth > 0 && !isSameAuthor.value);
|
|
22
|
+
const HIDE_GLOBE_FOR_HANDLE_SUFFIXES = [".bsky.social"];
|
|
23
|
+
const shouldShowHandleGlobe = computed(() => {
|
|
24
|
+
const handle = props.comment.author.handle?.toLowerCase() ?? "";
|
|
25
|
+
return !HIDE_GLOBE_FOR_HANDLE_SUFFIXES.some(
|
|
26
|
+
(suffix) => handle.endsWith(suffix)
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
const hasReplies = computed(
|
|
30
|
+
() => props.comment.replies && props.comment.replies.length > 0
|
|
31
|
+
);
|
|
32
|
+
const isNested = computed(() => props.depth > 0 && shouldIndent.value);
|
|
33
|
+
const replyItems = computed(() => {
|
|
34
|
+
const replies = props.comment.replies || [];
|
|
35
|
+
let lastIndentedIndex = -1;
|
|
36
|
+
for (let i = replies.length - 1; i >= 0; i -= 1) {
|
|
37
|
+
const reply = replies[i];
|
|
38
|
+
if (reply && reply.author.did !== props.comment.author.did) {
|
|
39
|
+
lastIndentedIndex = i;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return replies.map((reply, index) => ({
|
|
44
|
+
reply,
|
|
45
|
+
isLastIndentedSibling: index === lastIndentedIndex && reply.author.did !== props.comment.author.did
|
|
46
|
+
}));
|
|
47
|
+
});
|
|
48
|
+
const isMainBranchHovered = ref(false);
|
|
49
|
+
const parentHoverState = inject(
|
|
50
|
+
"parentThreadState",
|
|
51
|
+
null
|
|
52
|
+
);
|
|
53
|
+
if (hasReplies.value) {
|
|
54
|
+
provide("parentThreadState", {
|
|
55
|
+
isHovered: isMainBranchHovered,
|
|
56
|
+
toggleCollapse
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const isConnectedHovered = computed(() => {
|
|
60
|
+
if (isNested.value && parentHoverState) {
|
|
61
|
+
return parentHoverState.isHovered.value;
|
|
62
|
+
}
|
|
63
|
+
return isMainBranchHovered.value;
|
|
64
|
+
});
|
|
65
|
+
function countAllReplies(comment) {
|
|
66
|
+
let count = comment.replies?.length || 0;
|
|
67
|
+
for (const reply of comment.replies || []) {
|
|
68
|
+
count += countAllReplies(reply);
|
|
69
|
+
}
|
|
70
|
+
return count;
|
|
71
|
+
}
|
|
72
|
+
const totalNestedReplies = computed(() => countAllReplies(props.comment));
|
|
73
|
+
function toggleCollapse() {
|
|
74
|
+
collapsed.value = !collapsed.value;
|
|
75
|
+
}
|
|
76
|
+
function handleConnectionClick() {
|
|
77
|
+
if (parentHoverState) {
|
|
78
|
+
parentHoverState.toggleCollapse();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const shouldCutParentThreadLine = computed(() => {
|
|
82
|
+
return isNested.value && props.isLastSibling;
|
|
83
|
+
});
|
|
84
|
+
function formatRelativeTime(dateString) {
|
|
85
|
+
const date = new Date(dateString);
|
|
86
|
+
const now = /* @__PURE__ */ new Date();
|
|
87
|
+
const diffMs = now.getTime() - date.getTime();
|
|
88
|
+
const diffSecs = Math.floor(diffMs / 1e3);
|
|
89
|
+
const diffMins = Math.floor(diffSecs / 60);
|
|
90
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
91
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
92
|
+
const diffWeeks = Math.floor(diffDays / 7);
|
|
93
|
+
const diffMonths = Math.floor(diffDays / 30);
|
|
94
|
+
const diffYears = Math.floor(diffDays / 365);
|
|
95
|
+
if (diffYears > 0) return `${diffYears}y`;
|
|
96
|
+
if (diffMonths > 0) return `${diffMonths}mo`;
|
|
97
|
+
if (diffWeeks > 0) return `${diffWeeks}w`;
|
|
98
|
+
if (diffDays > 0) return `${diffDays}d`;
|
|
99
|
+
if (diffHours > 0) return `${diffHours}h`;
|
|
100
|
+
if (diffMins > 0) return `${diffMins}m`;
|
|
101
|
+
return "now";
|
|
102
|
+
}
|
|
103
|
+
function getCommentUrl(comment) {
|
|
104
|
+
const match = comment.uri.match(
|
|
105
|
+
/at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/?#]+)/
|
|
106
|
+
);
|
|
107
|
+
if (!match) return "";
|
|
108
|
+
const [, did, rkey] = match;
|
|
109
|
+
const identifier = comment.author.handle || did;
|
|
110
|
+
return `https://bsky.app/profile/${identifier}/post/${rkey}`;
|
|
111
|
+
}
|
|
112
|
+
const commentUrl = getCommentUrl(props.comment);
|
|
113
|
+
const relativeTime = formatRelativeTime(props.comment.createdAt);
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<template>
|
|
117
|
+
<div class="bsky-comment" :class="{ 'nested': isNested }">
|
|
118
|
+
<!-- Connection line from nested comment to main branch - overlays without causing indentation -->
|
|
119
|
+
<div
|
|
120
|
+
v-if="isNested"
|
|
121
|
+
class="connection-line-wrapper"
|
|
122
|
+
@click="handleConnectionClick"
|
|
123
|
+
@mouseenter="() => {
|
|
124
|
+
if (parentHoverState) parentHoverState.isHovered.value = true;
|
|
125
|
+
}"
|
|
126
|
+
@mouseleave="() => {
|
|
127
|
+
if (parentHoverState) parentHoverState.isHovered.value = false;
|
|
128
|
+
}"
|
|
129
|
+
>
|
|
130
|
+
<svg
|
|
131
|
+
class="connection-svg"
|
|
132
|
+
:class="{ 'hovered': isConnectedHovered }"
|
|
133
|
+
width="100%"
|
|
134
|
+
height="100%"
|
|
135
|
+
viewBox="0 0 29 32"
|
|
136
|
+
preserveAspectRatio="none"
|
|
137
|
+
>
|
|
138
|
+
<!-- Rounded "L" connector (vertical + horizontal) with avatar-like corner radius -->
|
|
139
|
+
<!-- Kept within avatar height (32px) so it never goes above the avatar -->
|
|
140
|
+
<!-- Use a true quarter-circle arc for the elbow (radius = 16px) -->
|
|
141
|
+
<!-- SVG width is tuned so the horizontal segment ends exactly at the avatar's left edge -->
|
|
142
|
+
<!-- Inset by 1px to avoid stroke clipping (keeps thickness consistent with 2px rails) -->
|
|
143
|
+
<path
|
|
144
|
+
d="M 1 1 V 15 A 16 16 0 0 0 17 31"
|
|
145
|
+
fill="none"
|
|
146
|
+
:stroke="isConnectedHovered ? '#2563eb' : '#d1d5db'"
|
|
147
|
+
stroke-width="2"
|
|
148
|
+
stroke-linecap="butt"
|
|
149
|
+
stroke-linejoin="round"
|
|
150
|
+
/>
|
|
151
|
+
</svg>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<!-- Mask to terminate the parent thread line for the last sibling at this depth -->
|
|
155
|
+
<div v-if="shouldCutParentThreadLine" class="thread-line-cut" aria-hidden="true" />
|
|
156
|
+
|
|
157
|
+
<div class="comment-row">
|
|
158
|
+
<!-- Grid column 1: avatar -->
|
|
159
|
+
<div class="avatar-cell">
|
|
160
|
+
<a
|
|
161
|
+
:href="`https://bsky.app/profile/${comment.author.handle}`"
|
|
162
|
+
target="_blank"
|
|
163
|
+
rel="noopener noreferrer"
|
|
164
|
+
class="avatar-link"
|
|
165
|
+
>
|
|
166
|
+
<img
|
|
167
|
+
v-if="comment.author.avatar && !avatarError"
|
|
168
|
+
:src="comment.author.avatar"
|
|
169
|
+
:alt="`${comment.author.displayName || comment.author.handle}'s avatar`"
|
|
170
|
+
class="avatar-img"
|
|
171
|
+
@error="handleAvatarError"
|
|
172
|
+
/>
|
|
173
|
+
<div v-else class="avatar-placeholder">
|
|
174
|
+
<span class="avatar-letter">{{ avatarLetter }}</span>
|
|
175
|
+
</div>
|
|
176
|
+
</a>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<!-- Grid column 1: rail spans below avatar -->
|
|
180
|
+
<div v-if="hasReplies" class="thread-line-wrapper" aria-hidden="true">
|
|
181
|
+
<div
|
|
182
|
+
v-if="!collapsed"
|
|
183
|
+
class="thread-line"
|
|
184
|
+
:class="{ 'hovered': isMainBranchHovered }"
|
|
185
|
+
@click="toggleCollapse"
|
|
186
|
+
@mouseenter="isMainBranchHovered = true"
|
|
187
|
+
@mouseleave="isMainBranchHovered = false"
|
|
188
|
+
/>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<!-- Grid column 1: toggle button in the SAME grid row as stats-row -->
|
|
192
|
+
<button
|
|
193
|
+
v-if="hasReplies"
|
|
194
|
+
class="thread-toggle-btn"
|
|
195
|
+
:class="{ 'collapsed': collapsed }"
|
|
196
|
+
:title="collapsed ? 'Expand replies' : 'Collapse replies'"
|
|
197
|
+
@click="toggleCollapse"
|
|
198
|
+
>
|
|
199
|
+
<svg
|
|
200
|
+
v-if="collapsed"
|
|
201
|
+
class="toggle-icon"
|
|
202
|
+
viewBox="0 0 24 24"
|
|
203
|
+
fill="none"
|
|
204
|
+
stroke="currentColor"
|
|
205
|
+
stroke-width="3"
|
|
206
|
+
>
|
|
207
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
|
208
|
+
</svg>
|
|
209
|
+
<svg
|
|
210
|
+
v-else
|
|
211
|
+
class="toggle-icon"
|
|
212
|
+
viewBox="0 0 24 24"
|
|
213
|
+
fill="none"
|
|
214
|
+
stroke="currentColor"
|
|
215
|
+
stroke-width="3"
|
|
216
|
+
>
|
|
217
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" />
|
|
218
|
+
</svg>
|
|
219
|
+
</button>
|
|
220
|
+
|
|
221
|
+
<!-- Grid column 2: author -->
|
|
222
|
+
<div class="author-row">
|
|
223
|
+
<div class="author-meta">
|
|
224
|
+
<a
|
|
225
|
+
:href="`https://bsky.app/profile/${comment.author.handle}`"
|
|
226
|
+
target="_blank"
|
|
227
|
+
rel="noopener noreferrer"
|
|
228
|
+
class="author-name"
|
|
229
|
+
>
|
|
230
|
+
{{ comment.author.displayName || comment.author.handle }}
|
|
231
|
+
</a>
|
|
232
|
+
<div class="author-handle-row">
|
|
233
|
+
<a
|
|
234
|
+
:href="`https://bsky.app/profile/${comment.author.handle}`"
|
|
235
|
+
target="_blank"
|
|
236
|
+
rel="noopener noreferrer"
|
|
237
|
+
class="author-handle"
|
|
238
|
+
>
|
|
239
|
+
@{{ comment.author.handle }}
|
|
240
|
+
</a>
|
|
241
|
+
<a
|
|
242
|
+
v-if="shouldShowHandleGlobe"
|
|
243
|
+
class="author-handle-site"
|
|
244
|
+
:href="`https://${comment.author.handle}`"
|
|
245
|
+
target="_blank"
|
|
246
|
+
rel="noopener noreferrer"
|
|
247
|
+
:aria-label="`Open ${comment.author.handle} website`"
|
|
248
|
+
:title="`Open website of ${comment.author.handle}`"
|
|
249
|
+
>
|
|
250
|
+
<svg
|
|
251
|
+
class="globe-icon"
|
|
252
|
+
viewBox="0 0 24 24"
|
|
253
|
+
fill="none"
|
|
254
|
+
stroke="currentColor"
|
|
255
|
+
stroke-width="1.8"
|
|
256
|
+
>
|
|
257
|
+
<path
|
|
258
|
+
stroke-linecap="round"
|
|
259
|
+
stroke-linejoin="round"
|
|
260
|
+
d="M12 21c4.97 0 9-4.03 9-9s-4.03-9-9-9-9 4.03-9 9 4.03 9 9 9z"
|
|
261
|
+
/>
|
|
262
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M3.6 9h16.8" />
|
|
263
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M3.6 15h16.8" />
|
|
264
|
+
<path
|
|
265
|
+
stroke-linecap="round"
|
|
266
|
+
stroke-linejoin="round"
|
|
267
|
+
d="M12 3c2.2 2.7 3.5 5.7 3.5 9s-1.3 6.3-3.5 9c-2.2-2.7-3.5-5.7-3.5-9S9.8 5.7 12 3z"
|
|
268
|
+
/>
|
|
269
|
+
</svg>
|
|
270
|
+
</a>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
<a
|
|
274
|
+
:href="commentUrl"
|
|
275
|
+
target="_blank"
|
|
276
|
+
rel="noopener noreferrer"
|
|
277
|
+
class="timestamp"
|
|
278
|
+
:title="new Date(comment.createdAt).toLocaleString()"
|
|
279
|
+
>
|
|
280
|
+
{{ relativeTime }}
|
|
281
|
+
</a>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<!-- Grid column 2: text -->
|
|
285
|
+
<div class="comment-text">
|
|
286
|
+
{{ comment.text }}
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
<!-- Grid column 2: stats (link to this comment on Bluesky) -->
|
|
290
|
+
<a
|
|
291
|
+
class="stats-row stats-row-link"
|
|
292
|
+
:href="commentUrl"
|
|
293
|
+
target="_blank"
|
|
294
|
+
rel="noopener noreferrer"
|
|
295
|
+
aria-label="Open this comment on Bluesky to reply"
|
|
296
|
+
title="Open this comment on Bluesky to reply"
|
|
297
|
+
>
|
|
298
|
+
<span class="stat">
|
|
299
|
+
<svg
|
|
300
|
+
class="stat-icon"
|
|
301
|
+
viewBox="0 0 24 24"
|
|
302
|
+
fill="none"
|
|
303
|
+
stroke="currentColor"
|
|
304
|
+
stroke-width="2"
|
|
305
|
+
>
|
|
306
|
+
<path
|
|
307
|
+
d="M12 21l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.18L12 21z"
|
|
308
|
+
stroke-linecap="round"
|
|
309
|
+
stroke-linejoin="round"
|
|
310
|
+
/>
|
|
311
|
+
</svg>
|
|
312
|
+
{{ comment.likeCount }}
|
|
313
|
+
</span>
|
|
314
|
+
<span class="stat">
|
|
315
|
+
<svg
|
|
316
|
+
class="stat-icon"
|
|
317
|
+
viewBox="0 0 24 24"
|
|
318
|
+
fill="none"
|
|
319
|
+
stroke="currentColor"
|
|
320
|
+
stroke-width="2"
|
|
321
|
+
>
|
|
322
|
+
<path
|
|
323
|
+
stroke-linecap="round"
|
|
324
|
+
stroke-linejoin="round"
|
|
325
|
+
d="M17 1l4 4-4 4M21 5H10a4 4 0 00-4 4v1"
|
|
326
|
+
/>
|
|
327
|
+
<path
|
|
328
|
+
stroke-linecap="round"
|
|
329
|
+
stroke-linejoin="round"
|
|
330
|
+
d="M7 23l-4-4 4-4M3 19h11a4 4 0 004-4v-1"
|
|
331
|
+
/>
|
|
332
|
+
</svg>
|
|
333
|
+
{{ comment.repostCount }}
|
|
334
|
+
</span>
|
|
335
|
+
<span class="stat">
|
|
336
|
+
<svg
|
|
337
|
+
class="stat-icon"
|
|
338
|
+
viewBox="0 0 24 24"
|
|
339
|
+
fill="none"
|
|
340
|
+
stroke="currentColor"
|
|
341
|
+
stroke-width="2"
|
|
342
|
+
>
|
|
343
|
+
<path
|
|
344
|
+
d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2v10z"
|
|
345
|
+
stroke-linecap="round"
|
|
346
|
+
stroke-linejoin="round"
|
|
347
|
+
/>
|
|
348
|
+
</svg>
|
|
349
|
+
{{ comment.replyCount }}
|
|
350
|
+
</span>
|
|
351
|
+
<svg
|
|
352
|
+
class="stats-link-indicator"
|
|
353
|
+
viewBox="0 0 24 24"
|
|
354
|
+
fill="none"
|
|
355
|
+
stroke="currentColor"
|
|
356
|
+
stroke-width="2"
|
|
357
|
+
aria-hidden="true"
|
|
358
|
+
>
|
|
359
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M7 17L17 7" />
|
|
360
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M10 7h7v7" />
|
|
361
|
+
</svg>
|
|
362
|
+
</a>
|
|
363
|
+
|
|
364
|
+
<!-- Grid column 2: collapsed indicator -->
|
|
365
|
+
<div v-if="collapsed && hasReplies" class="collapsed-info">
|
|
366
|
+
<button class="collapsed-link" @click="toggleCollapse">
|
|
367
|
+
{{ totalNestedReplies }} more {{ totalNestedReplies === 1 ? "reply" : "replies" }}
|
|
368
|
+
</button>
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
<!-- Grid column 2: replies -->
|
|
372
|
+
<div v-if="!collapsed && hasReplies" class="replies-list">
|
|
373
|
+
<BlueskyComment
|
|
374
|
+
v-for="item in replyItems"
|
|
375
|
+
:key="item.reply.id"
|
|
376
|
+
:comment="item.reply"
|
|
377
|
+
:depth="depth + 1"
|
|
378
|
+
:parent-author-did="comment.author.did"
|
|
379
|
+
:is-last-sibling="item.isLastIndentedSibling"
|
|
380
|
+
/>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
</template>
|
|
385
|
+
|
|
386
|
+
<style scoped>
|
|
387
|
+
.bsky-comment{padding-top:8px;position:relative;--bsky-avatar-size:32px;--bsky-avatar-center:16px;--bsky-connector-top:0px}.bsky-comment.nested{padding-top:12px}.bsky-comment.nested .comment-row{margin-left:-9px}.comment-row{align-items:start;-moz-column-gap:10px;column-gap:10px;display:grid;grid-template-columns:32px 1fr;grid-template-rows:auto auto auto auto auto}.avatar-cell{display:flex;grid-column:1;grid-row:1;justify-content:center}.author-row{grid-column:2;grid-row:1}.comment-text{grid-column:2;grid-row:2}.stats-row{grid-column:2;grid-row:3}.collapsed-info{grid-column:2;grid-row:4}.replies-list{grid-column:2;grid-row:5}.avatar-link{color:inherit;display:block;flex-shrink:0;text-decoration:none}.avatar-img{-o-object-fit:cover;object-fit:cover}.avatar-img,.avatar-placeholder{background:var(--bsky-border,#e5e5e5);border-radius:50%;height:32px;width:32px}.avatar-placeholder{align-items:center;display:flex;justify-content:center}.avatar-letter{color:inherit;font-size:14px;font-weight:500;line-height:1;text-transform:uppercase}.thread-line-wrapper{align-self:stretch;display:flex;grid-column:1;grid-row:2/6;justify-content:center;margin-top:4px}.thread-line{background:#d1d5db;border-radius:1px;cursor:pointer;height:100%;min-height:20px;transition:background-color .15s;width:2px}.thread-line.hovered,.thread-line:hover{background:#2563eb}.connection-line-wrapper{cursor:pointer;height:var(--bsky-avatar-size);left:-27px;overflow:visible;pointer-events:all;position:absolute;top:var(--bsky-connector-top);width:29px;z-index:1}.thread-line-cut{background:var(--bsky-bg,#fff);border-radius:999px;bottom:0;left:-25px;pointer-events:none;position:absolute;top:calc(var(--bsky-connector-top) + var(--bsky-avatar-center));transform:translateX(-50%);width:14px;z-index:0}.connection-svg{display:block;height:100%;overflow:visible;pointer-events:all;transition:opacity .15s;width:100%}.connection-svg path{transition:stroke .15s}.thread-toggle-btn{align-items:center;align-self:center;background:transparent;border:1px solid var(--bsky-toggle-border,rgba(17,24,39,.18));border-radius:50%;box-shadow:0 0 0 2px var(--bsky-bg,#fff),0 2px 8px rgba(0,0,0,.18);color:var(--bsky-toggle-icon,#111827);cursor:pointer;display:flex;grid-column:1;grid-row:3;height:22px;isolation:isolate;justify-content:center;justify-self:center;position:relative;transition:all .15s;width:22px;z-index:2}.thread-toggle-btn:before{background:var(--bsky-bg,#fff);z-index:-2}.thread-toggle-btn:after,.thread-toggle-btn:before{border-radius:inherit;content:"";inset:0;pointer-events:none;position:absolute}.thread-toggle-btn:after{background:var(--bsky-toggle-surface,rgba(229,231,235,.85));z-index:-1}.thread-toggle-btn:hover{--bsky-toggle-surface:rgba(37,99,235,.95);--bsky-toggle-border:hsla(0,0%,100%,.85);--bsky-toggle-icon:#fff}.thread-toggle-btn.collapsed{--bsky-toggle-surface:rgba(219,234,254,.75);--bsky-toggle-border:rgba(37,99,235,.28);--bsky-toggle-icon:#2563eb}.thread-toggle-btn.collapsed:hover{--bsky-toggle-surface:rgba(37,99,235,.95);--bsky-toggle-border:hsla(0,0%,100%,.85);--bsky-toggle-icon:#fff}.toggle-icon{height:14px;width:14px}.content-column{flex:1;min-width:0}.author-row{align-items:flex-start;display:flex;font-size:14px;gap:10px;justify-content:space-between;line-height:20px}.author-meta{display:flex;flex-direction:column;min-width:0}.author-name{color:inherit;font-weight:500}.author-name:hover{text-decoration:underline}.author-handle{color:inherit;font-size:12px;line-height:14px;opacity:.6;text-decoration:none}.author-handle-row{align-items:center;display:flex;gap:6px}.author-handle-site{align-items:center;color:inherit;display:inline-flex;justify-content:center;opacity:.5;text-decoration:none}.author-handle-site:hover{color:var(--bsky-link,#2563eb)}.globe-icon{height:12px;width:12px}.author-handle:hover{color:var(--bsky-link,#2563eb);text-decoration:underline}.timestamp{color:inherit;opacity:.6}.timestamp:hover{color:var(--bsky-link,#2563eb);text-decoration:underline}.comment-text{color:inherit;font-size:14px;line-height:20px;margin-top:4px;opacity:.85;white-space:pre-wrap;word-break:break-word}.stats-row{align-items:center;color:inherit;display:flex;font-size:12px;gap:12px;margin-top:6px;min-height:18px;opacity:.6}.stats-row-link{text-decoration:none}.stats-row-link:hover{color:var(--bsky-link,#2563eb)}.stats-link-indicator{height:14px;margin-left:2px;opacity:.65;width:14px}.stats-row-link:hover .stats-link-indicator{opacity:1}.stat{align-items:center;display:flex;gap:4px}.stat-icon{height:14px;width:14px}.collapsed-info{margin-top:4px}.collapsed-link{background:none;border:none;color:var(--bsky-link,#2563eb);cursor:pointer;font-size:12px;padding:0}.collapsed-link:hover{text-decoration:underline}.replies-list{margin-top:0}.dark .thread-toggle-btn,.dark-theme .thread-toggle-btn,:root.dark .thread-toggle-btn{--bsky-toggle-surface:rgba(55,65,81,.82);--bsky-toggle-border:hsla(0,0%,100%,.14);--bsky-toggle-icon:#e5e7eb}.dark .thread-toggle-btn:hover,.dark-theme .thread-toggle-btn:hover,:root.dark .thread-toggle-btn:hover{--bsky-toggle-surface:rgba(59,130,246,.95);--bsky-toggle-border:hsla(0,0%,100%,.85);--bsky-toggle-icon:#fff}.dark .thread-toggle-btn.collapsed,.dark-theme .thread-toggle-btn.collapsed,:root.dark .thread-toggle-btn.collapsed{--bsky-toggle-surface:rgba(30,58,95,.75);--bsky-toggle-border:rgba(96,165,250,.35);--bsky-toggle-icon:#60a5fa}.dark .thread-line,.dark-theme .thread-line,:root.dark .thread-line{background:#4b5563}.dark .thread-line.hovered,.dark .thread-line:hover,.dark-theme .thread-line.hovered,.dark-theme .thread-line:hover,:root.dark .thread-line.hovered,:root.dark .thread-line:hover{background:#3b82f6}.dark .connection-line,.dark-theme .connection-line,:root.dark .connection-line{background:#4b5563}.dark .connection-line.hovered,.dark .connection-line:hover,.dark-theme .connection-line.hovered,.dark-theme .connection-line:hover,:root.dark .connection-line.hovered,:root.dark .connection-line:hover{background:#3b82f6}.dark .connection-svg path,.dark-theme .connection-svg path,:root.dark .connection-svg path{stroke:#4b5563}.dark .connection-svg.hovered path,.dark-theme .connection-svg.hovered path,:root.dark .connection-svg.hovered path{stroke:#3b82f6}
|
|
388
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FlattenedComment } from "../types.js";
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
comment: FlattenedComment;
|
|
4
|
+
depth?: number;
|
|
5
|
+
parentAuthorDid?: string;
|
|
6
|
+
/**
|
|
7
|
+
* True when this comment is the last *indented* sibling at its depth.
|
|
8
|
+
* Used to visually terminate the parent thread line (so it doesn't look like it continues).
|
|
9
|
+
*/
|
|
10
|
+
isLastSibling?: boolean;
|
|
11
|
+
};
|
|
12
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
13
|
+
declare const _default: typeof __VLS_export;
|
|
14
|
+
export default _default;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
/**
|
|
3
|
+
* AT Protocol URI of the post
|
|
4
|
+
*/
|
|
5
|
+
uri?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Bluesky web URL of the post
|
|
8
|
+
*/
|
|
9
|
+
url?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Maximum number of top-level comments to show initially
|
|
12
|
+
*/
|
|
13
|
+
limit?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Render same-author continuation replies at the same visual level (no nested wrapping).
|
|
16
|
+
*/
|
|
17
|
+
flattenSameAuthorThreads?: boolean;
|
|
18
|
+
};
|
|
19
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
20
|
+
declare const _default: typeof __VLS_export;
|
|
21
|
+
export default _default;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed } from "vue";
|
|
3
|
+
import { useBlueskyComments } from "../composables/useBlueskyComments";
|
|
4
|
+
import BlueskyComment from "./BlueskyComment.vue";
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
uri: { type: String, required: false },
|
|
7
|
+
url: { type: String, required: false },
|
|
8
|
+
limit: { type: Number, required: false, default: 5 },
|
|
9
|
+
flattenSameAuthorThreads: { type: Boolean, required: false, default: true }
|
|
10
|
+
});
|
|
11
|
+
const postIdentifier = computed(() => {
|
|
12
|
+
if (props.uri) return props.uri;
|
|
13
|
+
if (props.url) return props.url;
|
|
14
|
+
return "";
|
|
15
|
+
});
|
|
16
|
+
const hasIdentifier = computed(() => !!postIdentifier.value);
|
|
17
|
+
const { loading, error, comments, stats, postUrl, refresh } = hasIdentifier.value ? useBlueskyComments(postIdentifier.value, {
|
|
18
|
+
flattenSameAuthorThreads: props.flattenSameAuthorThreads
|
|
19
|
+
}) : {
|
|
20
|
+
loading: ref(false),
|
|
21
|
+
error: ref("No Bluesky post URL or URI provided"),
|
|
22
|
+
comments: ref([]),
|
|
23
|
+
stats: ref({ likeCount: 0, repostCount: 0, replyCount: 0, quoteCount: 0 }),
|
|
24
|
+
postUrl: ref(""),
|
|
25
|
+
refresh: async () => {
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const showAll = ref(false);
|
|
29
|
+
const visibleComments = computed(() => {
|
|
30
|
+
if (showAll.value) return comments.value;
|
|
31
|
+
return comments.value.slice(0, props.limit);
|
|
32
|
+
});
|
|
33
|
+
const hasMoreComments = computed(() => {
|
|
34
|
+
return comments.value.length > props.limit && !showAll.value;
|
|
35
|
+
});
|
|
36
|
+
const remainingCount = computed(() => {
|
|
37
|
+
return comments.value.length - props.limit;
|
|
38
|
+
});
|
|
39
|
+
function showMoreComments() {
|
|
40
|
+
showAll.value = true;
|
|
41
|
+
}
|
|
42
|
+
function formatCount(count) {
|
|
43
|
+
if (count >= 1e6) {
|
|
44
|
+
return `${(count / 1e6).toFixed(1)}M`;
|
|
45
|
+
}
|
|
46
|
+
if (count >= 1e3) {
|
|
47
|
+
return `${(count / 1e3).toFixed(1)}K`;
|
|
48
|
+
}
|
|
49
|
+
return count.toString();
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<div
|
|
55
|
+
class="bsky-comments"
|
|
56
|
+
style="margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--bsky-border, #e5e5e5);"
|
|
57
|
+
>
|
|
58
|
+
<!-- Stats bar -->
|
|
59
|
+
<a
|
|
60
|
+
v-if="postUrl && stats.likeCount + stats.repostCount + stats.replyCount > 0"
|
|
61
|
+
:href="postUrl"
|
|
62
|
+
target="_blank"
|
|
63
|
+
rel="noopener noreferrer"
|
|
64
|
+
class="bsky-stats-bar"
|
|
65
|
+
>
|
|
66
|
+
<span v-if="stats.likeCount > 0" class="bsky-stat">
|
|
67
|
+
<svg class="bsky-stat-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
68
|
+
<path
|
|
69
|
+
stroke-linecap="round"
|
|
70
|
+
stroke-linejoin="round"
|
|
71
|
+
stroke-width="2"
|
|
72
|
+
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
|
73
|
+
/>
|
|
74
|
+
</svg>
|
|
75
|
+
{{ formatCount(stats.likeCount) }} {{ stats.likeCount === 1 ? "like" : "likes" }}
|
|
76
|
+
</span>
|
|
77
|
+
<span v-if="stats.repostCount > 0" class="bsky-stat">
|
|
78
|
+
<svg class="bsky-stat-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
79
|
+
<path
|
|
80
|
+
stroke-linecap="round"
|
|
81
|
+
stroke-linejoin="round"
|
|
82
|
+
stroke-width="2"
|
|
83
|
+
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
84
|
+
/>
|
|
85
|
+
</svg>
|
|
86
|
+
{{ formatCount(stats.repostCount) }} {{ stats.repostCount === 1 ? "repost" : "reposts" }}
|
|
87
|
+
</span>
|
|
88
|
+
<span v-if="stats.replyCount > 0" class="bsky-stat">
|
|
89
|
+
<svg class="bsky-stat-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
90
|
+
<path
|
|
91
|
+
stroke-linecap="round"
|
|
92
|
+
stroke-linejoin="round"
|
|
93
|
+
stroke-width="2"
|
|
94
|
+
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
|
95
|
+
/>
|
|
96
|
+
</svg>
|
|
97
|
+
{{ formatCount(stats.replyCount) }} {{ stats.replyCount === 1 ? "reply" : "replies" }}
|
|
98
|
+
</span>
|
|
99
|
+
</a>
|
|
100
|
+
|
|
101
|
+
<!-- Section header -->
|
|
102
|
+
<h2 class="bsky-heading">Comments</h2>
|
|
103
|
+
|
|
104
|
+
<!-- CTA to comment -->
|
|
105
|
+
<p class="bsky-cta">
|
|
106
|
+
Reply on Bluesky
|
|
107
|
+
<a v-if="postUrl" :href="postUrl" target="_blank" rel="noopener noreferrer" class="bsky-link"
|
|
108
|
+
>here</a
|
|
109
|
+
>
|
|
110
|
+
to join the conversation.
|
|
111
|
+
</p>
|
|
112
|
+
|
|
113
|
+
<!-- Loading state -->
|
|
114
|
+
<div v-if="loading" class="bsky-loading">
|
|
115
|
+
<svg class="bsky-spinner" fill="none" viewBox="0 0 24 24">
|
|
116
|
+
<circle
|
|
117
|
+
style="opacity: 0.25"
|
|
118
|
+
cx="12"
|
|
119
|
+
cy="12"
|
|
120
|
+
r="10"
|
|
121
|
+
stroke="currentColor"
|
|
122
|
+
stroke-width="4"
|
|
123
|
+
/>
|
|
124
|
+
<path
|
|
125
|
+
style="opacity: 0.75"
|
|
126
|
+
fill="currentColor"
|
|
127
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
128
|
+
/>
|
|
129
|
+
</svg>
|
|
130
|
+
Loading comments...
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<!-- Error state -->
|
|
134
|
+
<div v-else-if="error" class="bsky-state-message">
|
|
135
|
+
<p>{{ error }}</p>
|
|
136
|
+
<button v-if="hasIdentifier" class="bsky-link bsky-button" @click="refresh">Try again</button>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<!-- Empty state -->
|
|
140
|
+
<div v-else-if="comments.length === 0" class="bsky-state-message">
|
|
141
|
+
<p>No comments yet. Be the first to reply!</p>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<!-- Comments list -->
|
|
145
|
+
<div v-else class="bsky-comments-list">
|
|
146
|
+
<BlueskyComment
|
|
147
|
+
v-for="comment in visibleComments"
|
|
148
|
+
:key="comment.id"
|
|
149
|
+
:comment="comment"
|
|
150
|
+
:depth="0"
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<!-- Show more button -->
|
|
155
|
+
<div v-if="hasMoreComments" class="bsky-show-more">
|
|
156
|
+
<button class="bsky-link bsky-button" @click="showMoreComments">
|
|
157
|
+
{{ remainingCount }} more {{ remainingCount === 1 ? "comment" : "comments" }}
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</template>
|
|
162
|
+
|
|
163
|
+
<style scoped>
|
|
164
|
+
.bsky-stats-bar{align-items:center;color:inherit;display:inline-flex;font-size:.875rem;gap:1rem;margin-bottom:1rem;opacity:.6;text-decoration:none;transition:opacity .2s}.bsky-stats-bar:hover{color:var(--bsky-link,#2563eb);opacity:1}.bsky-stat{align-items:center;display:flex;gap:.25rem}.bsky-stat-icon{height:1rem;width:1rem}.bsky-heading{color:inherit;font-size:1.25rem;font-weight:600;margin-bottom:.5rem}.bsky-cta{font-size:.875rem;margin-bottom:1.5rem;opacity:.6}.bsky-link{color:var(--bsky-link,#2563eb);text-decoration:none}.bsky-link:hover{text-decoration:underline}.bsky-button{background:transparent;border:none;cursor:pointer;font-size:.875rem;padding:0}.bsky-loading{align-items:center;display:flex;justify-content:center;opacity:.6;padding:2rem 0}.bsky-spinner{animation:bsky-spin 1s linear infinite;height:1.5rem;margin-right:.5rem;width:1.5rem}@keyframes bsky-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.bsky-state-message{opacity:.6;padding:2rem 0;text-align:center}.bsky-show-more{margin-top:1rem}
|
|
165
|
+
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
/**
|
|
3
|
+
* AT Protocol URI of the post
|
|
4
|
+
*/
|
|
5
|
+
uri?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Bluesky web URL of the post
|
|
8
|
+
*/
|
|
9
|
+
url?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Maximum number of top-level comments to show initially
|
|
12
|
+
*/
|
|
13
|
+
limit?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Render same-author continuation replies at the same visual level (no nested wrapping).
|
|
16
|
+
*/
|
|
17
|
+
flattenSameAuthorThreads?: boolean;
|
|
18
|
+
};
|
|
19
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
20
|
+
declare const _default: typeof __VLS_export;
|
|
21
|
+
export default _default;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { AppBskyFeedDefs } from "@atproto/api";
|
|
2
|
+
import type { FlattenedComment } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Parse a Bluesky web URL into handle/DID and rkey.
|
|
5
|
+
*/
|
|
6
|
+
export declare function parseBlueskyUrl(url: string): {
|
|
7
|
+
identifier: string;
|
|
8
|
+
rkey: string;
|
|
9
|
+
} | null;
|
|
10
|
+
/**
|
|
11
|
+
* Convert an AT Protocol URI to a Bluesky web URL.
|
|
12
|
+
*/
|
|
13
|
+
export declare function uriToUrl(uri: string, handle?: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Process thread replies into flattened comments.
|
|
16
|
+
* Handles same-author consecutive reply flattening and engagement sorting.
|
|
17
|
+
*/
|
|
18
|
+
export type ProcessRepliesOptions = {
|
|
19
|
+
/**
|
|
20
|
+
* When true, same-author continuation replies are promoted to the same level as their parent comment
|
|
21
|
+
* (so they don't render as nested wrappers).
|
|
22
|
+
* @default true
|
|
23
|
+
*/
|
|
24
|
+
flattenSameAuthorThreads?: boolean;
|
|
25
|
+
};
|
|
26
|
+
export declare function processReplies(replies: AppBskyFeedDefs.ThreadViewPost["replies"], parentAuthorDid?: string, depth?: number, options?: ProcessRepliesOptions): FlattenedComment[];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { AppBskyFeedDefs } from "@atproto/api";
|
|
2
|
+
export function parseBlueskyUrl(url) {
|
|
3
|
+
const match = url.match(/https?:\/\/bsky\.app\/profile\/([^/]+)\/post\/([^/?#]+)/);
|
|
4
|
+
if (!match) return null;
|
|
5
|
+
const [, identifier, rkey] = match;
|
|
6
|
+
return { identifier, rkey };
|
|
7
|
+
}
|
|
8
|
+
export function uriToUrl(uri, handle) {
|
|
9
|
+
const match = uri.match(/at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/?#]+)/);
|
|
10
|
+
if (!match) return "";
|
|
11
|
+
const [, did, rkey] = match;
|
|
12
|
+
const identifier = handle || did;
|
|
13
|
+
return `https://bsky.app/profile/${identifier}/post/${rkey}`;
|
|
14
|
+
}
|
|
15
|
+
export function processReplies(replies, parentAuthorDid, depth = 0, options = {}) {
|
|
16
|
+
if (!replies || replies.length === 0) return [];
|
|
17
|
+
const flattenSameAuthorThreads = options.flattenSameAuthorThreads ?? true;
|
|
18
|
+
const groups = [];
|
|
19
|
+
for (const reply of replies) {
|
|
20
|
+
if (!AppBskyFeedDefs.isThreadViewPost(reply)) continue;
|
|
21
|
+
const post = reply.post;
|
|
22
|
+
const author = post.author;
|
|
23
|
+
const record = post.record;
|
|
24
|
+
const isSameAuthor = parentAuthorDid === author.did;
|
|
25
|
+
const comment = {
|
|
26
|
+
id: post.cid,
|
|
27
|
+
uri: post.uri,
|
|
28
|
+
author: {
|
|
29
|
+
did: author.did,
|
|
30
|
+
handle: author.handle,
|
|
31
|
+
displayName: author.displayName,
|
|
32
|
+
avatar: author.avatar
|
|
33
|
+
},
|
|
34
|
+
text: record?.text || "",
|
|
35
|
+
createdAt: record?.createdAt || post.indexedAt,
|
|
36
|
+
likeCount: post.likeCount || 0,
|
|
37
|
+
replyCount: post.replyCount || 0,
|
|
38
|
+
repostCount: post.repostCount || 0,
|
|
39
|
+
depth: isSameAuthor ? depth : depth,
|
|
40
|
+
replies: [],
|
|
41
|
+
parentAuthorDid
|
|
42
|
+
};
|
|
43
|
+
if (reply.replies && reply.replies.length > 0) {
|
|
44
|
+
const nestedReplies = processReplies(reply.replies, author.did, depth + 1, options);
|
|
45
|
+
if (flattenSameAuthorThreads) {
|
|
46
|
+
const continuations = [];
|
|
47
|
+
const nestedUnderComment = [];
|
|
48
|
+
for (const nestedReply of nestedReplies) {
|
|
49
|
+
if (nestedReply.author.did === author.did) {
|
|
50
|
+
continuations.push({
|
|
51
|
+
...nestedReply,
|
|
52
|
+
depth,
|
|
53
|
+
// Preserve "continuation" semantics for avatar/indent logic
|
|
54
|
+
parentAuthorDid: author.did
|
|
55
|
+
});
|
|
56
|
+
} else {
|
|
57
|
+
nestedUnderComment.push(nestedReply);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
comment.replies = nestedUnderComment;
|
|
61
|
+
groups.push({ head: comment, continuations });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
comment.replies = nestedReplies;
|
|
65
|
+
}
|
|
66
|
+
groups.push({ head: comment, continuations: [] });
|
|
67
|
+
}
|
|
68
|
+
groups.sort((a, b) => {
|
|
69
|
+
const scoreA = a.head.likeCount + a.head.replyCount;
|
|
70
|
+
const scoreB = b.head.likeCount + b.head.replyCount;
|
|
71
|
+
return scoreB - scoreA;
|
|
72
|
+
});
|
|
73
|
+
return groups.flatMap((g) => [g.head, ...g.continuations]);
|
|
74
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BlueskyCommentsResult } from "../types.js";
|
|
2
|
+
import { type ProcessRepliesOptions } from "./blueskyComments.logic.js";
|
|
3
|
+
/**
|
|
4
|
+
* Composable to fetch and manage Bluesky comments
|
|
5
|
+
*/
|
|
6
|
+
export declare function useBlueskyComments(uriOrUrl: string, options?: Pick<ProcessRepliesOptions, "flattenSameAuthorThreads">): BlueskyCommentsResult;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { ref, computed } from "vue";
|
|
2
|
+
import { AtpAgent, AppBskyFeedDefs } from "@atproto/api";
|
|
3
|
+
import {
|
|
4
|
+
parseBlueskyUrl,
|
|
5
|
+
processReplies,
|
|
6
|
+
uriToUrl
|
|
7
|
+
} from "./blueskyComments.logic.js";
|
|
8
|
+
const agent = new AtpAgent({
|
|
9
|
+
service: "https://public.api.bsky.app"
|
|
10
|
+
});
|
|
11
|
+
export function useBlueskyComments(uriOrUrl, options = {}) {
|
|
12
|
+
const loading = ref(true);
|
|
13
|
+
const error = ref(null);
|
|
14
|
+
const post = ref(null);
|
|
15
|
+
const comments = ref([]);
|
|
16
|
+
const stats = ref({
|
|
17
|
+
likeCount: 0,
|
|
18
|
+
repostCount: 0,
|
|
19
|
+
replyCount: 0,
|
|
20
|
+
quoteCount: 0
|
|
21
|
+
});
|
|
22
|
+
const postUrl = ref("");
|
|
23
|
+
async function resolveUri(input) {
|
|
24
|
+
if (input.startsWith("at://")) {
|
|
25
|
+
return input;
|
|
26
|
+
}
|
|
27
|
+
const parsed = parseBlueskyUrl(input);
|
|
28
|
+
if (!parsed) {
|
|
29
|
+
throw new Error("Invalid Bluesky URL format");
|
|
30
|
+
}
|
|
31
|
+
const { identifier, rkey } = parsed;
|
|
32
|
+
if (identifier.startsWith("did:")) {
|
|
33
|
+
return `at://${identifier}/app.bsky.feed.post/${rkey}`;
|
|
34
|
+
}
|
|
35
|
+
const { data } = await agent.resolveHandle({ handle: identifier });
|
|
36
|
+
return `at://${data.did}/app.bsky.feed.post/${rkey}`;
|
|
37
|
+
}
|
|
38
|
+
async function fetchComments() {
|
|
39
|
+
loading.value = true;
|
|
40
|
+
error.value = null;
|
|
41
|
+
try {
|
|
42
|
+
const uri = await resolveUri(uriOrUrl);
|
|
43
|
+
const response = await agent.getPostThread({
|
|
44
|
+
uri,
|
|
45
|
+
depth: 1e3,
|
|
46
|
+
// Fetch full depth
|
|
47
|
+
parentHeight: 0
|
|
48
|
+
// We don't need parent context
|
|
49
|
+
});
|
|
50
|
+
if (!response.success) {
|
|
51
|
+
throw new Error(`Failed to fetch post: ${uri}`);
|
|
52
|
+
}
|
|
53
|
+
const { data } = response;
|
|
54
|
+
if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
|
|
55
|
+
if (AppBskyFeedDefs.isBlockedPost(data.thread)) {
|
|
56
|
+
throw new Error("This post is from a blocked account");
|
|
57
|
+
}
|
|
58
|
+
if (AppBskyFeedDefs.isNotFoundPost(data.thread)) {
|
|
59
|
+
throw new Error(`Post not found: ${uri}`);
|
|
60
|
+
}
|
|
61
|
+
throw new Error("Post not found or not accessible");
|
|
62
|
+
}
|
|
63
|
+
const thread = data.thread;
|
|
64
|
+
post.value = thread.post;
|
|
65
|
+
stats.value = {
|
|
66
|
+
likeCount: thread.post.likeCount || 0,
|
|
67
|
+
repostCount: thread.post.repostCount || 0,
|
|
68
|
+
replyCount: thread.post.replyCount || 0,
|
|
69
|
+
quoteCount: thread.post.quoteCount || 0
|
|
70
|
+
};
|
|
71
|
+
postUrl.value = uriToUrl(uri, thread.post.author.handle);
|
|
72
|
+
comments.value = processReplies(thread.replies, thread.post.author.did, 0, options);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
error.value = e instanceof Error ? `Failed to load comments from ${uriOrUrl}: ${e.message}` : "Failed to load comments";
|
|
75
|
+
} finally {
|
|
76
|
+
loading.value = false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
fetchComments();
|
|
80
|
+
return {
|
|
81
|
+
loading: computed(() => loading.value),
|
|
82
|
+
error: computed(() => error.value),
|
|
83
|
+
post: computed(() => post.value),
|
|
84
|
+
comments: computed(() => comments.value),
|
|
85
|
+
stats: computed(() => stats.value),
|
|
86
|
+
postUrl: computed(() => postUrl.value),
|
|
87
|
+
refresh: fetchComments
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { AppBskyFeedDefs, AppBskyFeedPost, AppBskyActorDefs } from "@atproto/api";
|
|
2
|
+
import type { ComputedRef, Ref } from "vue";
|
|
3
|
+
/**
|
|
4
|
+
* Module configuration options
|
|
5
|
+
*/
|
|
6
|
+
export interface ModuleOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Default Bluesky API service URL
|
|
9
|
+
* @default 'https://public.api.bsky.app'
|
|
10
|
+
*/
|
|
11
|
+
apiService?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Props for the BlueskyComments component
|
|
15
|
+
*/
|
|
16
|
+
export interface BlueskyCommentsProps {
|
|
17
|
+
/**
|
|
18
|
+
* AT Protocol URI of the post (e.g., at://did:plc:.../app.bsky.feed.post/...)
|
|
19
|
+
*/
|
|
20
|
+
uri?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Bluesky web URL of the post (e.g., https://bsky.app/profile/user.bsky.social/post/abc123)
|
|
23
|
+
* Will be converted to URI internally
|
|
24
|
+
*/
|
|
25
|
+
url?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Maximum number of top-level comments to show initially
|
|
28
|
+
* @default 5
|
|
29
|
+
*/
|
|
30
|
+
limit?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Render same-author continuation replies at the same level (no nested wrapping).
|
|
33
|
+
* @default true
|
|
34
|
+
*/
|
|
35
|
+
flattenSameAuthorThreads?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Represents a flattened comment for display
|
|
39
|
+
*/
|
|
40
|
+
export interface FlattenedComment {
|
|
41
|
+
/** Unique comment ID (CID) */
|
|
42
|
+
id: string;
|
|
43
|
+
/** AT Protocol URI of the comment */
|
|
44
|
+
uri: string;
|
|
45
|
+
/** Author information */
|
|
46
|
+
author: {
|
|
47
|
+
did: string;
|
|
48
|
+
handle: string;
|
|
49
|
+
displayName?: string;
|
|
50
|
+
avatar?: string;
|
|
51
|
+
};
|
|
52
|
+
/** Comment text content */
|
|
53
|
+
text: string;
|
|
54
|
+
/** Timestamp when the comment was created */
|
|
55
|
+
createdAt: string;
|
|
56
|
+
/** Number of likes */
|
|
57
|
+
likeCount: number;
|
|
58
|
+
/** Number of replies */
|
|
59
|
+
replyCount: number;
|
|
60
|
+
/** Number of reposts */
|
|
61
|
+
repostCount: number;
|
|
62
|
+
/** Nesting depth level (0 = top level reply to main post) */
|
|
63
|
+
depth: number;
|
|
64
|
+
/** Child comments (flattened for same-author threads) */
|
|
65
|
+
replies: FlattenedComment[];
|
|
66
|
+
/** Parent comment author DID (for flattening logic) */
|
|
67
|
+
parentAuthorDid?: string;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Post engagement statistics
|
|
71
|
+
*/
|
|
72
|
+
export interface PostStats {
|
|
73
|
+
likeCount: number;
|
|
74
|
+
repostCount: number;
|
|
75
|
+
replyCount: number;
|
|
76
|
+
quoteCount: number;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Result from useBlueskyComments composable
|
|
80
|
+
*/
|
|
81
|
+
export type MaybeRef<T> = Ref<T> | ComputedRef<T>;
|
|
82
|
+
export interface BlueskyCommentsResult {
|
|
83
|
+
/** Loading state */
|
|
84
|
+
loading: MaybeRef<boolean>;
|
|
85
|
+
/** Error message if any */
|
|
86
|
+
error: MaybeRef<string | null>;
|
|
87
|
+
/** The main post data */
|
|
88
|
+
post: MaybeRef<AppBskyFeedDefs.PostView | null>;
|
|
89
|
+
/** Flattened and processed comments */
|
|
90
|
+
comments: MaybeRef<FlattenedComment[]>;
|
|
91
|
+
/** Post engagement stats */
|
|
92
|
+
stats: MaybeRef<PostStats>;
|
|
93
|
+
/** URL to the post on Bluesky */
|
|
94
|
+
postUrl: MaybeRef<string>;
|
|
95
|
+
/** Refresh comments */
|
|
96
|
+
refresh: () => Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Re-export useful types from @atproto/api
|
|
100
|
+
*/
|
|
101
|
+
export type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost;
|
|
102
|
+
export type PostView = AppBskyFeedDefs.PostView;
|
|
103
|
+
export type PostRecord = AppBskyFeedPost.Record;
|
|
104
|
+
export type ProfileViewBasic = AppBskyActorDefs.ProfileViewBasic;
|
|
File without changes
|
package/dist/types.d.mts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nuxt-bluesky-comments",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Nuxt module to display Bluesky post comments on your website",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"atproto",
|
|
7
|
+
"bluesky",
|
|
8
|
+
"comments",
|
|
9
|
+
"nuxt",
|
|
10
|
+
"nuxt-module",
|
|
11
|
+
"social"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Patryk Tomczyk <patzick.dev>",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/patzick/nuxt-bluesky-comments.git"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./dist/module.mjs",
|
|
24
|
+
"types": "./dist/types.d.mts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/types.d.mts",
|
|
28
|
+
"import": "./dist/module.mjs"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@atproto/api": "0.18.8",
|
|
33
|
+
"@nuxt/kit": "4.2.2"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@nuxt/module-builder": "1.0.2",
|
|
37
|
+
"@nuxt/schema": "4.2.2",
|
|
38
|
+
"@types/node": "24.3.0",
|
|
39
|
+
"@unocss/nuxt": "66.5.10",
|
|
40
|
+
"nuxt": "4.2.2",
|
|
41
|
+
"oxfmt": "0.21.0",
|
|
42
|
+
"oxlint": "1.36.0",
|
|
43
|
+
"typescript": "5.9.3",
|
|
44
|
+
"vitest": "4.0.16",
|
|
45
|
+
"vue-tsc": "3.2.1"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@unocss/nuxt": ">=66.0.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"@unocss/nuxt": {
|
|
52
|
+
"optional": true
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"dev": "pnpm -C playground dev",
|
|
57
|
+
"dev:build": "pnpm -C playground build",
|
|
58
|
+
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && (cd playground && nuxi prepare)",
|
|
59
|
+
"release": "npm run prepack && npm publish",
|
|
60
|
+
"typecheck": "tsc --noEmit",
|
|
61
|
+
"lint": "oxlint --fix",
|
|
62
|
+
"format": "oxfmt",
|
|
63
|
+
"test": "vitest run",
|
|
64
|
+
"test:watch": "vitest"
|
|
65
|
+
}
|
|
66
|
+
}
|