@jant/core 0.3.25 → 0.3.27
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/dist/app.js +70 -563
- package/dist/auth.js +3 -0
- package/dist/client.js +1 -0
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/zh-Hans.js +1 -1
- package/dist/i18n/locales/zh-Hant.js +1 -1
- package/dist/lib/avatar-upload.js +134 -0
- package/dist/lib/config.js +39 -0
- package/dist/lib/constants.js +10 -10
- package/dist/lib/favicon.js +102 -0
- package/dist/lib/image.js +13 -17
- package/dist/lib/media-helpers.js +2 -2
- package/dist/lib/navigation.js +23 -3
- package/dist/lib/render.js +10 -1
- package/dist/lib/schemas.js +31 -0
- package/dist/lib/timezones.js +388 -0
- package/dist/lib/view.js +1 -1
- package/dist/routes/api/posts.js +1 -1
- package/dist/routes/api/upload.js +3 -3
- package/dist/routes/auth/reset.js +221 -0
- package/dist/routes/auth/setup.js +194 -0
- package/dist/routes/auth/signin.js +176 -0
- package/dist/routes/dash/collections.js +23 -415
- package/dist/routes/dash/media.js +12 -392
- package/dist/routes/dash/pages.js +7 -330
- package/dist/routes/dash/redirects.js +18 -12
- package/dist/routes/dash/settings.js +198 -577
- package/dist/routes/feed/rss.js +2 -1
- package/dist/routes/feed/sitemap.js +4 -2
- package/dist/routes/pages/featured.js +5 -1
- package/dist/routes/pages/home.js +26 -1
- package/dist/routes/pages/latest.js +45 -0
- package/dist/services/post.js +30 -50
- package/dist/types/bindings.js +3 -0
- package/dist/types/config.js +147 -0
- package/dist/types/constants.js +27 -0
- package/dist/types/entities.js +3 -0
- package/dist/types/operations.js +3 -0
- package/dist/types/props.js +3 -0
- package/dist/types/views.js +5 -0
- package/dist/types.js +8 -111
- package/dist/ui/color-themes.js +33 -33
- package/dist/ui/compose/ComposeDialog.js +36 -21
- package/dist/ui/dash/PageForm.js +21 -15
- package/dist/ui/dash/PostForm.js +22 -16
- package/dist/ui/dash/collections/CollectionForm.js +152 -0
- package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
- package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
- package/dist/ui/dash/media/MediaListContent.js +166 -0
- package/dist/ui/dash/media/ViewMediaContent.js +212 -0
- package/dist/ui/dash/pages/LinkFormContent.js +130 -0
- package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
- package/dist/ui/dash/settings/AccountContent.js +209 -0
- package/dist/ui/dash/settings/AppearanceContent.js +259 -0
- package/dist/ui/dash/settings/GeneralContent.js +536 -0
- package/dist/ui/dash/settings/SettingsNav.js +41 -0
- package/dist/ui/font-themes.js +36 -0
- package/dist/ui/layouts/BaseLayout.js +24 -2
- package/dist/ui/layouts/SiteLayout.js +47 -19
- package/package.json +1 -1
- package/src/app.tsx +95 -553
- package/src/auth.ts +4 -1
- package/src/client.ts +1 -0
- package/src/i18n/locales/en.po +240 -175
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +240 -175
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +240 -175
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/lib/__tests__/config.test.ts +192 -0
- package/src/lib/__tests__/favicon.test.ts +151 -0
- package/src/lib/__tests__/image.test.ts +2 -6
- package/src/lib/__tests__/timezones.test.ts +61 -0
- package/src/lib/__tests__/view.test.ts +2 -2
- package/src/lib/avatar-upload.ts +165 -0
- package/src/lib/config.ts +47 -0
- package/src/lib/constants.ts +19 -11
- package/src/lib/favicon.ts +115 -0
- package/src/lib/image.ts +13 -21
- package/src/lib/media-helpers.ts +2 -2
- package/src/lib/navigation.ts +33 -2
- package/src/lib/render.tsx +15 -1
- package/src/lib/schemas.ts +39 -0
- package/src/lib/timezones.ts +325 -0
- package/src/lib/view.ts +1 -1
- package/src/routes/api/posts.ts +1 -1
- package/src/routes/api/upload.ts +2 -3
- package/src/routes/auth/reset.tsx +239 -0
- package/src/routes/auth/setup.tsx +189 -0
- package/src/routes/auth/signin.tsx +163 -0
- package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
- package/src/routes/dash/collections.tsx +17 -366
- package/src/routes/dash/media.tsx +12 -414
- package/src/routes/dash/pages.tsx +8 -348
- package/src/routes/dash/redirects.tsx +20 -14
- package/src/routes/dash/settings.tsx +243 -534
- package/src/routes/feed/__tests__/rss.test.ts +141 -0
- package/src/routes/feed/rss.ts +3 -1
- package/src/routes/feed/sitemap.ts +4 -2
- package/src/routes/pages/featured.tsx +7 -1
- package/src/routes/pages/home.tsx +25 -2
- package/src/routes/pages/latest.tsx +59 -0
- package/src/services/post.ts +34 -66
- package/src/styles/components.css +0 -65
- package/src/styles/tokens.css +1 -1
- package/src/styles/ui.css +24 -40
- package/src/types/bindings.ts +30 -0
- package/src/types/config.ts +183 -0
- package/src/types/constants.ts +26 -0
- package/src/types/entities.ts +109 -0
- package/src/types/operations.ts +88 -0
- package/src/types/props.ts +115 -0
- package/src/types/views.ts +172 -0
- package/src/types.ts +8 -644
- package/src/ui/__tests__/font-themes.test.ts +34 -0
- package/src/ui/color-themes.ts +34 -34
- package/src/ui/compose/ComposeDialog.tsx +40 -21
- package/src/ui/dash/PageForm.tsx +25 -19
- package/src/ui/dash/PostForm.tsx +26 -20
- package/src/ui/dash/collections/CollectionForm.tsx +153 -0
- package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
- package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
- package/src/ui/dash/media/MediaListContent.tsx +201 -0
- package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
- package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
- package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
- package/src/ui/dash/settings/AccountContent.tsx +176 -0
- package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
- package/src/ui/dash/settings/GeneralContent.tsx +533 -0
- package/src/ui/dash/settings/SettingsNav.tsx +56 -0
- package/src/ui/font-themes.ts +54 -0
- package/src/ui/layouts/BaseLayout.tsx +17 -0
- package/src/ui/layouts/SiteLayout.tsx +45 -31
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import type { Bindings } from "../../../types.js";
|
|
4
|
+
import type { AppVariables } from "../../../app.js";
|
|
5
|
+
import { createTestDatabase } from "../../../__tests__/helpers/db.js";
|
|
6
|
+
import { createPostService } from "../../../services/post.js";
|
|
7
|
+
import { createSettingsService } from "../../../services/settings.js";
|
|
8
|
+
import { createMediaService } from "../../../services/media.js";
|
|
9
|
+
import { rssRoutes } from "../rss.js";
|
|
10
|
+
|
|
11
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
12
|
+
|
|
13
|
+
function createFeedTestApp(envOverrides: Partial<Bindings> = {}) {
|
|
14
|
+
const { db } = createTestDatabase();
|
|
15
|
+
|
|
16
|
+
const services = {
|
|
17
|
+
posts: createPostService(db as never),
|
|
18
|
+
settings: createSettingsService(db as never),
|
|
19
|
+
media: createMediaService(db as never),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const app = new Hono<Env>();
|
|
23
|
+
|
|
24
|
+
app.use("*", async (c, next) => {
|
|
25
|
+
c.env = {
|
|
26
|
+
SITE_URL: "http://localhost:9019",
|
|
27
|
+
...envOverrides,
|
|
28
|
+
} as Bindings;
|
|
29
|
+
|
|
30
|
+
c.set("services", services as AppVariables["services"]);
|
|
31
|
+
c.set("config", {});
|
|
32
|
+
await next();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
app.route("/feed", rssRoutes);
|
|
36
|
+
|
|
37
|
+
return { app, services };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("RSS Feed Routes", () => {
|
|
41
|
+
describe("RSS_FEED_LIMIT env var", () => {
|
|
42
|
+
it("defaults to 50 when RSS_FEED_LIMIT is not set", async () => {
|
|
43
|
+
const { app, services } = createFeedTestApp();
|
|
44
|
+
|
|
45
|
+
// Create 3 posts
|
|
46
|
+
for (let i = 0; i < 3; i++) {
|
|
47
|
+
await services.posts.create({
|
|
48
|
+
format: "note",
|
|
49
|
+
title: `Post ${i}`,
|
|
50
|
+
body: `Body ${i}`,
|
|
51
|
+
status: "published",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const res = await app.request("/feed");
|
|
56
|
+
expect(res.status).toBe(200);
|
|
57
|
+
|
|
58
|
+
const xml = await res.text();
|
|
59
|
+
// All 3 posts should appear (under default limit of 50)
|
|
60
|
+
expect(xml).toContain("Post 0");
|
|
61
|
+
expect(xml).toContain("Post 1");
|
|
62
|
+
expect(xml).toContain("Post 2");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("respects RSS_FEED_LIMIT to limit the number of posts", async () => {
|
|
66
|
+
const { app, services } = createFeedTestApp({
|
|
67
|
+
RSS_FEED_LIMIT: "2",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Create 5 posts
|
|
71
|
+
for (let i = 0; i < 5; i++) {
|
|
72
|
+
await services.posts.create({
|
|
73
|
+
format: "note",
|
|
74
|
+
title: `Post ${i}`,
|
|
75
|
+
body: `Body ${i}`,
|
|
76
|
+
status: "published",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const res = await app.request("/feed");
|
|
81
|
+
expect(res.status).toBe(200);
|
|
82
|
+
|
|
83
|
+
const xml = await res.text();
|
|
84
|
+
// Posts are ordered by publishedAt DESC, so the latest 2 should appear
|
|
85
|
+
// With same timestamp they fall back to id DESC, so Post 4 and Post 3
|
|
86
|
+
expect(xml).toContain("Post 4");
|
|
87
|
+
expect(xml).toContain("Post 3");
|
|
88
|
+
expect(xml).not.toContain("Post 2");
|
|
89
|
+
expect(xml).not.toContain("Post 1");
|
|
90
|
+
expect(xml).not.toContain("Post 0");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("falls back to 50 for invalid RSS_FEED_LIMIT", async () => {
|
|
94
|
+
const { app, services } = createFeedTestApp({
|
|
95
|
+
RSS_FEED_LIMIT: "not-a-number",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Create 2 posts
|
|
99
|
+
for (let i = 0; i < 2; i++) {
|
|
100
|
+
await services.posts.create({
|
|
101
|
+
format: "note",
|
|
102
|
+
title: `Post ${i}`,
|
|
103
|
+
body: `Body ${i}`,
|
|
104
|
+
status: "published",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const res = await app.request("/feed");
|
|
109
|
+
expect(res.status).toBe(200);
|
|
110
|
+
|
|
111
|
+
const xml = await res.text();
|
|
112
|
+
// Both posts should appear (fallback to 50)
|
|
113
|
+
expect(xml).toContain("Post 0");
|
|
114
|
+
expect(xml).toContain("Post 1");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("also applies to atom feed", async () => {
|
|
118
|
+
const { app, services } = createFeedTestApp({
|
|
119
|
+
RSS_FEED_LIMIT: "1",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < 3; i++) {
|
|
123
|
+
await services.posts.create({
|
|
124
|
+
format: "note",
|
|
125
|
+
title: `Post ${i}`,
|
|
126
|
+
body: `Body ${i}`,
|
|
127
|
+
status: "published",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const res = await app.request("/feed/atom.xml");
|
|
132
|
+
expect(res.status).toBe(200);
|
|
133
|
+
|
|
134
|
+
const xml = await res.text();
|
|
135
|
+
// Only the latest post should appear
|
|
136
|
+
expect(xml).toContain("Post 2");
|
|
137
|
+
expect(xml).not.toContain("Post 1");
|
|
138
|
+
expect(xml).not.toContain("Post 0");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
package/src/routes/feed/rss.ts
CHANGED
|
@@ -25,10 +25,12 @@ async function buildFeedData(c: Context<Env>): Promise<FeedData> {
|
|
|
25
25
|
const siteUrl = c.env.SITE_URL;
|
|
26
26
|
const siteLanguage = await getSiteLanguage(c);
|
|
27
27
|
|
|
28
|
+
const feedLimit = parseInt(c.env.RSS_FEED_LIMIT ?? "50", 10) || 50;
|
|
29
|
+
|
|
28
30
|
const posts = await c.var.services.posts.list({
|
|
29
31
|
status: "published",
|
|
30
32
|
excludeReplies: true,
|
|
31
|
-
limit:
|
|
33
|
+
limit: feedLimit,
|
|
32
34
|
});
|
|
33
35
|
|
|
34
36
|
// Batch load media for enclosures
|
|
@@ -46,11 +46,13 @@ sitemapRoutes.get("/sitemap.xml", async (c) => {
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// robots.txt
|
|
49
|
-
sitemapRoutes.get("/robots.txt", (c) => {
|
|
49
|
+
sitemapRoutes.get("/robots.txt", async (c) => {
|
|
50
50
|
const siteUrl = c.env.SITE_URL;
|
|
51
|
+
const noindex = (await c.var.services.settings.get("NOINDEX")) === "true";
|
|
51
52
|
|
|
53
|
+
const directive = noindex ? "Disallow: /" : "Allow: /";
|
|
52
54
|
const robots = `User-agent: *
|
|
53
|
-
|
|
55
|
+
${directive}
|
|
54
56
|
|
|
55
57
|
Sitemap: ${siteUrl}/sitemap.xml
|
|
56
58
|
`;
|
|
@@ -17,13 +17,19 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
|
17
17
|
export const featuredRoutes = new Hono<Env>();
|
|
18
18
|
|
|
19
19
|
featuredRoutes.get("/", async (c) => {
|
|
20
|
+
const navData = await getNavigationData(c);
|
|
21
|
+
|
|
22
|
+
// When homepage already shows featured, redirect to avoid duplicate content
|
|
23
|
+
if (navData.homeDefaultView === "featured") {
|
|
24
|
+
return c.redirect("/", 302);
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
const posts = await c.var.services.posts.list({
|
|
21
28
|
featured: true,
|
|
22
29
|
status: "published",
|
|
23
30
|
excludeReplies: true,
|
|
24
31
|
});
|
|
25
32
|
|
|
26
|
-
const navData = await getNavigationData(c);
|
|
27
33
|
const mediaCtx = createMediaContext(c);
|
|
28
34
|
const postViews = toPostViewsFromPosts(posts, mediaCtx);
|
|
29
35
|
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Timeline feed with per-type card components and thread previews.
|
|
5
5
|
* Uses page-based pagination.
|
|
6
|
+
*
|
|
7
|
+
* When HOME_DEFAULT_VIEW is "featured", the homepage shows featured posts
|
|
8
|
+
* instead of latest. The /latest route always shows latest posts explicitly.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import { Hono } from "hono";
|
|
@@ -13,12 +16,34 @@ import { renderPublicPage } from "../../lib/render.js";
|
|
|
13
16
|
import { assembleTimeline } from "../../lib/timeline.js";
|
|
14
17
|
import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
|
|
15
18
|
import { HomePage } from "../../ui/pages/HomePage.js";
|
|
19
|
+
import { FeaturedPage } from "../../ui/pages/FeaturedPage.js";
|
|
16
20
|
|
|
17
21
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
18
22
|
|
|
19
23
|
export const homeRoutes = new Hono<Env>();
|
|
20
24
|
|
|
21
25
|
homeRoutes.get("/", async (c) => {
|
|
26
|
+
const navData = await getNavigationData(c);
|
|
27
|
+
|
|
28
|
+
if (navData.homeDefaultView === "featured") {
|
|
29
|
+
// Show featured posts on homepage
|
|
30
|
+
const posts = await c.var.services.posts.list({
|
|
31
|
+
featured: true,
|
|
32
|
+
status: "published",
|
|
33
|
+
excludeReplies: true,
|
|
34
|
+
});
|
|
35
|
+
const mediaCtx = createMediaContext(c);
|
|
36
|
+
const postViews = toPostViewsFromPosts(posts, mediaCtx);
|
|
37
|
+
const items = postViews.map((post) => ({ post }));
|
|
38
|
+
|
|
39
|
+
return renderPublicPage(c, {
|
|
40
|
+
title: navData.siteName,
|
|
41
|
+
navData,
|
|
42
|
+
content: <FeaturedPage items={items} />,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Default: show latest posts
|
|
22
47
|
const pageParam = c.req.query("page");
|
|
23
48
|
const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1;
|
|
24
49
|
|
|
@@ -26,8 +51,6 @@ homeRoutes.get("/", async (c) => {
|
|
|
26
51
|
page,
|
|
27
52
|
});
|
|
28
53
|
|
|
29
|
-
const navData = await getNavigationData(c);
|
|
30
|
-
|
|
31
54
|
// Fetch pinned posts
|
|
32
55
|
const pinnedPosts = await c.var.services.posts.list({
|
|
33
56
|
pinned: true,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Latest Page Route
|
|
3
|
+
*
|
|
4
|
+
* Explicit /latest URL that always shows the latest posts timeline.
|
|
5
|
+
* When HOME_DEFAULT_VIEW is "latest" (default), this redirects to /
|
|
6
|
+
* to avoid duplicate content. When it's "featured", this serves as
|
|
7
|
+
* the explicit latest view.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Hono } from "hono";
|
|
11
|
+
import type { Bindings } from "../../types.js";
|
|
12
|
+
import type { AppVariables } from "../../app.js";
|
|
13
|
+
import { getNavigationData } from "../../lib/navigation.js";
|
|
14
|
+
import { renderPublicPage } from "../../lib/render.js";
|
|
15
|
+
import { assembleTimeline } from "../../lib/timeline.js";
|
|
16
|
+
import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
|
|
17
|
+
import { HomePage } from "../../ui/pages/HomePage.js";
|
|
18
|
+
|
|
19
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
20
|
+
|
|
21
|
+
export const latestRoutes = new Hono<Env>();
|
|
22
|
+
|
|
23
|
+
latestRoutes.get("/", async (c) => {
|
|
24
|
+
const navData = await getNavigationData(c);
|
|
25
|
+
|
|
26
|
+
// When homepage already shows latest, redirect to avoid duplicate content
|
|
27
|
+
if (navData.homeDefaultView !== "featured") {
|
|
28
|
+
return c.redirect("/", 302);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const pageParam = c.req.query("page");
|
|
32
|
+
const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1;
|
|
33
|
+
|
|
34
|
+
const { items, currentPage, totalPages } = await assembleTimeline(c, {
|
|
35
|
+
page,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Fetch pinned posts
|
|
39
|
+
const pinnedPosts = await c.var.services.posts.list({
|
|
40
|
+
pinned: true,
|
|
41
|
+
status: "published",
|
|
42
|
+
excludeReplies: true,
|
|
43
|
+
});
|
|
44
|
+
const mediaCtx = createMediaContext(c);
|
|
45
|
+
const pinnedItems = toPostViewsFromPosts(pinnedPosts, mediaCtx);
|
|
46
|
+
|
|
47
|
+
return renderPublicPage(c, {
|
|
48
|
+
title: `Latest - ${navData.siteName}`,
|
|
49
|
+
navData,
|
|
50
|
+
content: (
|
|
51
|
+
<HomePage
|
|
52
|
+
items={items}
|
|
53
|
+
pinnedItems={pinnedItems}
|
|
54
|
+
currentPage={currentPage}
|
|
55
|
+
totalPages={totalPages}
|
|
56
|
+
/>
|
|
57
|
+
),
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/services/post.ts
CHANGED
|
@@ -53,6 +53,38 @@ export interface PostService {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
export function createPostService(db: Database): PostService {
|
|
56
|
+
/** Build WHERE conditions from filters (shared by list and count) */
|
|
57
|
+
function buildFilterConditions(filters: PostFilters) {
|
|
58
|
+
const conditions = [];
|
|
59
|
+
|
|
60
|
+
if (filters.status) {
|
|
61
|
+
conditions.push(eq(posts.status, filters.status));
|
|
62
|
+
}
|
|
63
|
+
if (filters.featured !== undefined) {
|
|
64
|
+
conditions.push(eq(posts.featured, filters.featured ? 1 : 0));
|
|
65
|
+
}
|
|
66
|
+
if (filters.pinned !== undefined) {
|
|
67
|
+
conditions.push(eq(posts.pinned, filters.pinned ? 1 : 0));
|
|
68
|
+
}
|
|
69
|
+
if (filters.format) {
|
|
70
|
+
conditions.push(eq(posts.format, filters.format));
|
|
71
|
+
}
|
|
72
|
+
if (filters.collectionId !== undefined) {
|
|
73
|
+
conditions.push(eq(posts.collectionId, filters.collectionId));
|
|
74
|
+
}
|
|
75
|
+
if (filters.threadId) {
|
|
76
|
+
conditions.push(eq(posts.threadId, filters.threadId));
|
|
77
|
+
}
|
|
78
|
+
if (filters.excludeReplies) {
|
|
79
|
+
conditions.push(isNull(posts.threadId));
|
|
80
|
+
}
|
|
81
|
+
if (!filters.includeDeleted) {
|
|
82
|
+
conditions.push(isNull(posts.deletedAt));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return conditions;
|
|
86
|
+
}
|
|
87
|
+
|
|
56
88
|
function toPost(row: typeof posts.$inferSelect): Post {
|
|
57
89
|
return {
|
|
58
90
|
id: row.id,
|
|
@@ -97,39 +129,7 @@ export function createPostService(db: Database): PostService {
|
|
|
97
129
|
},
|
|
98
130
|
|
|
99
131
|
async list(filters = {}) {
|
|
100
|
-
const conditions =
|
|
101
|
-
|
|
102
|
-
if (filters.status) {
|
|
103
|
-
conditions.push(eq(posts.status, filters.status));
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (filters.featured !== undefined) {
|
|
107
|
-
conditions.push(eq(posts.featured, filters.featured ? 1 : 0));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (filters.pinned !== undefined) {
|
|
111
|
-
conditions.push(eq(posts.pinned, filters.pinned ? 1 : 0));
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (filters.format) {
|
|
115
|
-
conditions.push(eq(posts.format, filters.format));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (filters.collectionId !== undefined) {
|
|
119
|
-
conditions.push(eq(posts.collectionId, filters.collectionId));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (filters.threadId) {
|
|
123
|
-
conditions.push(eq(posts.threadId, filters.threadId));
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (filters.excludeReplies) {
|
|
127
|
-
conditions.push(isNull(posts.threadId));
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (!filters.includeDeleted) {
|
|
131
|
-
conditions.push(isNull(posts.deletedAt));
|
|
132
|
-
}
|
|
132
|
+
const conditions = buildFilterConditions(filters);
|
|
133
133
|
|
|
134
134
|
if (filters.cursor) {
|
|
135
135
|
conditions.push(sql`${posts.id} < ${filters.cursor}`);
|
|
@@ -151,39 +151,7 @@ export function createPostService(db: Database): PostService {
|
|
|
151
151
|
},
|
|
152
152
|
|
|
153
153
|
async count(filters = {}) {
|
|
154
|
-
const conditions =
|
|
155
|
-
|
|
156
|
-
if (filters.status) {
|
|
157
|
-
conditions.push(eq(posts.status, filters.status));
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (filters.featured !== undefined) {
|
|
161
|
-
conditions.push(eq(posts.featured, filters.featured ? 1 : 0));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (filters.pinned !== undefined) {
|
|
165
|
-
conditions.push(eq(posts.pinned, filters.pinned ? 1 : 0));
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (filters.format) {
|
|
169
|
-
conditions.push(eq(posts.format, filters.format));
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (filters.collectionId !== undefined) {
|
|
173
|
-
conditions.push(eq(posts.collectionId, filters.collectionId));
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (filters.threadId) {
|
|
177
|
-
conditions.push(eq(posts.threadId, filters.threadId));
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (filters.excludeReplies) {
|
|
181
|
-
conditions.push(isNull(posts.threadId));
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (!filters.includeDeleted) {
|
|
185
|
-
conditions.push(isNull(posts.deletedAt));
|
|
186
|
-
}
|
|
154
|
+
const conditions = buildFilterConditions(filters);
|
|
187
155
|
|
|
188
156
|
const result = await db
|
|
189
157
|
.select({ count: sql<number>`count(*)`.as("count") })
|
|
@@ -10,71 +10,6 @@
|
|
|
10
10
|
.container {
|
|
11
11
|
@apply mx-auto max-w-2xl px-4;
|
|
12
12
|
}
|
|
13
|
-
|
|
14
|
-
.container-timeline {
|
|
15
|
-
@apply mx-auto px-4;
|
|
16
|
-
max-width: 600px;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/* Alert variants */
|
|
21
|
-
@layer components {
|
|
22
|
-
.alert-success {
|
|
23
|
-
@apply relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current;
|
|
24
|
-
@apply text-success bg-card [&>svg]:text-current;
|
|
25
|
-
|
|
26
|
-
> h2,
|
|
27
|
-
> h3,
|
|
28
|
-
> h4,
|
|
29
|
-
> h5,
|
|
30
|
-
> h6,
|
|
31
|
-
> strong,
|
|
32
|
-
> [data-title] {
|
|
33
|
-
@apply col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
> section {
|
|
37
|
-
@apply text-success col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed;
|
|
38
|
-
|
|
39
|
-
ul {
|
|
40
|
-
@apply list-inside list-disc text-sm;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/* Badge components */
|
|
47
|
-
@layer components {
|
|
48
|
-
.badge {
|
|
49
|
-
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
|
|
50
|
-
background-color: var(--color-secondary);
|
|
51
|
-
color: var(--color-secondary-foreground);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
.badge-primary {
|
|
55
|
-
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
|
|
56
|
-
background-color: var(--color-primary);
|
|
57
|
-
color: var(--color-primary-foreground);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
.badge-secondary {
|
|
61
|
-
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
|
|
62
|
-
background-color: var(--color-secondary);
|
|
63
|
-
color: var(--color-secondary-foreground);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
.badge-outline {
|
|
67
|
-
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
|
|
68
|
-
@apply border bg-transparent;
|
|
69
|
-
border-color: var(--color-border);
|
|
70
|
-
color: var(--color-foreground);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
.badge-destructive {
|
|
74
|
-
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
|
|
75
|
-
background-color: var(--color-destructive);
|
|
76
|
-
color: white;
|
|
77
|
-
}
|
|
78
13
|
}
|
|
79
14
|
|
|
80
15
|
/* Toast notifications */
|
package/src/styles/tokens.css
CHANGED
package/src/styles/ui.css
CHANGED
|
@@ -82,6 +82,9 @@
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
.site-logo {
|
|
85
|
+
display: flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
gap: 8px;
|
|
85
88
|
font-size: 1.125rem;
|
|
86
89
|
font-weight: 800;
|
|
87
90
|
font-family: var(--font-heading);
|
|
@@ -90,6 +93,13 @@
|
|
|
90
93
|
line-height: 1;
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
.site-logo-avatar {
|
|
97
|
+
width: var(--avatar-size);
|
|
98
|
+
height: var(--avatar-size);
|
|
99
|
+
border-radius: var(--avatar-radius);
|
|
100
|
+
object-fit: cover;
|
|
101
|
+
}
|
|
102
|
+
|
|
93
103
|
.site-header-nav {
|
|
94
104
|
display: flex;
|
|
95
105
|
align-items: center;
|
|
@@ -175,7 +185,7 @@
|
|
|
175
185
|
border: none;
|
|
176
186
|
height: 10px;
|
|
177
187
|
width: 32px;
|
|
178
|
-
margin:
|
|
188
|
+
margin: 2.5rem auto;
|
|
179
189
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 45 13'%3E%3Cpath fill='%23ccc' transform='translate(0,0) rotate(90 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='%23ccc' transform='translate(16,0) rotate(100 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='%23ccc' transform='translate(32,0) rotate(80 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3C/svg%3E");
|
|
180
190
|
background-repeat: no-repeat;
|
|
181
191
|
background-position: center;
|
|
@@ -213,45 +223,6 @@
|
|
|
213
223
|
@apply text-sm;
|
|
214
224
|
}
|
|
215
225
|
|
|
216
|
-
/* Media — rounded corners + subtle outline */
|
|
217
|
-
.feed-media img {
|
|
218
|
-
border-radius: var(--media-radius);
|
|
219
|
-
outline: 1px solid var(--site-media-outline);
|
|
220
|
-
outline-offset: -1px;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/* Image carousel — horizontal scroll */
|
|
224
|
-
.feed-carousel-track {
|
|
225
|
-
display: flex;
|
|
226
|
-
overflow-x: auto;
|
|
227
|
-
gap: 4px;
|
|
228
|
-
margin-inline: calc(-1 * var(--site-padding));
|
|
229
|
-
padding-inline: var(--site-padding);
|
|
230
|
-
scrollbar-width: none; /* Firefox */
|
|
231
|
-
-ms-overflow-style: none; /* IE/Edge */
|
|
232
|
-
}
|
|
233
|
-
.feed-carousel-track::-webkit-scrollbar {
|
|
234
|
-
display: none; /* Chrome/Safari */
|
|
235
|
-
}
|
|
236
|
-
.feed-carousel-item {
|
|
237
|
-
flex: 0 0 auto;
|
|
238
|
-
width: 224px;
|
|
239
|
-
height: 280px;
|
|
240
|
-
}
|
|
241
|
-
.feed-carousel-img {
|
|
242
|
-
width: 100%;
|
|
243
|
-
height: 100%;
|
|
244
|
-
object-fit: cover;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/* Link preview styling */
|
|
248
|
-
.feed-link-preview {
|
|
249
|
-
border-radius: 12px;
|
|
250
|
-
border: var(--card-border-width) solid var(--site-column-outline);
|
|
251
|
-
padding: 12px 16px;
|
|
252
|
-
margin-top: 8px;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
226
|
/* Quote block styling */
|
|
256
227
|
.feed-quote {
|
|
257
228
|
padding-left: 12px;
|
|
@@ -488,4 +459,17 @@
|
|
|
488
459
|
color: var(--site-text-primary);
|
|
489
460
|
background-color: var(--site-nav-hover-bg);
|
|
490
461
|
}
|
|
462
|
+
|
|
463
|
+
/* =========================================================================
|
|
464
|
+
* Site Footer
|
|
465
|
+
* ========================================================================= */
|
|
466
|
+
|
|
467
|
+
.site-footer {
|
|
468
|
+
max-width: var(--site-width);
|
|
469
|
+
margin: var(--space-xl) auto 0;
|
|
470
|
+
padding: var(--content-gap) var(--site-padding) var(--space-xl);
|
|
471
|
+
border-top: 0.5px solid var(--site-divider);
|
|
472
|
+
color: var(--site-text-secondary);
|
|
473
|
+
font-size: var(--text-sm);
|
|
474
|
+
}
|
|
491
475
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Worker Bindings
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface Bindings {
|
|
6
|
+
DB: D1Database;
|
|
7
|
+
R2?: R2Bucket;
|
|
8
|
+
SITE_URL: string;
|
|
9
|
+
AUTH_SECRET?: string;
|
|
10
|
+
R2_PUBLIC_URL?: string;
|
|
11
|
+
IMAGE_TRANSFORM_URL?: string;
|
|
12
|
+
DEMO_EMAIL?: string;
|
|
13
|
+
DEMO_PASSWORD?: string;
|
|
14
|
+
// Timeline
|
|
15
|
+
PAGE_SIZE?: string;
|
|
16
|
+
// Site configuration (optional - can be overridden in DB)
|
|
17
|
+
SITE_NAME?: string;
|
|
18
|
+
SITE_DESCRIPTION?: string;
|
|
19
|
+
SITE_LANGUAGE?: string;
|
|
20
|
+
// S3-compatible storage (alternative to R2)
|
|
21
|
+
STORAGE_DRIVER?: string;
|
|
22
|
+
S3_ENDPOINT?: string;
|
|
23
|
+
S3_BUCKET?: string;
|
|
24
|
+
S3_ACCESS_KEY_ID?: string;
|
|
25
|
+
S3_SECRET_ACCESS_KEY?: string;
|
|
26
|
+
S3_REGION?: string;
|
|
27
|
+
S3_PUBLIC_URL?: string;
|
|
28
|
+
// RSS feed
|
|
29
|
+
RSS_FEED_LIMIT?: string;
|
|
30
|
+
}
|