@respectify/astro 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 +189 -0
- package/client/comments.ts +59 -0
- package/components/CommentForm.astro +133 -0
- package/components/CommentList.astro +119 -0
- package/components/CommentSection.astro +41 -0
- package/components/PoweredByRespectify.astro +42 -0
- package/components/respectify-comments.css +373 -0
- package/default-respectify.config.json +19 -0
- package/package.json +65 -0
- package/src/actions.ts +77 -0
- package/src/index.ts +34 -0
- package/src/lib/config.ts +76 -0
- package/src/lib/logger.ts +20 -0
- package/src/lib/service.ts +184 -0
- package/src/routes/comments.ts +44 -0
- package/src/runtime-config.ts +25 -0
- package/src/schema.ts +22 -0
- package/src/types.ts +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# @respectify/astro
|
|
2
|
+
|
|
3
|
+
Respectify-powered commenting for Astro. Drop in AI moderation, spam filtering, and a polished comment UX in minutes.
|
|
4
|
+
|
|
5
|
+
Built for production — powers [nickhodges.com](https://nickhodges.com) as a live Respectify demo.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Respectify AI moderation** — spam detection, toxicity scoring, fallacy detection, revision suggestions
|
|
10
|
+
- **Works with prerendered pages** — comments load at runtime via API; no rebuild needed
|
|
11
|
+
- **One component** — `<CommentSection postSlug="..." />`
|
|
12
|
+
- **Powered by Respectify branding** — optional callout and badge for demos
|
|
13
|
+
- **Astro DB storage** — Turso/libSQL via `@astrojs/db`
|
|
14
|
+
- **Self-contained CSS** — no Tailwind required; customize via CSS variables
|
|
15
|
+
- **Fail-closed** — rejects comments when Respectify is unavailable
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Astro 5 or 6 with **`output: 'server'`** (or hybrid) and a server adapter (Vercel, Node, etc.)
|
|
20
|
+
- [`@astrojs/db`](https://docs.astro.build/en/guides/astro-db/) configured with Turso for production
|
|
21
|
+
- A [Respectify](https://respectify.ai) account (`RESPECTIFY_EMAIL` + `RESPECTIFY_API_KEY`)
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
### 1. Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install @respectify/astro @respectify/client @astrojs/db
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. Add the integration
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// astro.config.ts
|
|
35
|
+
import { defineConfig } from 'astro/config';
|
|
36
|
+
import db from '@astrojs/db';
|
|
37
|
+
import respectify from '@respectify/astro';
|
|
38
|
+
|
|
39
|
+
export default defineConfig({
|
|
40
|
+
site: 'https://yoursite.com',
|
|
41
|
+
output: 'server',
|
|
42
|
+
adapter: vercel(), // or node, netlify, etc.
|
|
43
|
+
integrations: [
|
|
44
|
+
db(),
|
|
45
|
+
respectify({
|
|
46
|
+
// Optional: customize post URL for Respectify topic context
|
|
47
|
+
getPostUrl: (slug, site) => `${site}/blog/${slug}/`,
|
|
48
|
+
}),
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 3. Configure Respectify
|
|
54
|
+
|
|
55
|
+
Copy the default config to your project root:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cp node_modules/@respectify/astro/default-respectify.config.json ./respectify.config.json
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Add environment variables:
|
|
62
|
+
|
|
63
|
+
```env
|
|
64
|
+
RESPECTIFY_EMAIL=you@example.com
|
|
65
|
+
RESPECTIFY_API_KEY=your-api-key
|
|
66
|
+
ASTRO_DB_REMOTE_URL=libsql://...
|
|
67
|
+
ASTRO_DB_APP_TOKEN=...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 4. Add the database table
|
|
71
|
+
|
|
72
|
+
Copy this into `db/config.ts` (or import from `@respectify/astro/schema` if your setup supports it):
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { defineDb, defineTable, column, NOW } from 'astro:db';
|
|
76
|
+
|
|
77
|
+
const Comment = defineTable({
|
|
78
|
+
columns: {
|
|
79
|
+
id: column.number({ primaryKey: true }),
|
|
80
|
+
postSlug: column.text(),
|
|
81
|
+
author: column.text(),
|
|
82
|
+
email: column.text({ optional: true }),
|
|
83
|
+
content: column.text(),
|
|
84
|
+
createdAt: column.date({ default: NOW }),
|
|
85
|
+
approved: column.boolean({ default: false }),
|
|
86
|
+
parentId: column.number({ optional: true }),
|
|
87
|
+
},
|
|
88
|
+
indexes: [
|
|
89
|
+
{ on: ['postSlug'], unique: false },
|
|
90
|
+
{ on: ['approved'], unique: false },
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export default defineDb({ tables: { Comment } });
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Or use the exported helper:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { defineDb } from 'astro:db';
|
|
101
|
+
import { Comment } from '@respectify/astro/schema';
|
|
102
|
+
|
|
103
|
+
export default defineDb({ tables: { Comment } });
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Run migrations:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
npx astro db push
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 5. Wire up Astro Actions
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
// src/actions/index.ts
|
|
116
|
+
import { respectifyCommentActions } from '@respectify/astro/actions';
|
|
117
|
+
|
|
118
|
+
export const server = {
|
|
119
|
+
comments: respectifyCommentActions,
|
|
120
|
+
};
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### 6. Add comments to your layout
|
|
124
|
+
|
|
125
|
+
```astro
|
|
126
|
+
---
|
|
127
|
+
import CommentSection from '@respectify/astro/components/CommentSection.astro';
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
<CommentSection postSlug={post.id} />
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
That's it. Comments are moderated by Respectify before they appear.
|
|
134
|
+
|
|
135
|
+
## Component props
|
|
136
|
+
|
|
137
|
+
| Prop | Default | Description |
|
|
138
|
+
|------|---------|-------------|
|
|
139
|
+
| `postSlug` | required | Unique identifier for the page |
|
|
140
|
+
| `apiPath` | `/api/respectify/comments` | Comments API path (must match integration) |
|
|
141
|
+
| `showBranding` | `true` | Show Powered by Respectify callout |
|
|
142
|
+
| `enableDelete` | `false` | Show delete controls when admin is authenticated |
|
|
143
|
+
| `class` | — | Extra CSS class on root wrapper |
|
|
144
|
+
|
|
145
|
+
## Customization
|
|
146
|
+
|
|
147
|
+
### CSS variables
|
|
148
|
+
|
|
149
|
+
Override on `.rf-comments`:
|
|
150
|
+
|
|
151
|
+
```css
|
|
152
|
+
.rf-comments {
|
|
153
|
+
--rf-accent: #2bbc89;
|
|
154
|
+
--rf-accent-hover: #24966f;
|
|
155
|
+
--rf-radius: 0.75rem;
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Integration options
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
respectify({
|
|
163
|
+
configPath: './respectify.config.json',
|
|
164
|
+
commentsApiPath: '/api/respectify/comments',
|
|
165
|
+
showBranding: true,
|
|
166
|
+
getPostUrl: (slug, site) => `${site}/posts/${slug}/`,
|
|
167
|
+
rateLimit: { windowMs: 300_000, maxRequests: 10 },
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Admin delete (optional)
|
|
172
|
+
|
|
173
|
+
Set `enableDelete` on `CommentSection` and implement auth so `context.locals.isAuthenticated` is true. See the [Nick-Blog](https://github.com/NickHodges/Nick-Blog) repo for a full admin auth example.
|
|
174
|
+
|
|
175
|
+
## How it works
|
|
176
|
+
|
|
177
|
+
```text
|
|
178
|
+
Visitor submits comment
|
|
179
|
+
→ Astro Action (comments.submit)
|
|
180
|
+
→ Respectify megacall (spam + respectfulness)
|
|
181
|
+
→ If approved: save to Astro DB
|
|
182
|
+
→ Client refreshes comment list via GET /api/respectify/comments
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Post pages can stay `prerender = true`. Only the API route and actions need server runtime.
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface CommentResponse {
|
|
2
|
+
id: number;
|
|
3
|
+
author: string;
|
|
4
|
+
content: string;
|
|
5
|
+
createdAt: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatCommentDate(date: string | Date): string {
|
|
9
|
+
return new Date(date).toLocaleDateString('en-US', {
|
|
10
|
+
year: 'numeric',
|
|
11
|
+
month: 'long',
|
|
12
|
+
day: 'numeric',
|
|
13
|
+
hour: '2-digit',
|
|
14
|
+
minute: '2-digit',
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function escapeHtml(text: string): string {
|
|
19
|
+
const div = document.createElement('div');
|
|
20
|
+
div.textContent = text;
|
|
21
|
+
return div.innerHTML;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function renderCommentHtml(comment: CommentResponse, options?: { showDelete?: boolean }): string {
|
|
25
|
+
const formattedDate = formatCommentDate(comment.createdAt);
|
|
26
|
+
const deleteControls = options?.showDelete
|
|
27
|
+
? `
|
|
28
|
+
<span class="rf-comments__delete-controls" data-delete-for="${comment.id}">
|
|
29
|
+
<button class="rf-comments__delete-btn" data-comment-id="${comment.id}" type="button">Delete</button>
|
|
30
|
+
<span class="rf-comments__delete-confirm">
|
|
31
|
+
<span>Delete?</span>
|
|
32
|
+
<button class="rf-comments__delete-btn confirm-delete-btn" data-comment-id="${comment.id}" type="button">Yes</button>
|
|
33
|
+
<button class="rf-comments__delete-cancel cancel-delete-btn" type="button">Cancel</button>
|
|
34
|
+
</span>
|
|
35
|
+
<span class="rf-comments__delete-error rf-comments__hidden"></span>
|
|
36
|
+
</span>`
|
|
37
|
+
: '';
|
|
38
|
+
|
|
39
|
+
return `
|
|
40
|
+
<article class="rf-comments__item" data-comment-id="${comment.id}">
|
|
41
|
+
<header class="rf-comments__item-header">
|
|
42
|
+
<div>
|
|
43
|
+
<span class="rf-comments__author">${escapeHtml(comment.author)}</span>
|
|
44
|
+
${deleteControls}
|
|
45
|
+
</div>
|
|
46
|
+
<time class="rf-comments__date" datetime="${comment.createdAt}">${formattedDate}</time>
|
|
47
|
+
</header>
|
|
48
|
+
<div class="rf-comments__content">${escapeHtml(comment.content)}</div>
|
|
49
|
+
</article>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function fetchComments(apiPath: string, postSlug: string): Promise<CommentResponse[]> {
|
|
53
|
+
const url = `${apiPath}?slug=${encodeURIComponent(postSlug)}`;
|
|
54
|
+
const response = await fetch(url);
|
|
55
|
+
if (!response.ok) return [];
|
|
56
|
+
|
|
57
|
+
const data = (await response.json()) as { comments?: CommentResponse[] };
|
|
58
|
+
return data.comments ?? [];
|
|
59
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
postSlug: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { postSlug } = Astro.props;
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<div class="rf-comments__form-panel">
|
|
10
|
+
<h3 class="rf-comments__form-title">Leave a Comment</h3>
|
|
11
|
+
<p class="rf-comments__form-note">
|
|
12
|
+
Your comment will be reviewed by <a href="https://respectify.ai" rel="noopener noreferrer" target="_blank">Respectify</a> before it appears.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<form class="rf-comments__form" data-post-slug={postSlug} id="rf-comment-form">
|
|
16
|
+
<div class="rf-comments__field">
|
|
17
|
+
<label class="rf-comments__label" for="rf-author">
|
|
18
|
+
Name <span class="rf-comments__required">*</span>
|
|
19
|
+
</label>
|
|
20
|
+
<input class="rf-comments__input" id="rf-author" maxlength="100" name="author" required type="text" />
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="rf-comments__field">
|
|
24
|
+
<label class="rf-comments__label" for="rf-email">Email (optional, not public)</label>
|
|
25
|
+
<input class="rf-comments__input" id="rf-email" name="email" type="email" />
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div class="rf-comments__field">
|
|
29
|
+
<label class="rf-comments__label" for="rf-content">
|
|
30
|
+
Comment <span class="rf-comments__required">*</span>
|
|
31
|
+
</label>
|
|
32
|
+
<textarea class="rf-comments__textarea" id="rf-content" maxlength="5000" name="content" required rows="5"></textarea>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<button class="rf-comments__submit" id="rf-submit-button" type="submit">
|
|
36
|
+
<span id="rf-button-text">Submit Comment</span>
|
|
37
|
+
<svg aria-hidden="true" class="rf-comments__spinner" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
38
|
+
<circle cx="12" cy="12" fill="none" r="10" stroke="currentColor" stroke-width="4" style="opacity:0.25"></circle>
|
|
39
|
+
<path 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" fill="currentColor" style="opacity:0.75"></path>
|
|
40
|
+
</svg>
|
|
41
|
+
</button>
|
|
42
|
+
|
|
43
|
+
<div class="rf-comments__message rf-comments__hidden" id="rf-form-message" role="status"></div>
|
|
44
|
+
</form>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<script>
|
|
48
|
+
import { actions } from 'astro:actions';
|
|
49
|
+
|
|
50
|
+
function createTextDiv(text: string, className: string): HTMLDivElement {
|
|
51
|
+
const div = document.createElement('div');
|
|
52
|
+
div.className = className;
|
|
53
|
+
div.textContent = text;
|
|
54
|
+
return div;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const form = document.getElementById('rf-comment-form') as HTMLFormElement | null;
|
|
58
|
+
const messageEl = document.getElementById('rf-form-message');
|
|
59
|
+
const submitButton = document.getElementById('rf-submit-button') as HTMLButtonElement | null;
|
|
60
|
+
const buttonText = document.getElementById('rf-button-text');
|
|
61
|
+
|
|
62
|
+
if (form && messageEl && submitButton && buttonText) {
|
|
63
|
+
const postSlug = form.dataset.postSlug || '';
|
|
64
|
+
|
|
65
|
+
form.addEventListener('submit', async (e) => {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
|
|
68
|
+
submitButton.disabled = true;
|
|
69
|
+
submitButton.classList.add('rf-comments__submit--analyzing');
|
|
70
|
+
buttonText.textContent = 'Analyzing with Respectify AI...';
|
|
71
|
+
|
|
72
|
+
const formData = new FormData(form);
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const { data, error } = await actions.comments.submit({
|
|
76
|
+
postSlug,
|
|
77
|
+
author: formData.get('author') as string,
|
|
78
|
+
email: (formData.get('email') as string) || undefined,
|
|
79
|
+
content: formData.get('content') as string,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
messageEl.classList.remove('rf-comments__hidden', 'rf-comments__message--success', 'rf-comments__message--warning', 'rf-comments__message--error');
|
|
83
|
+
messageEl.replaceChildren();
|
|
84
|
+
|
|
85
|
+
if (error) {
|
|
86
|
+
messageEl.classList.add('rf-comments__message--error');
|
|
87
|
+
messageEl.appendChild(createTextDiv('An error occurred. Please try again.', 'rf-comments__message-title'));
|
|
88
|
+
messageEl.classList.remove('rf-comments__hidden');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (data.approved) {
|
|
93
|
+
messageEl.classList.add('rf-comments__message--success');
|
|
94
|
+
messageEl.appendChild(createTextDiv(`✓ ${data.message}`, 'rf-comments__message-title'));
|
|
95
|
+
messageEl.appendChild(createTextDiv(data.feedback, 'rf-comments__message-body'));
|
|
96
|
+
if (data.score !== undefined) {
|
|
97
|
+
messageEl.appendChild(createTextDiv(`Respectfulness score: ${(data.score * 100).toFixed(0)}%`, 'rf-comments__message-score'));
|
|
98
|
+
}
|
|
99
|
+
form.reset();
|
|
100
|
+
window.dispatchEvent(new CustomEvent('respectify:comment-published', { detail: { postSlug } }));
|
|
101
|
+
} else {
|
|
102
|
+
messageEl.classList.add('rf-comments__message--warning');
|
|
103
|
+
messageEl.appendChild(createTextDiv('Comment needs improvement', 'rf-comments__message-title'));
|
|
104
|
+
messageEl.appendChild(createTextDiv(data.feedback || 'Your comment did not meet our respectfulness standards.', 'rf-comments__message-body'));
|
|
105
|
+
|
|
106
|
+
if (data.suggestion) {
|
|
107
|
+
const suggestionBox = document.createElement('div');
|
|
108
|
+
suggestionBox.className = 'rf-comments__suggestion';
|
|
109
|
+
suggestionBox.appendChild(createTextDiv('Suggestion:', 'rf-comments__suggestion-label'));
|
|
110
|
+
suggestionBox.appendChild(createTextDiv(data.suggestion, 'rf-comments__suggestion-text'));
|
|
111
|
+
messageEl.appendChild(suggestionBox);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (data.score !== undefined) {
|
|
115
|
+
messageEl.appendChild(createTextDiv(`Respectfulness score: ${(data.score * 100).toFixed(0)}%`, 'rf-comments__message-score'));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
messageEl.appendChild(createTextDiv('Please revise your comment and try again.', 'rf-comments__message-footer'));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
messageEl.classList.remove('rf-comments__hidden');
|
|
122
|
+
} catch {
|
|
123
|
+
messageEl.classList.remove('rf-comments__hidden');
|
|
124
|
+
messageEl.classList.add('rf-comments__message--error');
|
|
125
|
+
messageEl.replaceChildren(createTextDiv('An error occurred. Please try again.', 'rf-comments__message-title'));
|
|
126
|
+
} finally {
|
|
127
|
+
submitButton.disabled = false;
|
|
128
|
+
submitButton.classList.remove('rf-comments__submit--analyzing');
|
|
129
|
+
buttonText.textContent = 'Submit Comment';
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
</script>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
postSlug: string;
|
|
4
|
+
apiPath?: string;
|
|
5
|
+
enableDelete?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { postSlug, apiPath = '/api/respectify/comments', enableDelete = false } = Astro.props;
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<section class="rf-comments__section" data-api-path={apiPath} data-enable-delete={String(enableDelete)} data-post-slug={postSlug}>
|
|
12
|
+
<h3 class="rf-comments__heading">
|
|
13
|
+
Comments (<span class="rf-comments__count">…</span>)
|
|
14
|
+
</h3>
|
|
15
|
+
|
|
16
|
+
<p class="rf-comments__loading">Loading comments…</p>
|
|
17
|
+
<div class="rf-comments__list rf-comments__hidden"></div>
|
|
18
|
+
<p class="rf-comments__empty rf-comments__hidden">No comments yet. Be the first to comment!</p>
|
|
19
|
+
</section>
|
|
20
|
+
|
|
21
|
+
<script define:vars={{ apiPath, enableDelete }}>
|
|
22
|
+
import { actions } from 'astro:actions';
|
|
23
|
+
import { fetchComments, renderCommentHtml } from '@respectify/astro/client';
|
|
24
|
+
|
|
25
|
+
async function checkAuthForDelete() {
|
|
26
|
+
if (!enableDelete) return false;
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch('/api/auth-status');
|
|
29
|
+
if (!response.ok) return false;
|
|
30
|
+
const data = await response.json();
|
|
31
|
+
return Boolean(data.isAuthenticated);
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function setupDeleteControls() {
|
|
38
|
+
document.querySelectorAll('.rf-comments__delete-controls').forEach((group) => {
|
|
39
|
+
if (group.getAttribute('data-delete-bound') === 'true') return;
|
|
40
|
+
group.setAttribute('data-delete-bound', 'true');
|
|
41
|
+
group.classList.add('rf-comments__delete-controls--visible');
|
|
42
|
+
|
|
43
|
+
const deleteBtn = group.querySelector('.rf-comments__delete-btn:not(.confirm-delete-btn)') ;
|
|
44
|
+
const confirmBtn = group.querySelector('.confirm-delete-btn');
|
|
45
|
+
const cancelBtn = group.querySelector('.cancel-delete-btn');
|
|
46
|
+
const confirmSpan = group.querySelector('.rf-comments__delete-confirm');
|
|
47
|
+
|
|
48
|
+
deleteBtn?.addEventListener('click', () => {
|
|
49
|
+
deleteBtn.classList.add('rf-comments__hidden');
|
|
50
|
+
confirmSpan?.classList.add('rf-comments__delete-confirm--visible');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
cancelBtn?.addEventListener('click', () => {
|
|
54
|
+
confirmSpan?.classList.remove('rf-comments__delete-confirm--visible');
|
|
55
|
+
deleteBtn?.classList.remove('rf-comments__hidden');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
confirmBtn?.addEventListener('click', async () => {
|
|
59
|
+
const commentId = parseInt(confirmBtn.getAttribute('data-comment-id') || '0', 10);
|
|
60
|
+
if (!commentId) return;
|
|
61
|
+
|
|
62
|
+
const { error } = await actions.comments.delete({ commentId });
|
|
63
|
+
if (error) return;
|
|
64
|
+
|
|
65
|
+
document.querySelector(`[data-comment-id="${commentId}"]`)?.remove();
|
|
66
|
+
const section = document.querySelector('.rf-comments__section');
|
|
67
|
+
const countEl = section?.querySelector('.rf-comments__count');
|
|
68
|
+
if (countEl) {
|
|
69
|
+
countEl.textContent = String(Math.max(parseInt(countEl.textContent || '0', 10) - 1, 0));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function loadComments(section) {
|
|
76
|
+
const postSlug = section.dataset.postSlug || '';
|
|
77
|
+
const path = section.dataset.apiPath || apiPath;
|
|
78
|
+
const loadingEl = section.querySelector('.rf-comments__loading');
|
|
79
|
+
const listEl = section.querySelector('.rf-comments__list');
|
|
80
|
+
const emptyEl = section.querySelector('.rf-comments__empty');
|
|
81
|
+
const countEl = section.querySelector('.rf-comments__count');
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const isAdmin = await checkAuthForDelete();
|
|
85
|
+
const comments = await fetchComments(path, postSlug);
|
|
86
|
+
|
|
87
|
+
loadingEl?.classList.add('rf-comments__hidden');
|
|
88
|
+
if (countEl) countEl.textContent = String(comments.length);
|
|
89
|
+
|
|
90
|
+
if (comments.length === 0) {
|
|
91
|
+
emptyEl?.classList.remove('rf-comments__hidden');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (listEl) {
|
|
96
|
+
listEl.innerHTML = comments.map((c) => renderCommentHtml(c, { showDelete: isAdmin })).join('');
|
|
97
|
+
listEl.classList.remove('rf-comments__hidden');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isAdmin) setupDeleteControls();
|
|
101
|
+
} catch {
|
|
102
|
+
if (loadingEl) loadingEl.textContent = 'Unable to load comments.';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const section = document.querySelector('.rf-comments__section');
|
|
107
|
+
if (section) {
|
|
108
|
+
loadComments(section);
|
|
109
|
+
|
|
110
|
+
window.addEventListener('respectify:comment-published', (event) => {
|
|
111
|
+
const detail = event.detail;
|
|
112
|
+
if (detail?.postSlug === section.dataset.postSlug) {
|
|
113
|
+
section.querySelector('.rf-comments__empty')?.classList.add('rf-comments__hidden');
|
|
114
|
+
section.querySelector('.rf-comments__list')?.classList.remove('rf-comments__hidden');
|
|
115
|
+
loadComments(section);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
</script>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '@respectify/astro/styles.css';
|
|
3
|
+
import PoweredByRespectify from './PoweredByRespectify.astro';
|
|
4
|
+
import CommentList from './CommentList.astro';
|
|
5
|
+
import CommentForm from './CommentForm.astro';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
/** Unique identifier for the page/post (e.g. blog slug) */
|
|
9
|
+
postSlug: string;
|
|
10
|
+
/** API path injected by the integration (default: /api/respectify/comments) */
|
|
11
|
+
apiPath?: string;
|
|
12
|
+
/** Show Powered by Respectify branding (default: true) */
|
|
13
|
+
showBranding?: boolean;
|
|
14
|
+
/** Enable admin delete controls when authenticated (default: false) */
|
|
15
|
+
enableDelete?: boolean;
|
|
16
|
+
/** Optional CSS class on the root wrapper */
|
|
17
|
+
class?: string;
|
|
18
|
+
/** Inline styles (e.g. CSS variable overrides) */
|
|
19
|
+
style?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
postSlug,
|
|
24
|
+
apiPath = '/api/respectify/comments',
|
|
25
|
+
showBranding = true,
|
|
26
|
+
enableDelete = false,
|
|
27
|
+
class: className,
|
|
28
|
+
style,
|
|
29
|
+
} = Astro.props;
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<div class:list={['rf-comments', className]} style={style}>
|
|
33
|
+
{showBranding && <PoweredByRespectify variant="callout" />}
|
|
34
|
+
<CommentList postSlug={postSlug} apiPath={apiPath} enableDelete={enableDelete} />
|
|
35
|
+
<CommentForm postSlug={postSlug} />
|
|
36
|
+
{showBranding && (
|
|
37
|
+
<div style="display: flex; justify-content: flex-end;">
|
|
38
|
+
<PoweredByRespectify variant="badge" />
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
variant?: 'badge' | 'callout';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { variant = 'callout' } = Astro.props;
|
|
7
|
+
|
|
8
|
+
const respectifyUrl = 'https://respectify.ai';
|
|
9
|
+
const demoUrl = 'https://demo.respectify.ai';
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
{
|
|
13
|
+
variant === 'callout' ? (
|
|
14
|
+
<div class="rf-comments__brand">
|
|
15
|
+
<div class="rf-comments__brand-inner">
|
|
16
|
+
<div>
|
|
17
|
+
<p class="rf-comments__brand-title">
|
|
18
|
+
<a href={respectifyUrl} rel="noopener noreferrer" target="_blank">
|
|
19
|
+
<svg aria-hidden="true" fill="none" height="16" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" xmlns="http://www.w3.org/2000/svg">
|
|
20
|
+
<path d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" stroke-linecap="round" stroke-linejoin="round" />
|
|
21
|
+
</svg>
|
|
22
|
+
Powered by Respectify
|
|
23
|
+
</a>
|
|
24
|
+
</p>
|
|
25
|
+
<p class="rf-comments__brand-desc">
|
|
26
|
+
Comments are analyzed in real time for spam and respectfulness before they appear. Leave a comment to see Respectify in action.
|
|
27
|
+
</p>
|
|
28
|
+
</div>
|
|
29
|
+
<a class="rf-comments__brand-cta" href={demoUrl} rel="noopener noreferrer" target="_blank">
|
|
30
|
+
Try the demo →
|
|
31
|
+
</a>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
) : (
|
|
35
|
+
<a class="rf-comments__brand-badge" href={respectifyUrl} rel="noopener noreferrer" target="_blank">
|
|
36
|
+
<svg aria-hidden="true" fill="none" height="14" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="14" xmlns="http://www.w3.org/2000/svg">
|
|
37
|
+
<path d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" stroke-linecap="round" stroke-linejoin="round" />
|
|
38
|
+
</svg>
|
|
39
|
+
Powered by Respectify
|
|
40
|
+
</a>
|
|
41
|
+
)
|
|
42
|
+
}
|