@seriphxyz/astro 0.1.2 → 0.1.7

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 CHANGED
@@ -152,6 +152,49 @@ import Reactions from "@seriphxyz/astro/Reactions";
152
152
  - `seriph:reaction-added` - Reaction added
153
153
  - `seriph:reaction-removed` - Reaction removed
154
154
 
155
+ ### Subscribe
156
+
157
+ Email subscription form with double opt-in:
158
+
159
+ ```astro
160
+ ---
161
+ import Subscribe from "@seriphxyz/astro/Subscribe";
162
+ ---
163
+
164
+ <Subscribe
165
+ siteKey={import.meta.env.SERIPH_SITE_KEY}
166
+ buttonText="Subscribe"
167
+ placeholder="your@email.com"
168
+ />
169
+ ```
170
+
171
+ **Props:**
172
+ - `siteKey` (required) - Your Seriph site key
173
+ - `endpoint` - Base URL (default: `https://seriph.xyz`)
174
+ - `buttonText` - Submit button text (default: `'Subscribe'`)
175
+ - `placeholder` - Email input placeholder
176
+ - `successMessage` - Custom success message
177
+ - `theme` - `'light'` | `'dark'` | `'auto'` (default: `'light'`)
178
+ - `class` - Additional CSS class
179
+
180
+ **Events:**
181
+ - `seriph:subscribed` - Subscription successful
182
+
183
+ ### SubscribeForm
184
+
185
+ A more flexible subscription form that wraps your own markup:
186
+
187
+ ```astro
188
+ ---
189
+ import SubscribeForm from "@seriphxyz/astro/SubscribeForm";
190
+ ---
191
+
192
+ <SubscribeForm siteKey={import.meta.env.SERIPH_SITE_KEY}>
193
+ <input name="email" type="email" placeholder="Email" required />
194
+ <button type="submit">Join newsletter</button>
195
+ </SubscribeForm>
196
+ ```
197
+
155
198
  ## JavaScript API
156
199
 
157
200
  For advanced use cases, use the JavaScript API directly:
