@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.
Files changed (133) hide show
  1. package/dist/app.js +70 -563
  2. package/dist/auth.js +3 -0
  3. package/dist/client.js +1 -0
  4. package/dist/i18n/locales/en.js +1 -1
  5. package/dist/i18n/locales/zh-Hans.js +1 -1
  6. package/dist/i18n/locales/zh-Hant.js +1 -1
  7. package/dist/lib/avatar-upload.js +134 -0
  8. package/dist/lib/config.js +39 -0
  9. package/dist/lib/constants.js +10 -10
  10. package/dist/lib/favicon.js +102 -0
  11. package/dist/lib/image.js +13 -17
  12. package/dist/lib/media-helpers.js +2 -2
  13. package/dist/lib/navigation.js +23 -3
  14. package/dist/lib/render.js +10 -1
  15. package/dist/lib/schemas.js +31 -0
  16. package/dist/lib/timezones.js +388 -0
  17. package/dist/lib/view.js +1 -1
  18. package/dist/routes/api/posts.js +1 -1
  19. package/dist/routes/api/upload.js +3 -3
  20. package/dist/routes/auth/reset.js +221 -0
  21. package/dist/routes/auth/setup.js +194 -0
  22. package/dist/routes/auth/signin.js +176 -0
  23. package/dist/routes/dash/collections.js +23 -415
  24. package/dist/routes/dash/media.js +12 -392
  25. package/dist/routes/dash/pages.js +7 -330
  26. package/dist/routes/dash/redirects.js +18 -12
  27. package/dist/routes/dash/settings.js +198 -577
  28. package/dist/routes/feed/rss.js +2 -1
  29. package/dist/routes/feed/sitemap.js +4 -2
  30. package/dist/routes/pages/featured.js +5 -1
  31. package/dist/routes/pages/home.js +26 -1
  32. package/dist/routes/pages/latest.js +45 -0
  33. package/dist/services/post.js +30 -50
  34. package/dist/types/bindings.js +3 -0
  35. package/dist/types/config.js +147 -0
  36. package/dist/types/constants.js +27 -0
  37. package/dist/types/entities.js +3 -0
  38. package/dist/types/operations.js +3 -0
  39. package/dist/types/props.js +3 -0
  40. package/dist/types/views.js +5 -0
  41. package/dist/types.js +8 -111
  42. package/dist/ui/color-themes.js +33 -33
  43. package/dist/ui/compose/ComposeDialog.js +36 -21
  44. package/dist/ui/dash/PageForm.js +21 -15
  45. package/dist/ui/dash/PostForm.js +22 -16
  46. package/dist/ui/dash/collections/CollectionForm.js +152 -0
  47. package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
  48. package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
  49. package/dist/ui/dash/media/MediaListContent.js +166 -0
  50. package/dist/ui/dash/media/ViewMediaContent.js +212 -0
  51. package/dist/ui/dash/pages/LinkFormContent.js +130 -0
  52. package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
  53. package/dist/ui/dash/settings/AccountContent.js +209 -0
  54. package/dist/ui/dash/settings/AppearanceContent.js +259 -0
  55. package/dist/ui/dash/settings/GeneralContent.js +536 -0
  56. package/dist/ui/dash/settings/SettingsNav.js +41 -0
  57. package/dist/ui/font-themes.js +36 -0
  58. package/dist/ui/layouts/BaseLayout.js +24 -2
  59. package/dist/ui/layouts/SiteLayout.js +47 -19
  60. package/package.json +1 -1
  61. package/src/app.tsx +95 -553
  62. package/src/auth.ts +4 -1
  63. package/src/client.ts +1 -0
  64. package/src/i18n/locales/en.po +240 -175
  65. package/src/i18n/locales/en.ts +1 -1
  66. package/src/i18n/locales/zh-Hans.po +240 -175
  67. package/src/i18n/locales/zh-Hans.ts +1 -1
  68. package/src/i18n/locales/zh-Hant.po +240 -175
  69. package/src/i18n/locales/zh-Hant.ts +1 -1
  70. package/src/lib/__tests__/config.test.ts +192 -0
  71. package/src/lib/__tests__/favicon.test.ts +151 -0
  72. package/src/lib/__tests__/image.test.ts +2 -6
  73. package/src/lib/__tests__/timezones.test.ts +61 -0
  74. package/src/lib/__tests__/view.test.ts +2 -2
  75. package/src/lib/avatar-upload.ts +165 -0
  76. package/src/lib/config.ts +47 -0
  77. package/src/lib/constants.ts +19 -11
  78. package/src/lib/favicon.ts +115 -0
  79. package/src/lib/image.ts +13 -21
  80. package/src/lib/media-helpers.ts +2 -2
  81. package/src/lib/navigation.ts +33 -2
  82. package/src/lib/render.tsx +15 -1
  83. package/src/lib/schemas.ts +39 -0
  84. package/src/lib/timezones.ts +325 -0
  85. package/src/lib/view.ts +1 -1
  86. package/src/routes/api/posts.ts +1 -1
  87. package/src/routes/api/upload.ts +2 -3
  88. package/src/routes/auth/reset.tsx +239 -0
  89. package/src/routes/auth/setup.tsx +189 -0
  90. package/src/routes/auth/signin.tsx +163 -0
  91. package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
  92. package/src/routes/dash/collections.tsx +17 -366
  93. package/src/routes/dash/media.tsx +12 -414
  94. package/src/routes/dash/pages.tsx +8 -348
  95. package/src/routes/dash/redirects.tsx +20 -14
  96. package/src/routes/dash/settings.tsx +243 -534
  97. package/src/routes/feed/__tests__/rss.test.ts +141 -0
  98. package/src/routes/feed/rss.ts +3 -1
  99. package/src/routes/feed/sitemap.ts +4 -2
  100. package/src/routes/pages/featured.tsx +7 -1
  101. package/src/routes/pages/home.tsx +25 -2
  102. package/src/routes/pages/latest.tsx +59 -0
  103. package/src/services/post.ts +34 -66
  104. package/src/styles/components.css +0 -65
  105. package/src/styles/tokens.css +1 -1
  106. package/src/styles/ui.css +24 -40
  107. package/src/types/bindings.ts +30 -0
  108. package/src/types/config.ts +183 -0
  109. package/src/types/constants.ts +26 -0
  110. package/src/types/entities.ts +109 -0
  111. package/src/types/operations.ts +88 -0
  112. package/src/types/props.ts +115 -0
  113. package/src/types/views.ts +172 -0
  114. package/src/types.ts +8 -644
  115. package/src/ui/__tests__/font-themes.test.ts +34 -0
  116. package/src/ui/color-themes.ts +34 -34
  117. package/src/ui/compose/ComposeDialog.tsx +40 -21
  118. package/src/ui/dash/PageForm.tsx +25 -19
  119. package/src/ui/dash/PostForm.tsx +26 -20
  120. package/src/ui/dash/collections/CollectionForm.tsx +153 -0
  121. package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
  122. package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
  123. package/src/ui/dash/media/MediaListContent.tsx +201 -0
  124. package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
  125. package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
  126. package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
  127. package/src/ui/dash/settings/AccountContent.tsx +176 -0
  128. package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
  129. package/src/ui/dash/settings/GeneralContent.tsx +533 -0
  130. package/src/ui/dash/settings/SettingsNav.tsx +56 -0
  131. package/src/ui/font-themes.ts +54 -0
  132. package/src/ui/layouts/BaseLayout.tsx +17 -0
  133. 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
+ });
@@ -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: 50,
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
- Allow: /
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
+ });
@@ -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 */
@@ -30,7 +30,7 @@
30
30
  --card-shadow: none;
31
31
 
32
32
  /* Elements */
33
- --avatar-size: 36px;
33
+ --avatar-size: 28px;
34
34
  --avatar-radius: 50%;
35
35
  --media-radius: 0.5rem;
36
36
 
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: 3rem auto;
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
+ }