@seriphxyz/astro 0.1.2

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.
@@ -0,0 +1,290 @@
1
+ ---
2
+ /**
3
+ * Seriph Reactions Component
4
+ *
5
+ * Displays reaction buttons (like, clap, etc.) for a page.
6
+ * Customize with CSS custom properties.
7
+ *
8
+ * @example
9
+ * <Reactions
10
+ * siteKey={import.meta.env.SERIPH_SITE_KEY}
11
+ * pageId={Astro.url.pathname}
12
+ * reactions={['like', 'love', 'clap']}
13
+ * />
14
+ */
15
+
16
+ interface Props {
17
+ /** Your site key (required) */
18
+ siteKey: string;
19
+ /** Unique identifier for this page (e.g., slug or URL path) */
20
+ pageId: string;
21
+ /** Base URL of your Seriph instance (default: 'https://seriph.xyz') */
22
+ endpoint?: string;
23
+ /** Reaction types to show (default: ['like']) */
24
+ reactions?: string[];
25
+ /** Custom icons for reaction types (emoji or text) */
26
+ icons?: Record<string, string>;
27
+ /** Theme preset: 'light' (default), 'dark', or 'auto' (uses prefers-color-scheme) */
28
+ theme?: "light" | "dark" | "auto";
29
+ /** CSS class to add to the container */
30
+ class?: string;
31
+ }
32
+
33
+ const DEFAULT_ENDPOINT = "https://seriph.xyz";
34
+ const API_PATH = "/api/v1";
35
+
36
+ const defaultIcons: Record<string, string> = {
37
+ like: "\u{1F44D}",
38
+ love: "\u{2764}\u{FE0F}",
39
+ clap: "\u{1F44F}",
40
+ fire: "\u{1F525}",
41
+ think: "\u{1F914}",
42
+ sad: "\u{1F622}",
43
+ laugh: "\u{1F602}",
44
+ };
45
+
46
+ const {
47
+ siteKey,
48
+ pageId,
49
+ endpoint = DEFAULT_ENDPOINT,
50
+ reactions = ["like"],
51
+ icons = {},
52
+ theme = "light",
53
+ class: className = "",
54
+ } = Astro.props;
55
+
56
+ // Merge custom icons with defaults
57
+ const mergedIcons = { ...defaultIcons, ...icons };
58
+
59
+ function getIcon(type: string): string {
60
+ return mergedIcons[type] || type;
61
+ }
62
+
63
+ // Build the API URL
64
+ const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
65
+ ---
66
+
67
+ <div
68
+ class:list={["seriph-reactions", `seriph-theme-${theme}`, className]}
69
+ data-seriph-reactions
70
+ data-endpoint={baseUrl}
71
+ data-site-key={siteKey}
72
+ data-page-id={pageId}
73
+ >
74
+ {
75
+ reactions.map((reaction) => (
76
+ <button
77
+ type="button"
78
+ class="seriph-reaction-btn"
79
+ data-reaction-type={reaction}
80
+ title={reaction}
81
+ >
82
+ <span class="seriph-reaction-icon">{getIcon(reaction)}</span>
83
+ <span class="seriph-reaction-count">0</span>
84
+ </button>
85
+ ))
86
+ }
87
+ </div>
88
+
89
+ <script>
90
+ document.querySelectorAll("[data-seriph-reactions]").forEach((container) => {
91
+ const endpoint = (container as HTMLElement).dataset.endpoint;
92
+ const siteKey = (container as HTMLElement).dataset.siteKey;
93
+ const pageId = (container as HTMLElement).dataset.pageId;
94
+
95
+ if (!endpoint || !siteKey || !pageId) return;
96
+
97
+ // Track which reactions the user has made (stored in localStorage)
98
+ const storageKey = `seriph-reactions-${pageId}`;
99
+ const userReactions = new Set<string>(
100
+ JSON.parse(localStorage.getItem(storageKey) || "[]")
101
+ );
102
+
103
+ // Apply initial "reacted" state
104
+ container.querySelectorAll(".seriph-reaction-btn").forEach((btn) => {
105
+ const type = (btn as HTMLElement).dataset.reactionType;
106
+ if (type && userReactions.has(type)) {
107
+ btn.classList.add("seriph-reacted");
108
+ }
109
+ });
110
+
111
+ async function loadReactions() {
112
+ try {
113
+ const response = await fetch(
114
+ `${endpoint}/reactions/${encodeURIComponent(pageId!)}`,
115
+ {
116
+ headers: { "X-Seriph-Key": siteKey! },
117
+ },
118
+ );
119
+ if (!response.ok) return;
120
+ const data = await response.json();
121
+ const counts = data.reaction_counts?.counts || {};
122
+
123
+ container.querySelectorAll(".seriph-reaction-btn").forEach((btn) => {
124
+ const type = (btn as HTMLElement).dataset.reactionType;
125
+ const countEl = btn.querySelector(".seriph-reaction-count");
126
+ if (type && countEl) {
127
+ countEl.textContent = String(counts[type] || 0);
128
+ }
129
+ });
130
+ } catch (e) {
131
+ console.error("Failed to load reactions:", e);
132
+ }
133
+ }
134
+
135
+ container.querySelectorAll(".seriph-reaction-btn").forEach((btn) => {
136
+ btn.addEventListener("click", async () => {
137
+ const type = (btn as HTMLElement).dataset.reactionType;
138
+ if (!type) return;
139
+
140
+ const isReacted = userReactions.has(type);
141
+ btn.classList.add("seriph-loading");
142
+
143
+ try {
144
+ const response = await fetch(
145
+ `${endpoint}/reactions/${encodeURIComponent(pageId)}`,
146
+ {
147
+ method: isReacted ? "DELETE" : "POST",
148
+ headers: {
149
+ "Content-Type": "application/json",
150
+ "X-Seriph-Key": siteKey!,
151
+ },
152
+ body: JSON.stringify({ reactionType: type }),
153
+ },
154
+ );
155
+
156
+ if (!response.ok) throw new Error(isReacted ? "Failed to remove reaction" : "Failed to add reaction");
157
+
158
+ const data = await response.json();
159
+ const countEl = btn.querySelector(".seriph-reaction-count");
160
+ if (countEl) {
161
+ countEl.textContent = String(data.reaction?.count || 0);
162
+ }
163
+
164
+ if (isReacted) {
165
+ // Remove reaction
166
+ btn.classList.remove("seriph-reacted");
167
+ userReactions.delete(type);
168
+ container.dispatchEvent(
169
+ new CustomEvent("seriph:reaction-removed", {
170
+ detail: data.reaction,
171
+ }),
172
+ );
173
+ } else {
174
+ // Add reaction
175
+ btn.classList.add("seriph-reacted");
176
+ userReactions.add(type);
177
+ container.dispatchEvent(
178
+ new CustomEvent("seriph:reaction-added", {
179
+ detail: data.reaction,
180
+ }),
181
+ );
182
+ }
183
+ localStorage.setItem(storageKey, JSON.stringify([...userReactions]));
184
+ } catch (e) {
185
+ console.error(isReacted ? "Failed to remove reaction:" : "Failed to add reaction:", e);
186
+ } finally {
187
+ btn.classList.remove("seriph-loading");
188
+ }
189
+ });
190
+ });
191
+
192
+ loadReactions();
193
+ });
194
+ </script>
195
+
196
+ <style is:global>
197
+ @layer seriph {
198
+ .seriph-reactions {
199
+ --seriph-border-color: #e5e7eb;
200
+ --seriph-button-bg: white;
201
+ --seriph-hover-bg: #f3f4f6;
202
+ --seriph-hover-border: #d1d5db;
203
+ --seriph-active-bg: #dbeafe;
204
+ --seriph-active-border: #93c5fd;
205
+ --seriph-active-hover-bg: #bfdbfe;
206
+ --seriph-count-color: #6b7280;
207
+ --seriph-active-text: #1d4ed8;
208
+
209
+ display: flex;
210
+ gap: 0.5rem;
211
+ flex-wrap: wrap;
212
+ }
213
+
214
+ /* Dark theme */
215
+ .seriph-reactions.seriph-theme-dark {
216
+ --seriph-border-color: #374151;
217
+ --seriph-button-bg: transparent;
218
+ --seriph-hover-bg: #374151;
219
+ --seriph-hover-border: #4b5563;
220
+ --seriph-active-bg: rgba(59, 130, 246, 0.2);
221
+ --seriph-active-border: #3b82f6;
222
+ --seriph-active-hover-bg: rgba(59, 130, 246, 0.3);
223
+ --seriph-count-color: #9ca3af;
224
+ --seriph-active-text: #60a5fa;
225
+ }
226
+
227
+ /* Auto theme - follows prefers-color-scheme */
228
+ @media (prefers-color-scheme: dark) {
229
+ .seriph-reactions.seriph-theme-auto {
230
+ --seriph-border-color: #374151;
231
+ --seriph-button-bg: transparent;
232
+ --seriph-hover-bg: #374151;
233
+ --seriph-hover-border: #4b5563;
234
+ --seriph-active-bg: rgba(59, 130, 246, 0.2);
235
+ --seriph-active-border: #3b82f6;
236
+ --seriph-active-hover-bg: rgba(59, 130, 246, 0.3);
237
+ --seriph-count-color: #9ca3af;
238
+ --seriph-active-text: #60a5fa;
239
+ }
240
+ }
241
+
242
+ .seriph-reaction-btn {
243
+ display: inline-flex;
244
+ align-items: center;
245
+ gap: 0.375rem;
246
+ padding: 0.5rem 0.75rem;
247
+ border: 1px solid var(--seriph-border-color);
248
+ border-radius: 9999px;
249
+ background: var(--seriph-button-bg);
250
+ cursor: pointer;
251
+ transition: all 0.15s ease;
252
+ font-family: inherit;
253
+ font-size: 0.875rem;
254
+ }
255
+
256
+ .seriph-reaction-btn:hover:not(.seriph-reacted) {
257
+ background: var(--seriph-hover-bg);
258
+ border-color: var(--seriph-hover-border);
259
+ }
260
+
261
+ .seriph-reaction-btn.seriph-reacted {
262
+ background: var(--seriph-active-bg);
263
+ border-color: var(--seriph-active-border);
264
+ }
265
+
266
+ .seriph-reaction-btn.seriph-reacted:hover {
267
+ background: var(--seriph-active-hover-bg);
268
+ }
269
+
270
+ .seriph-reaction-btn.seriph-loading {
271
+ opacity: 0.6;
272
+ cursor: wait;
273
+ }
274
+
275
+ .seriph-reaction-icon {
276
+ font-size: 1.125rem;
277
+ line-height: 1;
278
+ }
279
+
280
+ .seriph-reaction-count {
281
+ color: var(--seriph-count-color);
282
+ font-weight: 500;
283
+ min-width: 1ch;
284
+ }
285
+
286
+ .seriph-reacted .seriph-reaction-count {
287
+ color: var(--seriph-active-text);
288
+ }
289
+ }
290
+ </style>
package/src/index.ts ADDED
@@ -0,0 +1,212 @@
1
+ const DEFAULT_ENDPOINT = "https://seriph.xyz";
2
+ const API_PATH = "/api/v1";
3
+
4
+ // Types
5
+ export interface SeriphConfig {
6
+ /** Your site key (required) */
7
+ siteKey: string;
8
+ /** @deprecated Use siteKey instead */
9
+ apiKey?: string;
10
+ /** Base URL of your Seriph instance (default: 'https://seriph.xyz') */
11
+ endpoint?: string;
12
+ }
13
+
14
+ /** @deprecated Use SeriphConfig instead */
15
+ export type SeraphConfig = SeriphConfig;
16
+
17
+ // Re-export loader
18
+ export {
19
+ seriphPostsLoader,
20
+ seraphPostsLoader,
21
+ fetchPosts,
22
+ fetchPost,
23
+ type SeriphPost,
24
+ type SeraphPost,
25
+ type SeriphPostsLoaderOptions,
26
+ type SeraphPostsLoaderOptions,
27
+ type FetchPostsOptions,
28
+ type FetchPostOptions,
29
+ } from "./loader.js";
30
+
31
+ export interface FormSubmitOptions {
32
+ onSuccess?: (data: unknown) => void;
33
+ onError?: (error: Error) => void;
34
+ }
35
+
36
+ export interface Comment {
37
+ id: string;
38
+ pageId: string;
39
+ parentId?: string;
40
+ authorName: string;
41
+ content: string;
42
+ createdAt: string;
43
+ replies: Comment[];
44
+ }
45
+
46
+ export interface ReactionCounts {
47
+ pageId: string;
48
+ counts: Record<string, number>;
49
+ }
50
+
51
+ // Helper to build API URL
52
+ function buildUrl(endpoint: string | undefined, path: string): string {
53
+ const base = (endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, "");
54
+ return `${base}${API_PATH}${path}`;
55
+ }
56
+
57
+ // Helper to get site key (supports both siteKey and deprecated apiKey)
58
+ function getSiteKey(config: SeriphConfig): string {
59
+ const key = config.siteKey || config.apiKey;
60
+ if (!key) {
61
+ throw new Error("siteKey is required");
62
+ }
63
+ return key;
64
+ }
65
+
66
+ export interface SubmitFormOptions extends SeriphConfig {
67
+ formSlug: string;
68
+ data: Record<string, unknown>;
69
+ /** Form load timestamp for spam detection (auto-set if not provided) */
70
+ formLoadTime?: number;
71
+ }
72
+
73
+ export interface FormSubmitResponse {
74
+ success: boolean;
75
+ message: string;
76
+ }
77
+
78
+ // Client utilities
79
+ export async function submitForm(options: SubmitFormOptions): Promise<FormSubmitResponse> {
80
+ const { endpoint, formSlug, data, formLoadTime } = options;
81
+ const siteKey = getSiteKey(options);
82
+ const url = buildUrl(endpoint, `/forms/${formSlug}/submit`);
83
+
84
+ // Include timestamp for time-based spam detection
85
+ const payload = {
86
+ ...data,
87
+ _seriph_ts: formLoadTime || Math.floor(Date.now() / 1000),
88
+ };
89
+
90
+ const response = await fetch(url, {
91
+ method: "POST",
92
+ headers: {
93
+ "Content-Type": "application/json",
94
+ "X-Seriph-Key": siteKey,
95
+ },
96
+ body: JSON.stringify(payload),
97
+ });
98
+
99
+ if (!response.ok) {
100
+ throw new Error(`Form submission failed: ${response.statusText}`);
101
+ }
102
+
103
+ return response.json();
104
+ }
105
+
106
+ export interface FetchCommentsOptions extends SeriphConfig {
107
+ pageId: string;
108
+ }
109
+
110
+ export async function fetchComments(options: FetchCommentsOptions): Promise<Comment[]> {
111
+ const { endpoint, pageId } = options;
112
+ const siteKey = getSiteKey(options);
113
+ const url = buildUrl(endpoint, `/comments/${encodeURIComponent(pageId)}`);
114
+
115
+ const response = await fetch(url, {
116
+ headers: {
117
+ "X-Seriph-Key": siteKey,
118
+ },
119
+ });
120
+
121
+ if (!response.ok) {
122
+ throw new Error(`Failed to fetch comments: ${response.statusText}`);
123
+ }
124
+
125
+ const data = await response.json();
126
+ return data.data || [];
127
+ }
128
+
129
+ export interface PostCommentOptions extends SeriphConfig {
130
+ pageId: string;
131
+ authorName: string;
132
+ authorEmail?: string;
133
+ content: string;
134
+ parentId?: string;
135
+ }
136
+
137
+ export async function postComment(options: PostCommentOptions): Promise<Comment> {
138
+ const { endpoint, pageId, ...rest } = options;
139
+ const siteKey = getSiteKey(options);
140
+ const url = buildUrl(endpoint, `/comments/${encodeURIComponent(pageId)}`);
141
+
142
+ const response = await fetch(url, {
143
+ method: "POST",
144
+ headers: {
145
+ "Content-Type": "application/json",
146
+ "X-Seriph-Key": siteKey,
147
+ },
148
+ body: JSON.stringify({
149
+ authorName: rest.authorName,
150
+ authorEmail: rest.authorEmail,
151
+ content: rest.content,
152
+ parentId: rest.parentId,
153
+ }),
154
+ });
155
+
156
+ if (!response.ok) {
157
+ throw new Error(`Failed to post comment: ${response.statusText}`);
158
+ }
159
+
160
+ const data = await response.json();
161
+ return data.data;
162
+ }
163
+
164
+ export interface FetchReactionsOptions extends SeriphConfig {
165
+ pageId: string;
166
+ }
167
+
168
+ export async function fetchReactions(options: FetchReactionsOptions): Promise<ReactionCounts> {
169
+ const { endpoint, pageId } = options;
170
+ const siteKey = getSiteKey(options);
171
+ const url = buildUrl(endpoint, `/reactions/${encodeURIComponent(pageId)}`);
172
+
173
+ const response = await fetch(url, {
174
+ headers: {
175
+ "X-Seriph-Key": siteKey,
176
+ },
177
+ });
178
+
179
+ if (!response.ok) {
180
+ throw new Error(`Failed to fetch reactions: ${response.statusText}`);
181
+ }
182
+
183
+ const data = await response.json();
184
+ return data.data;
185
+ }
186
+
187
+ export interface AddReactionOptions extends SeriphConfig {
188
+ pageId: string;
189
+ reactionType?: string;
190
+ }
191
+
192
+ export async function addReaction(options: AddReactionOptions): Promise<{ reactionType: string; count: number }> {
193
+ const { endpoint, pageId, reactionType = "like" } = options;
194
+ const siteKey = getSiteKey(options);
195
+ const url = buildUrl(endpoint, `/reactions/${encodeURIComponent(pageId)}`);
196
+
197
+ const response = await fetch(url, {
198
+ method: "POST",
199
+ headers: {
200
+ "Content-Type": "application/json",
201
+ "X-Seriph-Key": siteKey,
202
+ },
203
+ body: JSON.stringify({ reactionType }),
204
+ });
205
+
206
+ if (!response.ok) {
207
+ throw new Error(`Failed to add reaction: ${response.statusText}`);
208
+ }
209
+
210
+ const data = await response.json();
211
+ return data.data;
212
+ }