package/dist/index.d.ts CHANGED
@@ -1,63 +1,8 @@
1
- export interface SeriphConfig {
2
- /** Your site key (required) */
3
- siteKey: string;
4
- /** @deprecated Use siteKey instead */
5
- apiKey?: string;
6
- /** Base URL of your Seriph instance (default: 'https://seriph.xyz') */
7
- endpoint?: string;
8
- }
9
- /** @deprecated Use SeriphConfig instead */
10
- export type SeraphConfig = SeriphConfig;
11
- export { seriphPostsLoader, seraphPostsLoader, fetchPosts, fetchPost, type SeriphPost, type SeraphPost, type SeriphPostsLoaderOptions, type SeraphPostsLoaderOptions, type FetchPostsOptions, type FetchPostOptions, } from "./loader.js";
12
- export interface FormSubmitOptions {
13
- onSuccess?: (data: unknown) => void;
14
- onError?: (error: Error) => void;
15
- }
16
- export interface Comment {
17
- id: string;
18
- pageId: string;
19
- parentId?: string;
20
- authorName: string;
21
- content: string;
22
- createdAt: string;
23
- replies: Comment[];
24
- }
25
- export interface ReactionCounts {
26
- pageId: string;
27
- counts: Record<string, number>;
28
- }
29
- export interface SubmitFormOptions extends SeriphConfig {
30
- formSlug: string;
31
- data: Record<string, unknown>;
32
- /** Form load timestamp for spam detection (auto-set if not provided) */
33
- formLoadTime?: number;
34
- }
35
- export interface FormSubmitResponse {
36
- success: boolean;
37
- message: string;
38
- }
39
- export declare function submitForm(options: SubmitFormOptions): Promise<FormSubmitResponse>;
40
- export interface FetchCommentsOptions extends SeriphConfig {
41
- pageId: string;
42
- }
43
- export declare function fetchComments(options: FetchCommentsOptions): Promise<Comment[]>;
44
- export interface PostCommentOptions extends SeriphConfig {
45
- pageId: string;
46
- authorName: string;
47
- authorEmail?: string;
48
- content: string;
49
- parentId?: string;
50
- }
51
- export declare function postComment(options: PostCommentOptions): Promise<Comment>;
52
- export interface FetchReactionsOptions extends SeriphConfig {
53
- pageId: string;
54
- }
55
- export declare function fetchReactions(options: FetchReactionsOptions): Promise<ReactionCounts>;
56
- export interface AddReactionOptions extends SeriphConfig {
57
- pageId: string;
58
- reactionType?: string;
59
- }
60
- export declare function addReaction(options: AddReactionOptions): Promise<{
61
- reactionType: string;
62
- count: number;
63
- }>;
1
+ /**
2
+ * @seriphxyz/astro
3
+ *
4
+ * Astro components and content loader for Seriph widgets.
5
+ * Re-exports all types, API functions, and controllers from @seriphxyz/core.
6
+ */
7
+ export { DEFAULT_ENDPOINT, API_PATH, VISITOR_STORAGE_KEY, type SeriphConfig, type Comment, type ReactionCounts, type FormSubmitResponse, type SubscribeResponse, type SeriphPost, buildUrl, getSiteKey, getVisitorId, setVisitorId, type SubmitFormOptions, submitForm, type FetchCommentsOptions, fetchComments, type PostCommentOptions, postComment, type FetchReactionsOptions, type FetchReactionsResponse, fetchReactions, type AddReactionOptions, addReaction, type RemoveReactionOptions, removeReaction, type SubscribeOptions, subscribe, type JoinWaitlistOptions, type JoinWaitlistResponse, joinWaitlist, type ViewCountsOptions, type ViewCounts, type RecordViewResponse, getViewCounts, recordView, type FetchPostsOptions, fetchPosts, type FetchPostOptions, fetchPost, type ControllerStatus, type ControllerListener, type SubscribeState, type FormState, type ReactionsState, type CommentsState, type WaitlistState, SubscribeController, WaitlistController, FormController, ReactionsController, CommentsController, } from "@seriphxyz/core";
8
+ export { seriphPostsLoader, type SeriphPostsLoaderOptions, } from "./loader.js";
package/dist/index.js CHANGED
@@ -1,111 +1,14 @@
1
- const DEFAULT_ENDPOINT = "https://seriph.xyz";
2
- const API_PATH = "/api/v1";
3
- // Re-export loader
4
- export { seriphPostsLoader, seraphPostsLoader, fetchPosts, fetchPost, } from "./loader.js";
5
- // Helper to build API URL
6
- function buildUrl(endpoint, path) {
7
- const base = (endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, "");
8
- return `${base}${API_PATH}${path}`;
9
- }
10
- // Helper to get site key (supports both siteKey and deprecated apiKey)
11
- function getSiteKey(config) {
12
- const key = config.siteKey || config.apiKey;
13
- if (!key) {
14
- throw new Error("siteKey is required");
15
- }
16
- return key;
17
- }
18
- // Client utilities
19
- export async function submitForm(options) {
20
- const { endpoint, formSlug, data, formLoadTime } = options;
21
- const siteKey = getSiteKey(options);
22
- const url = buildUrl(endpoint, `/forms/${formSlug}/submit`);
23
- // Include timestamp for time-based spam detection
24
- const payload = {
25
- ...data,
26
- _seriph_ts: formLoadTime || Math.floor(Date.now() / 1000),
27
- };
28
- const response = await fetch(url, {
29
- method: "POST",
30
- headers: {
31
- "Content-Type": "application/json",
32
- "X-Seriph-Key": siteKey,
33
- },
34
- body: JSON.stringify(payload),
35
- });
36
- if (!response.ok) {
37
- throw new Error(`Form submission failed: ${response.statusText}`);
38
- }
39
- return response.json();
40
- }
41
- export async function fetchComments(options) {
42
- const { endpoint, pageId } = options;
43
- const siteKey = getSiteKey(options);
44
- const url = buildUrl(endpoint, `/comments/${encodeURIComponent(pageId)}`);
45
- const response = await fetch(url, {
46
- headers: {
47
- "X-Seriph-Key": siteKey,
48
- },
49
- });
50
- if (!response.ok) {
51
- throw new Error(`Failed to fetch comments: ${response.statusText}`);
52
- }
53
- const data = await response.json();
54
- return data.data || [];
55
- }
56
- export async function postComment(options) {
57
- const { endpoint, pageId, ...rest } = options;
58
- const siteKey = getSiteKey(options);
59
- const url = buildUrl(endpoint, `/comments/${encodeURIComponent(pageId)}`);
60
- const response = await fetch(url, {
61
- method: "POST",
62
- headers: {
63
- "Content-Type": "application/json",
64
- "X-Seriph-Key": siteKey,
65
- },
66
- body: JSON.stringify({
67
- authorName: rest.authorName,
68
- authorEmail: rest.authorEmail,
69
- content: rest.content,
70
- parentId: rest.parentId,
71
- }),
72
- });
73
- if (!response.ok) {
74
- throw new Error(`Failed to post comment: ${response.statusText}`);
75
- }
76
- const data = await response.json();
77
- return data.data;
78
- }
79
- export async function fetchReactions(options) {
80
- const { endpoint, pageId } = options;
81
- const siteKey = getSiteKey(options);
82
- const url = buildUrl(endpoint, `/reactions/${encodeURIComponent(pageId)}`);
83
- const response = await fetch(url, {
84
- headers: {
85
- "X-Seriph-Key": siteKey,
86
- },
87
- });
88
- if (!response.ok) {
89
- throw new Error(`Failed to fetch reactions: ${response.statusText}`);
90
- }
91
- const data = await response.json();
92
- return data.data;
93
- }
94
- export async function addReaction(options) {
95
- const { endpoint, pageId, reactionType = "like" } = options;
96
- const siteKey = getSiteKey(options);
97
- const url = buildUrl(endpoint, `/reactions/${encodeURIComponent(pageId)}`);
98
- const response = await fetch(url, {
99
- method: "POST",
100
- headers: {
101
- "Content-Type": "application/json",
102
- "X-Seriph-Key": siteKey,
103
- },
104
- body: JSON.stringify({ reactionType }),
105
- });
106
- if (!response.ok) {
107
- throw new Error(`Failed to add reaction: ${response.statusText}`);
108
- }
109
- const data = await response.json();
110
- return data.data;
111
- }
1
+ /**
2
+ * @seriphxyz/astro
3
+ *
4
+ * Astro components and content loader for Seriph widgets.
5
+ * Re-exports all types, API functions, and controllers from @seriphxyz/core.
6
+ */
7
+ // Re-export everything from core
8
+ export {
9
+ // Constants
10
+ DEFAULT_ENDPOINT, API_PATH, VISITOR_STORAGE_KEY,
11
+ // Helpers
12
+ buildUrl, getSiteKey, getVisitorId, setVisitorId, submitForm, fetchComments, postComment, fetchReactions, addReaction, removeReaction, subscribe, joinWaitlist, getViewCounts, recordView, fetchPosts, fetchPost, SubscribeController, WaitlistController, FormController, ReactionsController, CommentsController, } from "@seriphxyz/core";
13
+ // Re-export loader (Astro-specific)
14
+ export { seriphPostsLoader, } from "./loader.js";
package/dist/loader.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * @example
7
7
  * // In src/content.config.ts
