@jant/core 0.0.1
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/bin/jant.js +188 -0
- package/drizzle.config.ts +10 -0
- package/lingui.config.ts +16 -0
- package/package.json +116 -0
- package/src/app.tsx +377 -0
- package/src/assets/datastar.min.js +8 -0
- package/src/auth.ts +38 -0
- package/src/client.ts +6 -0
- package/src/db/index.ts +14 -0
- package/src/db/migrations/0000_solid_moon_knight.sql +118 -0
- package/src/db/migrations/0001_add_search_fts.sql +40 -0
- package/src/db/migrations/0002_collection_path.sql +2 -0
- package/src/db/migrations/0003_collection_path_nullable.sql +21 -0
- package/src/db/migrations/0004_media_uuid.sql +35 -0
- package/src/db/migrations/meta/0000_snapshot.json +784 -0
- package/src/db/migrations/meta/_journal.json +41 -0
- package/src/db/schema.ts +159 -0
- package/src/i18n/EXAMPLES.md +235 -0
- package/src/i18n/README.md +296 -0
- package/src/i18n/Trans.tsx +31 -0
- package/src/i18n/context.tsx +101 -0
- package/src/i18n/detect.ts +100 -0
- package/src/i18n/i18n.ts +62 -0
- package/src/i18n/index.ts +65 -0
- package/src/i18n/locales/en.po +875 -0
- package/src/i18n/locales/en.ts +1 -0
- package/src/i18n/locales/zh-Hans.po +875 -0
- package/src/i18n/locales/zh-Hans.ts +1 -0
- package/src/i18n/locales/zh-Hant.po +875 -0
- package/src/i18n/locales/zh-Hant.ts +1 -0
- package/src/i18n/locales.ts +14 -0
- package/src/i18n/middleware.ts +59 -0
- package/src/index.ts +42 -0
- package/src/lib/assets.ts +47 -0
- package/src/lib/constants.ts +67 -0
- package/src/lib/image.ts +107 -0
- package/src/lib/index.ts +9 -0
- package/src/lib/markdown.ts +93 -0
- package/src/lib/schemas.ts +92 -0
- package/src/lib/sqid.ts +79 -0
- package/src/lib/sse.ts +152 -0
- package/src/lib/time.ts +117 -0
- package/src/lib/url.ts +107 -0
- package/src/middleware/auth.ts +59 -0
- package/src/routes/api/posts.ts +127 -0
- package/src/routes/api/search.ts +53 -0
- package/src/routes/api/upload.ts +240 -0
- package/src/routes/dash/collections.tsx +341 -0
- package/src/routes/dash/index.tsx +89 -0
- package/src/routes/dash/media.tsx +551 -0
- package/src/routes/dash/pages.tsx +245 -0
- package/src/routes/dash/posts.tsx +202 -0
- package/src/routes/dash/redirects.tsx +155 -0
- package/src/routes/dash/settings.tsx +93 -0
- package/src/routes/feed/rss.ts +119 -0
- package/src/routes/feed/sitemap.ts +75 -0
- package/src/routes/pages/archive.tsx +223 -0
- package/src/routes/pages/collection.tsx +79 -0
- package/src/routes/pages/home.tsx +93 -0
- package/src/routes/pages/page.tsx +64 -0
- package/src/routes/pages/post.tsx +81 -0
- package/src/routes/pages/search.tsx +162 -0
- package/src/services/collection.ts +180 -0
- package/src/services/index.ts +40 -0
- package/src/services/media.ts +97 -0
- package/src/services/post.ts +279 -0
- package/src/services/redirect.ts +74 -0
- package/src/services/search.ts +117 -0
- package/src/services/settings.ts +76 -0
- package/src/theme/components/ActionButtons.tsx +98 -0
- package/src/theme/components/CrudPageHeader.tsx +48 -0
- package/src/theme/components/DangerZone.tsx +77 -0
- package/src/theme/components/EmptyState.tsx +56 -0
- package/src/theme/components/ListItemRow.tsx +24 -0
- package/src/theme/components/PageForm.tsx +114 -0
- package/src/theme/components/Pagination.tsx +196 -0
- package/src/theme/components/PostForm.tsx +122 -0
- package/src/theme/components/PostList.tsx +68 -0
- package/src/theme/components/ThreadView.tsx +118 -0
- package/src/theme/components/TypeBadge.tsx +28 -0
- package/src/theme/components/VisibilityBadge.tsx +33 -0
- package/src/theme/components/index.ts +12 -0
- package/src/theme/index.ts +24 -0
- package/src/theme/layouts/BaseLayout.tsx +49 -0
- package/src/theme/layouts/DashLayout.tsx +108 -0
- package/src/theme/layouts/index.ts +2 -0
- package/src/theme/styles/main.css +52 -0
- package/src/types.ts +222 -0
- package/static/assets/datastar.min.js +7 -0
- package/static/assets/image-processor.js +234 -0
- package/tsconfig.json +16 -0
- package/vite.config.ts +82 -0
- package/wrangler.toml +21 -0
package/src/lib/sse.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events (SSE) utilities for Datastar
|
|
3
|
+
*
|
|
4
|
+
* Provides helpers for streaming SSE responses that Datastar can consume.
|
|
5
|
+
* Datastar uses SSE for real-time UI updates without page reloads.
|
|
6
|
+
*
|
|
7
|
+
* @see https://data-star.dev/
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* app.post("/api/example", (c) => {
|
|
12
|
+
* return sse(c, async (stream) => {
|
|
13
|
+
* await stream.patchSignals({ loading: false });
|
|
14
|
+
* await stream.patchElements("#result", "<div>Done!</div>");
|
|
15
|
+
* });
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Context } from "hono";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Patch modes for DOM updates
|
|
24
|
+
*/
|
|
25
|
+
export type PatchMode = "morph" | "inner" | "outer" | "append" | "prepend" | "remove";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* SSE stream writer for Datastar events
|
|
29
|
+
*/
|
|
30
|
+
export interface SSEStream {
|
|
31
|
+
/**
|
|
32
|
+
* Update reactive signals on the client
|
|
33
|
+
*
|
|
34
|
+
* @param signals - Object containing signal values to update
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* await stream.patchSignals({ count: 42, loading: false });
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
patchSignals(signals: Record<string, unknown>): Promise<void>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Update DOM elements
|
|
45
|
+
*
|
|
46
|
+
* @param html - HTML content (must include element with id for targeting)
|
|
47
|
+
* @param options - Optional mode and selector
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* // Replace element with matching id (default: morph)
|
|
52
|
+
* await stream.patchElements('<div id="content">New content</div>');
|
|
53
|
+
*
|
|
54
|
+
* // Append to a container
|
|
55
|
+
* await stream.patchElements('<div>New item</div>', {
|
|
56
|
+
* mode: 'append',
|
|
57
|
+
* selector: '#list'
|
|
58
|
+
* });
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
patchElements(
|
|
62
|
+
html: string,
|
|
63
|
+
options?: { mode?: PatchMode; selector?: string }
|
|
64
|
+
): Promise<void>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Execute JavaScript on the client
|
|
68
|
+
*
|
|
69
|
+
* @param script - JavaScript code to execute
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```ts
|
|
73
|
+
* await stream.executeScript('console.log("Hello from server")');
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
executeScript(script: string): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create an SSE response for Datastar
|
|
81
|
+
*
|
|
82
|
+
* @param c - Hono context
|
|
83
|
+
* @param handler - Async function that writes to the SSE stream
|
|
84
|
+
* @returns Response with SSE content-type
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* app.post("/api/upload", (c) => {
|
|
89
|
+
* return sse(c, async (stream) => {
|
|
90
|
+
* // Process upload...
|
|
91
|
+
* await stream.patchSignals({ uploading: false });
|
|
92
|
+
* await stream.patchElements('<div id="new-item">...</div>', {
|
|
93
|
+
* mode: 'append',
|
|
94
|
+
* selector: '#items'
|
|
95
|
+
* });
|
|
96
|
+
* });
|
|
97
|
+
* });
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export function sse(
|
|
101
|
+
c: Context,
|
|
102
|
+
handler: (stream: SSEStream) => Promise<void>
|
|
103
|
+
): Response {
|
|
104
|
+
const encoder = new TextEncoder();
|
|
105
|
+
|
|
106
|
+
const stream = new ReadableStream({
|
|
107
|
+
async start(controller) {
|
|
108
|
+
const write = (data: string) => {
|
|
109
|
+
controller.enqueue(encoder.encode(data));
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const sseStream: SSEStream = {
|
|
113
|
+
async patchSignals(signals) {
|
|
114
|
+
write(`event: datastar-patch-signals\n`);
|
|
115
|
+
write(`data: signals ${JSON.stringify(signals)}\n\n`);
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async patchElements(html, options = {}) {
|
|
119
|
+
write(`event: datastar-patch-elements\n`);
|
|
120
|
+
if (options.mode) {
|
|
121
|
+
write(`data: mode ${options.mode}\n`);
|
|
122
|
+
}
|
|
123
|
+
if (options.selector) {
|
|
124
|
+
write(`data: selector ${options.selector}\n`);
|
|
125
|
+
}
|
|
126
|
+
// Escape newlines in HTML for SSE format
|
|
127
|
+
const escapedHtml = html.replace(/\n/g, "\ndata: ");
|
|
128
|
+
write(`data: elements ${escapedHtml}\n\n`);
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async executeScript(script) {
|
|
132
|
+
write(`event: datastar-execute-script\n`);
|
|
133
|
+
write(`data: script ${script}\n\n`);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await handler(sseStream);
|
|
139
|
+
} finally {
|
|
140
|
+
controller.close();
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return new Response(stream, {
|
|
146
|
+
headers: {
|
|
147
|
+
"Content-Type": "text/event-stream",
|
|
148
|
+
"Cache-Control": "no-cache",
|
|
149
|
+
Connection: "keep-alive",
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
package/src/lib/time.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time Utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Gets the current Unix timestamp in seconds.
|
|
7
|
+
*
|
|
8
|
+
* Returns the number of seconds since the Unix epoch (January 1, 1970 00:00:00 UTC).
|
|
9
|
+
* This is the standard time format used throughout the application for consistency
|
|
10
|
+
* and database storage.
|
|
11
|
+
*
|
|
12
|
+
* @returns Current Unix timestamp in seconds (not milliseconds)
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const timestamp = now();
|
|
17
|
+
* // Returns: 1706745600 (example value for Feb 1, 2024)
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function now(): number {
|
|
21
|
+
return Math.floor(Date.now() / 1000);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One month in seconds
|
|
26
|
+
*/
|
|
27
|
+
const ONE_MONTH = 30 * 24 * 60 * 60;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Checks if a Unix timestamp is within the last 30 days.
|
|
31
|
+
*
|
|
32
|
+
* Compares the given timestamp to the current time to determine if it falls within
|
|
33
|
+
* the last month (defined as 30 days). Useful for highlighting recent posts or
|
|
34
|
+
* filtering time-sensitive content.
|
|
35
|
+
*
|
|
36
|
+
* @param timestamp - Unix timestamp in seconds to check
|
|
37
|
+
* @returns `true` if the timestamp is within the last 30 days, `false` otherwise
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* const recentPost = 1706745600; // Recent timestamp
|
|
42
|
+
* if (isWithinMonth(recentPost)) {
|
|
43
|
+
* // Show "new" badge
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function isWithinMonth(timestamp: number): boolean {
|
|
48
|
+
return now() - timestamp < ONE_MONTH;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Converts a Unix timestamp to an ISO 8601 date-time string.
|
|
53
|
+
*
|
|
54
|
+
* Formats a Unix timestamp (in seconds) as an ISO 8601 string suitable for HTML
|
|
55
|
+
* `datetime` attributes and API responses. The output includes full date, time,
|
|
56
|
+
* and timezone information in UTC.
|
|
57
|
+
*
|
|
58
|
+
* @param timestamp - Unix timestamp in seconds to convert
|
|
59
|
+
* @returns ISO 8601 formatted string (e.g., "2024-02-01T12:00:00.000Z")
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* const isoDate = toISOString(1706745600);
|
|
64
|
+
* // Returns: "2024-02-01T00:00:00.000Z"
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function toISOString(timestamp: number): string {
|
|
68
|
+
return new Date(timestamp * 1000).toISOString();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Formats a Unix timestamp as a human-readable date string.
|
|
73
|
+
*
|
|
74
|
+
* Converts a Unix timestamp (in seconds) to a localized date string in the format
|
|
75
|
+
* "MMM DD, YYYY" (e.g., "Jan 15, 2024"). Always uses UTC timezone to ensure
|
|
76
|
+
* consistent display regardless of server or client location.
|
|
77
|
+
*
|
|
78
|
+
* @param timestamp - Unix timestamp in seconds to format
|
|
79
|
+
* @returns Formatted date string in "MMM DD, YYYY" format
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```ts
|
|
83
|
+
* const readable = formatDate(1706745600);
|
|
84
|
+
* // Returns: "Feb 1, 2024"
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export function formatDate(timestamp: number): string {
|
|
88
|
+
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
|
|
89
|
+
year: "numeric",
|
|
90
|
+
month: "short",
|
|
91
|
+
day: "numeric",
|
|
92
|
+
timeZone: "UTC",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Formats a Unix timestamp as a year-month string for grouping.
|
|
98
|
+
*
|
|
99
|
+
* Converts a Unix timestamp (in seconds) to a "YYYY-MM" format string, useful for
|
|
100
|
+
* grouping posts by month in archives or creating month-based URLs. Always uses
|
|
101
|
+
* UTC timezone for consistency.
|
|
102
|
+
*
|
|
103
|
+
* @param timestamp - Unix timestamp in seconds to format
|
|
104
|
+
* @returns Year-month string in "YYYY-MM" format
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* const yearMonth = formatYearMonth(1706745600);
|
|
109
|
+
* // Returns: "2024-02"
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
export function formatYearMonth(timestamp: number): string {
|
|
113
|
+
const date = new Date(timestamp * 1000);
|
|
114
|
+
const year = date.getUTCFullYear();
|
|
115
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
116
|
+
return `${year}-${month}`;
|
|
117
|
+
}
|
package/src/lib/url.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL Utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extracts the hostname (domain) from a URL string.
|
|
7
|
+
*
|
|
8
|
+
* Parses a full URL and returns just the hostname portion (e.g., "example.com" from
|
|
9
|
+
* "https://example.com/path"). Returns `null` if the URL is malformed or cannot be parsed.
|
|
10
|
+
*
|
|
11
|
+
* @param url - The full URL string to extract the domain from
|
|
12
|
+
* @returns The hostname/domain if valid, or `null` if parsing fails
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const domain = extractDomain("https://www.example.com/path");
|
|
17
|
+
* // Returns: "www.example.com"
|
|
18
|
+
*
|
|
19
|
+
* const invalid = extractDomain("not-a-url");
|
|
20
|
+
* // Returns: null
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function extractDomain(url: string): string | null {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = new URL(url);
|
|
26
|
+
return parsed.hostname;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalizes a path by removing slashes and converting to lowercase.
|
|
34
|
+
*
|
|
35
|
+
* Trims whitespace, converts to lowercase, removes leading and trailing slashes,
|
|
36
|
+
* and collapses multiple consecutive slashes into single slashes. Used to create
|
|
37
|
+
* consistent path representations for routing and storage.
|
|
38
|
+
*
|
|
39
|
+
* @param path - The path string to normalize
|
|
40
|
+
* @returns The normalized path string
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* const normalized = normalizePath(" /About/Contact// ");
|
|
45
|
+
* // Returns: "about/contact"
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function normalizePath(path: string): string {
|
|
49
|
+
return path
|
|
50
|
+
.trim()
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/^\/+|\/+$/g, "")
|
|
53
|
+
.replace(/\/+/g, "/");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Checks if a string is a full URL with HTTP or HTTPS protocol.
|
|
58
|
+
*
|
|
59
|
+
* Validates whether a string starts with "http://" or "https://", indicating it's
|
|
60
|
+
* a full URL rather than a relative path. Useful for distinguishing between internal
|
|
61
|
+
* paths and external URLs.
|
|
62
|
+
*
|
|
63
|
+
* @param str - The string to check
|
|
64
|
+
* @returns `true` if the string starts with http:// or https://, `false` otherwise
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* isFullUrl("https://example.com"); // Returns: true
|
|
69
|
+
* isFullUrl("/about"); // Returns: false
|
|
70
|
+
* isFullUrl("example.com"); // Returns: false
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export function isFullUrl(str: string): boolean {
|
|
74
|
+
return str.startsWith("http://") || str.startsWith("https://");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Converts text to a URL-friendly slug.
|
|
79
|
+
*
|
|
80
|
+
* Transforms text into a lowercase, hyphen-separated slug by:
|
|
81
|
+
* - Converting to lowercase
|
|
82
|
+
* - Removing special characters (keeping only word characters, spaces, and hyphens)
|
|
83
|
+
* - Replacing whitespace and underscores with hyphens
|
|
84
|
+
* - Removing leading and trailing hyphens
|
|
85
|
+
*
|
|
86
|
+
* Used for generating clean URLs from titles and names.
|
|
87
|
+
*
|
|
88
|
+
* @param text - The text to convert to a slug
|
|
89
|
+
* @returns The slugified string
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* const slug = slugify("Hello World! This is a Test.");
|
|
94
|
+
* // Returns: "hello-world-this-is-a-test"
|
|
95
|
+
*
|
|
96
|
+
* const slug = slugify(" Multiple Spaces ");
|
|
97
|
+
* // Returns: "multiple-spaces"
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export function slugify(text: string): string {
|
|
101
|
+
return text
|
|
102
|
+
.toLowerCase()
|
|
103
|
+
.trim()
|
|
104
|
+
.replace(/[^\w\s-]/g, "")
|
|
105
|
+
.replace(/[\s_-]+/g, "-")
|
|
106
|
+
.replace(/^-+|-+$/g, "");
|
|
107
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Middleware
|
|
3
|
+
*
|
|
4
|
+
* Protects routes by requiring authentication
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { MiddlewareHandler } from "hono";
|
|
8
|
+
import type { Bindings } from "../types.js";
|
|
9
|
+
import type { AppVariables } from "../app.js";
|
|
10
|
+
|
|
11
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Middleware that requires authentication.
|
|
15
|
+
* Redirects to signin page if not authenticated.
|
|
16
|
+
*/
|
|
17
|
+
export function requireAuth(redirectTo = "/signin"): MiddlewareHandler<Env> {
|
|
18
|
+
return async (c, next) => {
|
|
19
|
+
if (!c.var.auth) {
|
|
20
|
+
return c.redirect(redirectTo);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const session = await c.var.auth.api.getSession({ headers: c.req.raw.headers });
|
|
25
|
+
|
|
26
|
+
if (!session?.user) {
|
|
27
|
+
return c.redirect(redirectTo);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await next();
|
|
31
|
+
} catch {
|
|
32
|
+
return c.redirect(redirectTo);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Middleware for API routes that requires authentication.
|
|
39
|
+
* Returns 401 if not authenticated.
|
|
40
|
+
*/
|
|
41
|
+
export function requireAuthApi(): MiddlewareHandler<Env> {
|
|
42
|
+
return async (c, next) => {
|
|
43
|
+
if (!c.var.auth) {
|
|
44
|
+
return c.json({ error: "Authentication not configured" }, 500);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const session = await c.var.auth.api.getSession({ headers: c.req.raw.headers });
|
|
49
|
+
|
|
50
|
+
if (!session?.user) {
|
|
51
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await next();
|
|
55
|
+
} catch {
|
|
56
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Posts API Routes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import type { Bindings, PostType, Visibility } from "../../types.js";
|
|
7
|
+
import type { AppVariables } from "../../app.js";
|
|
8
|
+
import * as sqid from "../../lib/sqid.js";
|
|
9
|
+
import { CreatePostSchema, UpdatePostSchema } from "../../lib/schemas.js";
|
|
10
|
+
import { requireAuthApi } from "../../middleware/auth.js";
|
|
11
|
+
|
|
12
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
13
|
+
|
|
14
|
+
export const postsApiRoutes = new Hono<Env>();
|
|
15
|
+
|
|
16
|
+
// List posts
|
|
17
|
+
postsApiRoutes.get("/", async (c) => {
|
|
18
|
+
const type = c.req.query("type") as PostType | undefined;
|
|
19
|
+
const visibility = c.req.query("visibility") as Visibility | undefined;
|
|
20
|
+
const cursor = c.req.query("cursor");
|
|
21
|
+
const limit = parseInt(c.req.query("limit") ?? "100", 10);
|
|
22
|
+
|
|
23
|
+
const posts = await c.var.services.posts.list({
|
|
24
|
+
type,
|
|
25
|
+
visibility: visibility ? [visibility] : ["featured", "quiet"],
|
|
26
|
+
cursor: cursor ? sqid.decode(cursor) ?? undefined : undefined,
|
|
27
|
+
limit,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return c.json({
|
|
31
|
+
posts: posts.map((p) => ({
|
|
32
|
+
...p,
|
|
33
|
+
sqid: sqid.encode(p.id),
|
|
34
|
+
})),
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Array length check guarantees element exists
|
|
36
|
+
nextCursor: posts.length === limit ? sqid.encode(posts[posts.length - 1]!.id) : null,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Get single post
|
|
41
|
+
postsApiRoutes.get("/:id", async (c) => {
|
|
42
|
+
const id = sqid.decode(c.req.param("id"));
|
|
43
|
+
if (!id) return c.json({ error: "Invalid ID" }, 400);
|
|
44
|
+
|
|
45
|
+
const post = await c.var.services.posts.getById(id);
|
|
46
|
+
if (!post) return c.json({ error: "Not found" }, 404);
|
|
47
|
+
|
|
48
|
+
return c.json({ ...post, sqid: sqid.encode(post.id) });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Create post (requires auth)
|
|
52
|
+
postsApiRoutes.post("/", requireAuthApi(), async (c) => {
|
|
53
|
+
|
|
54
|
+
const rawBody = await c.req.json();
|
|
55
|
+
|
|
56
|
+
// Validate request body
|
|
57
|
+
const parseResult = CreatePostSchema.safeParse(rawBody);
|
|
58
|
+
if (!parseResult.success) {
|
|
59
|
+
return c.json(
|
|
60
|
+
{ error: "Validation failed", details: parseResult.error.flatten() },
|
|
61
|
+
400
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const body = parseResult.data;
|
|
66
|
+
|
|
67
|
+
const post = await c.var.services.posts.create({
|
|
68
|
+
type: body.type,
|
|
69
|
+
title: body.title,
|
|
70
|
+
content: body.content,
|
|
71
|
+
visibility: body.visibility,
|
|
72
|
+
sourceUrl: body.sourceUrl || undefined,
|
|
73
|
+
sourceName: body.sourceName,
|
|
74
|
+
path: body.path || undefined,
|
|
75
|
+
replyToId: body.replyToId ? sqid.decode(body.replyToId) ?? undefined : undefined,
|
|
76
|
+
publishedAt: body.publishedAt,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return c.json({ ...post, sqid: sqid.encode(post.id) }, 201);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Update post (requires auth)
|
|
83
|
+
postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
84
|
+
|
|
85
|
+
const id = sqid.decode(c.req.param("id"));
|
|
86
|
+
if (!id) return c.json({ error: "Invalid ID" }, 400);
|
|
87
|
+
|
|
88
|
+
const rawBody = await c.req.json();
|
|
89
|
+
|
|
90
|
+
// Validate request body
|
|
91
|
+
const parseResult = UpdatePostSchema.safeParse(rawBody);
|
|
92
|
+
if (!parseResult.success) {
|
|
93
|
+
return c.json(
|
|
94
|
+
{ error: "Validation failed", details: parseResult.error.flatten() },
|
|
95
|
+
400
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const body = parseResult.data;
|
|
100
|
+
|
|
101
|
+
const post = await c.var.services.posts.update(id, {
|
|
102
|
+
type: body.type,
|
|
103
|
+
title: body.title,
|
|
104
|
+
content: body.content,
|
|
105
|
+
visibility: body.visibility,
|
|
106
|
+
sourceUrl: body.sourceUrl,
|
|
107
|
+
sourceName: body.sourceName,
|
|
108
|
+
path: body.path,
|
|
109
|
+
publishedAt: body.publishedAt,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!post) return c.json({ error: "Not found" }, 404);
|
|
113
|
+
|
|
114
|
+
return c.json({ ...post, sqid: sqid.encode(post.id) });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Delete post (requires auth)
|
|
118
|
+
postsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
|
|
119
|
+
|
|
120
|
+
const id = sqid.decode(c.req.param("id"));
|
|
121
|
+
if (!id) return c.json({ error: "Invalid ID" }, 400);
|
|
122
|
+
|
|
123
|
+
const success = await c.var.services.posts.delete(id);
|
|
124
|
+
if (!success) return c.json({ error: "Not found" }, 404);
|
|
125
|
+
|
|
126
|
+
return c.json({ success: true });
|
|
127
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search API Routes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import type { Bindings } from "../../types.js";
|
|
7
|
+
import type { AppVariables } from "../../app.js";
|
|
8
|
+
import * as sqid from "../../lib/sqid.js";
|
|
9
|
+
|
|
10
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
11
|
+
|
|
12
|
+
export const searchApiRoutes = new Hono<Env>();
|
|
13
|
+
|
|
14
|
+
// Search posts
|
|
15
|
+
searchApiRoutes.get("/", async (c) => {
|
|
16
|
+
const query = c.req.query("q");
|
|
17
|
+
|
|
18
|
+
if (!query || query.trim().length === 0) {
|
|
19
|
+
return c.json({ error: "Query parameter 'q' is required" }, 400);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (query.length > 200) {
|
|
23
|
+
return c.json({ error: "Query too long" }, 400);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const limitParam = c.req.query("limit");
|
|
27
|
+
const limit = limitParam ? Math.min(parseInt(limitParam, 10) || 20, 50) : 20;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const results = await c.var.services.search.search(query, {
|
|
31
|
+
limit,
|
|
32
|
+
visibility: ["featured", "quiet"],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return c.json({
|
|
36
|
+
query,
|
|
37
|
+
results: results.map((r) => ({
|
|
38
|
+
id: sqid.encode(r.post.id),
|
|
39
|
+
type: r.post.type,
|
|
40
|
+
title: r.post.title,
|
|
41
|
+
path: r.post.path,
|
|
42
|
+
snippet: r.snippet,
|
|
43
|
+
publishedAt: r.post.publishedAt,
|
|
44
|
+
url: `/p/${sqid.encode(r.post.id)}`,
|
|
45
|
+
})),
|
|
46
|
+
count: results.length,
|
|
47
|
+
});
|
|
48
|
+
} catch (err) {
|
|
49
|
+
// eslint-disable-next-line no-console -- Error logging is intentional
|
|
50
|
+
console.error("Search error:", err);
|
|
51
|
+
return c.json({ error: "Search failed" }, 500);
|
|
52
|
+
}
|
|
53
|
+
});
|