8
8
  * import { defineCollection } from 'astro:content';
9
- * import { seriphPostsLoader } from 'seriph-astro/loader';
9
+ * import { seriphPostsLoader } from '@seriphxyz/astro/loader';
10
10
  *
11
11
  * const posts = defineCollection({
12
12
  * loader: seriphPostsLoader({
@@ -16,25 +16,12 @@
16
16
  *
17
17
  * export const collections = { posts };
18
18
  */
19
- export interface SeriphPost {
20
- id: string;
21
- title: string;
22
- slug: string;
23
- content: string;
24
- excerpt?: string;
25
- coverImage?: string;
26
- metaTitle?: string;
27
- metaDescription?: string;
28
- tags: string[];
29
- publishedAt: string;
30
- }
31
- /** @deprecated Use SeriphPost instead */
32
- export type SeraphPost = SeriphPost;
19
+ import { fetchPosts as coreFetchPosts, fetchPost as coreFetchPost, type SeriphPost, type FetchPostsOptions, type FetchPostOptions } from "@seriphxyz/core";
20
+ export type { SeriphPost, FetchPostsOptions, FetchPostOptions };
21
+ export { coreFetchPosts as fetchPosts, coreFetchPost as fetchPost };
33
22
  export interface SeriphPostsLoaderOptions {
34
23
  /** Your site key (required) */
35
- siteKey?: string;
36
- /** @deprecated Use siteKey instead */
37
- apiKey?: string;
24
+ siteKey: string;
38
25
  /** Base URL of your Seriph instance (default: 'https://seriph.xyz') */
39
26
  endpoint?: string;
40
27
  /** Filter posts by tag */
@@ -44,8 +31,6 @@ export interface SeriphPostsLoaderOptions {
44
31
  /** How to handle errors: 'throw' (default), 'warn', or 'ignore' */
45
32
  onError?: "throw" | "warn" | "ignore";
46
33
  }
47
- /** @deprecated Use SeriphPostsLoaderOptions instead */
48
- export type SeraphPostsLoaderOptions = SeriphPostsLoaderOptions;
49
34
  interface LoaderContext {
50
35
  store: {
51
36
  set: (entry: {
@@ -70,36 +55,3 @@ export declare function seriphPostsLoader(options: SeriphPostsLoaderOptions): {
70
55
  name: string;
71
56
  load(context: LoaderContext): Promise<void>;
72
57
  };
73
- /** @deprecated Use seriphPostsLoader instead (note the 'i' in seriph) */
74
- export declare const seraphPostsLoader: typeof seriphPostsLoader;
75
- export interface FetchPostsOptions {
76
- /** Your site key (required) */
77
- siteKey?: string;
78
- /** @deprecated Use siteKey instead */
79
- apiKey?: string;
80
- /** Base URL of your Seriph instance (default: 'https://seriph.xyz') */
81
- endpoint?: string;
82
- /** Filter posts by tag */
83
- tag?: string;
84
- /** Maximum number of posts to fetch (default: 500) */
85
- limit?: number;
86
- }
87
- /**
88
- * Utility function to fetch posts directly (for server-side use cases)
89
- */
90
- export declare function fetchPosts(options: FetchPostsOptions): Promise<SeriphPost[]>;
91
- export interface FetchPostOptions {
92
- /** Your site key (required) */
93
- siteKey?: string;
94
- /** @deprecated Use siteKey instead */
95
- apiKey?: string;
96
- /** Base URL of your Seriph instance (default: 'https://seriph.xyz') */
97
- endpoint?: string;
98
- /** The post slug to fetch */
99
- slug: string;
100
- }
101
- /**
102
- * Utility function to fetch a single post by slug
103
- */
104
- export declare function fetchPost(options: FetchPostOptions): Promise<SeriphPost | null>;
105
- export {};
package/dist/loader.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * @example
7
7
  * // In src/content.config.ts
8
8
  * import { defineCollection } from 'astro:content';
9
- * import { seriphPostsLoader } from 'seriph-astro/loader';
9
+ * import { seriphPostsLoader } from '@seriphxyz/astro/loader';
10
10
  *
11
11
  * const posts = defineCollection({
12
12
  * loader: seriphPostsLoader({
@@ -16,16 +16,8 @@
16
16
  *
17
17
  * export const collections = { posts };
18
18
  */
19
- const DEFAULT_ENDPOINT = "https://seriph.xyz";
20
- const API_PATH = "/api/v1";
21
- // Helper to get site key (supports both siteKey and deprecated apiKey)
22
- function getSiteKey(options) {
23
- const key = options.siteKey || options.apiKey;
24
- if (!key) {
25
- throw new Error("siteKey is required");
26
- }
27
- return key;
28
- }
19
+ import { DEFAULT_ENDPOINT, API_PATH, getSiteKey, fetchPosts as coreFetchPosts, fetchPost as coreFetchPost, } from "@seriphxyz/core";
20
+ export { coreFetchPosts as fetchPosts, coreFetchPost as fetchPost };
29
21
  /**
30
22
  * Creates an Astro content loader that fetches posts from Seriph.
31
23
  *
@@ -33,15 +25,13 @@ function getSiteKey(options) {
33
25
  */
34
26
  export function seriphPostsLoader(options) {
35
27
  const { endpoint = DEFAULT_ENDPOINT, tag, limit = 500, onError = "throw", } = options;
36
- const siteKey = getSiteKey(options);
37
- // Build the base API URL (strip trailing slash, add API path)
28
+ const siteKey = getSiteKey({ siteKey: options.siteKey });
38
29
  const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
39
30
  return {
40
31
  name: "seriph-posts-loader",
41
32
  async load(context) {
42
33
  const { store, logger } = context;
43
34
  try {
44
- // Build the URL with query parameters
45
35
  const url = new URL(`${baseUrl}/posts`);
46
36
  url.searchParams.set("limit", String(limit));
47
37
  if (tag) {
@@ -57,24 +47,11 @@ export function seriphPostsLoader(options) {
57
47
  throw new Error(`Failed to fetch posts: ${response.status} ${response.statusText}`);
58
48
  }
59
49
  const data = await response.json();
60
- // Clear previous entries
61
50
  store.clear();
62
- // Add each post as an entry
63
51
  for (const post of data.posts) {
64
52
  store.set({
65
53
  id: post.slug,
66
- data: {
67
- id: post.id,
68
- title: post.title,
69
- slug: post.slug,
70
- content: post.content,
71
- excerpt: post.excerpt,
72
- coverImage: post.coverImage,
73
- metaTitle: post.metaTitle,
74
- metaDescription: post.metaDescription,
75
- tags: post.tags,
76
- publishedAt: post.publishedAt,
77
- },
54
+ data: post,
78
55
  });
79
56
  }
80
57
  logger.info(`Loaded ${data.posts.length} posts from Seriph`);
@@ -88,54 +65,7 @@ export function seriphPostsLoader(options) {
88
65
  else if (onError === "warn") {
89
66
  logger.warn(`Error loading posts (continuing anyway): ${message}`);
90
67
  }
91
- // onError === "ignore" - silently continue
92
68
  }
93
69
  },
94
70
  };
95
71
  }
96
- /** @deprecated Use seriphPostsLoader instead (note the 'i' in seriph) */
97
- export const seraphPostsLoader = seriphPostsLoader;
98
- /**
99
- * Utility function to fetch posts directly (for server-side use cases)
100
- */
101
- export async function fetchPosts(options) {
102
- const { endpoint = DEFAULT_ENDPOINT, tag, limit = 500 } = options;
103
- const siteKey = getSiteKey(options);
104
- const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
105
- const url = new URL(`${baseUrl}/posts`);
106
- url.searchParams.set("limit", String(limit));
107
- if (tag) {
108
- url.searchParams.set("tag", tag);
109
- }
110
- const response = await fetch(url.toString(), {
111
- headers: {
112
- "X-Seriph-Key": siteKey,
113
- },
114
- });
115
- if (!response.ok) {
116
- throw new Error(`Failed to fetch posts: ${response.status} ${response.statusText}`);
117
- }
118
- const data = await response.json();
119
- return data.posts;
120
- }
121
- /**
122
- * Utility function to fetch a single post by slug
123
- */
124
- export async function fetchPost(options) {
125
- const { endpoint = DEFAULT_ENDPOINT, slug } = options;
126
- const siteKey = getSiteKey(options);
127
- const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
128
- const response = await fetch(`${baseUrl}/posts/${encodeURIComponent(slug)}`, {
129
- headers: {
130
- "X-Seriph-Key": siteKey,
131
- },
132
- });
133
- if (response.status === 404) {
134
- return null;
135
- }
136
- if (!response.ok) {
137
- throw new Error(`Failed to fetch post: ${response.status} ${response.statusText}`);
138
- }
139
- const data = await response.json();
140
- return data.public_post || data;
141
- }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@seriphxyz/astro",
3
- "version": "0.1.2",
3
+ "version": "0.1.7",
4
4
  "description": "Astro components and content loader for Seriph widgets (forms, comments, reactions, posts)",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "https://github.com/seriphxyz/astro"
7
+ "url": "git+https://github.com/seriphxyz/astro.git"
8
8
  },
9
9
  "homepage": "https://seriph.xyz",
10
10
  "author": "Tim Shedor",
@@ -20,16 +20,14 @@
20
20
  },
21
21
  "./Form": "./src/Form.astro",
22
22
  "./Comments": "./src/Comments.astro",
23
- "./Reactions": "./src/Reactions.astro"
23
+ "./Reactions": "./src/Reactions.astro",
24
+ "./Subscribe": "./src/Subscribe.astro",
25
+ "./SubscribeForm": "./src/SubscribeForm.astro"
24
26
  },
25
27
  "files": [
26
28
  "src",
27
29
  "dist"
28
30
  ],
29
- "scripts": {
30
- "build": "tsc",
31
- "dev": "tsc --watch"
32
- },
33
31
  "keywords": [
34
32
  "astro",
35
33
  "seriph",
@@ -38,13 +36,21 @@
38
36
  "reactions",
39
37
  "posts",
40
38
  "widgets",
41
- "content-loader"
39
+ "content-loader",
40
+ "subscribe"
42
41
  ],
43
42
  "license": "MIT",
43
+ "dependencies": {
44
+ "@seriphxyz/core": "0.1.7"
45
+ },
44
46
  "peerDependencies": {
45
47
  "astro": "^5.0.0"
46
48
  },
47
49
  "devDependencies": {
48
50
  "typescript": "^5.7.3"
51
+ },
52
+ "scripts": {
53
+ "build": "tsc",
54
+ "dev": "tsc --watch"
49
55
  }
50
- }
56
+ }
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Seriph Comments Component
4
4
  *
5
- * Displays threaded comments with a form to post new comments.
5
+ * Displays threaded comments with a form to post new comments and replies.
6
6
  * Customize with CSS custom properties.
7
7
  *
8
8
  * @example
@@ -47,6 +47,11 @@ const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
47
47
  <div class="seriph-comments-list"></div>
48
48
 
49
49
  <form class="seriph-comments-form">
50
+ <div class="seriph-reply-indicator" style="display: none;">
51
+ <span>Replying to <strong class="seriph-reply-to-name"></strong></span>
52
+ <button type="button" class="seriph-cancel-reply">Cancel</button>
53
+ </div>
54
+ <input type="hidden" name="parentId" value="" />
50
55
  <slot name="form">
51
56
  <div class="seriph-form-group">
52
57
  <label for="seriph-author-name">Name</label>
@@ -76,6 +81,7 @@ const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
76
81
  <span class="seriph-comment-date"></span>
77
82
  </div>
78
83
  <div class="seriph-comment-content"></div>
84
+ <button type="button" class="seriph-reply-btn">Reply</button>
79
85
  <div class="seriph-comment-replies"></div>
80
86
  </div>
81
87
  </template>
@@ -98,14 +104,28 @@ const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
98
104
  const siteKey = (container as HTMLElement).dataset.siteKey;
99
105
  const pageId = (container as HTMLElement).dataset.pageId;
100
106
  const list = container.querySelector(".seriph-comments-list");
101
- const form = container.querySelector(".seriph-comments-form");
107
+ const form = container.querySelector(".seriph-comments-form") as HTMLFormElement;
102
108
  const template = container.querySelector("#seriph-comment-template") as HTMLTemplateElement;
109
+ const replyIndicator = container.querySelector(".seriph-reply-indicator") as HTMLElement;
110
+ const replyToName = container.querySelector(".seriph-reply-to-name") as HTMLElement;
111
+ const cancelReplyBtn = container.querySelector(".seriph-cancel-reply") as HTMLButtonElement;
112
+ const parentIdInput = form?.querySelector('input[name="parentId"]') as HTMLInputElement;
103
113
 
104
114
  if (!endpoint || !siteKey || !pageId || !list || !form || !template) return;
105
115
 
106
116
  // Record when form was loaded (for time-based spam detection)
107
117
  formTimestamps.set(form, Math.floor(Date.now() / 1000));
108
118
 
119
+ // Cancel reply mode
120
+ function cancelReply() {
121
+ if (parentIdInput) parentIdInput.value = "";
122
+ if (replyIndicator) replyIndicator.style.display = "none";
123
+ const submitBtn = form.querySelector('[type="submit"]') as HTMLButtonElement;
124
+ if (submitBtn) submitBtn.textContent = "Post Comment";
125
+ }
126
+
127
+ cancelReplyBtn?.addEventListener("click", cancelReply);
128
+
109
129
  async function loadComments() {
110
130
  try {
111
131
  const response = await fetch(
@@ -144,6 +164,20 @@ const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
144
164
  ).toLocaleDateString();
145
165
  el.querySelector(".seriph-comment-content")!.textContent = comment.content;
146
166
 
167
+ // Add reply button handler
168
+ const replyBtn = el.querySelector(".seriph-reply-btn") as HTMLButtonElement;
169
+ replyBtn?.addEventListener("click", () => {
170
+ if (parentIdInput) parentIdInput.value = comment.id;
171
+ if (replyToName) replyToName.textContent = comment.authorName;
172
+ if (replyIndicator) replyIndicator.style.display = "flex";
173
+ const submitBtn = form.querySelector('[type="submit"]') as HTMLButtonElement;
174
+ if (submitBtn) submitBtn.textContent = "Post Reply";
175
+ // Scroll form into view and focus
176
+ form.scrollIntoView({ behavior: "smooth", block: "center" });
177
+ const contentField = form.querySelector('textarea[name="content"]') as HTMLTextAreaElement;
178
+ setTimeout(() => contentField?.focus(), 300);
179
+ });
180
+
147
181
  const repliesContainer = el.querySelector(".seriph-comment-replies")!;
148
182
  comment.replies?.forEach((reply) => renderComment(reply, repliesContainer));
149
183
 
@@ -164,6 +198,9 @@ const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
164
198
  // Get load timestamp for time-based spam detection
165
199
  const loadTimestamp = formTimestamps.get(formEl);
166
200
 
201
+ // Get parent ID if replying
202
+ const parentId = formData.get("parentId") as string | null;
203
+
167
204
  try {
168
205
  const response = await fetch(
169
206
  `${endpoint}/comments/${encodeURIComponent(pageId)}`,
@@ -177,6 +214,7 @@ const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
177
214
  authorName: formData.get("authorName"),
178
215
  authorEmail: formData.get("authorEmail") || undefined,
179
216
  content: formData.get("content"),
217
+ parentId: parentId ? parseInt(parentId, 10) : undefined,
180
218
  _gotcha: honeypot || undefined,
181
219
  _seriph_ts: loadTimestamp,
182
220
  }),
@@ -186,6 +224,7 @@ const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
186
224
  if (!response.ok) throw new Error("Failed to post comment");
187
225
 
188
226
  formEl.reset();
227
+ cancelReply(); // Reset reply mode
189
228
  container.dispatchEvent(
190
229
  new CustomEvent("seriph:comment-posted", {
191
230
  detail: await response.json(),
@@ -278,12 +317,40 @@ const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
278
317
  grid-template-columns: 1fr 1fr;
279
318
  }
280
319
 
281
- .seriph-comments-form .seriph-form-group:nth-child(3),
320
+ .seriph-comments-form .seriph-form-group:nth-child(4),
321
+ .seriph-comments-form .seriph-reply-indicator,
282
322
  .seriph-comments-form button[type="submit"] {
283
323
  grid-column: 1 / -1;
284
324
  }
285
325
  }
286
326
 
327
+ .seriph-reply-indicator {
328
+ display: flex;
329
+ align-items: center;
330
+ gap: 0.5rem;
331
+ padding: 0.5rem 0.75rem;
332
+ background: var(--seriph-notice-bg);
333
+ color: var(--seriph-notice-text);
334
+ border-radius: 0.25rem;
335
+ font-size: 0.8125rem;
336
+ grid-column: 1 / -1;
337
+ }
338
+
339
+ .seriph-cancel-reply {
340
+ margin-left: auto;
341
+ padding: 0.125rem 0.5rem;
342
+ background: transparent;
343
+ border: 1px solid currentColor;
344
+ border-radius: 0.25rem;
345
+ color: inherit;
346
+ font-size: 0.75rem;
347
+ cursor: pointer;
348
+ }
349
+
350
+ .seriph-cancel-reply:hover {
351
+ background: rgba(0, 0, 0, 0.1);
352
+ }
353
+
287
354
  .seriph-form-group {
288
355
  margin-bottom: 0;
289
356
  }
@@ -368,6 +435,21 @@ const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
368
435
  font-size: 0.875rem;
369
436
  }
370
437
 
438
+ .seriph-reply-btn {
439
+ margin-top: 0.5rem;
440
+ padding: 0.125rem 0.5rem;
441
+ background: transparent;
442
+ border: none;
443
+ color: var(--seriph-text-muted);
444
+ font-size: 0.75rem;
445
+ cursor: pointer;
446
+ }
447
+
448
+ .seriph-reply-btn:hover {
449
+ color: var(--seriph-focus-color);
450
+ text-decoration: underline;
451
+ }
452
+
371
453
  .seriph-comment-replies {
372
454
  margin-left: 1rem;
373
455
  margin-top: 0.5rem;