grok-digest 0.1.1 → 0.2.0

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,24 @@
1
+ src/styles/shadcn-tailwind.css (dist/styles/shadcn-tailwind.css in the npm package) is
2
+ copied from shadcn (https://ui.shadcn.com) v4.21.5, dist/tailwind.css.
3
+
4
+ MIT License
5
+
6
+ Copyright (c) 2023 shadcn
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
package/README.md CHANGED
@@ -61,7 +61,7 @@ The site needs no Astro config. The package depends on the Astro version it was
61
61
  import { SCHEMA_VERSION, bookmarkSchema, parseBookmarks, TOPICS } from "grok-digest/schema";
62
62
  ```
63
63
 
64
- `SCHEMA_VERSION` is `1`. The JSON files have no version field. A `bookmarks.json` that already validates is version 1, including files written before this package existed. `posted_at` with milliseconds and a `Z` offset, as the bot writes it, is version 1.
64
+ `SCHEMA_VERSION` is `1`. The JSON files have no version field. A `bookmarks.json` that already validates is version 1. `posted_at` with milliseconds and a `Z` offset, as the bot writes it, is version 1.
65
65
 
66
66
  | File | Version 1 shape |
67
67
  | --------------------- | ---------------------------------------------------------------------- |
@@ -75,7 +75,7 @@ Every site takes every release, so every release reads every data file an earlie
75
75
 
76
76
  The page title is "Grok Digest". The footer reads "Unofficial. Summaries created with Grok." Every page sends `<meta name="robots" content="noindex,nofollow">` and `<meta name="referrer" content="no-referrer">`. `robots.txt` disallows all crawlers. Feeds live at `rss.xml` and `rss/<topic-id>.xml`. Card anchors are `#b-<id>`.
77
77
 
78
- Search and topic filters are `?q=` and `?topic=` on the page address. The behavior is documented in `template/README.md`.
78
+ The page opens on Today, a daily review of up to five bookmarks, and has Inbox, Done, and All views. Search, topic, and view are `?q=`, `?topic=`, and `?view=` on the page address. The behavior is documented in `template/README.md`.
79
79
 
80
80
  ## Package exports
81
81
 
@@ -1,7 +1,8 @@
1
1
  ---
2
- import { ArrowUpRightIcon } from "lucide-react";
2
+ import { ArrowUpRightIcon, CheckIcon, ChevronDownIcon } from "lucide-react";
3
3
  import { Badge } from "./ui/badge";
4
- import { Card, CardContent, CardFooter, CardHeader } from "./ui/card";
4
+ import { Button } from "./ui/button";
5
+ import { Card } from "./ui/card";
5
6
  import type { Bookmark } from "../lib/bookmarks";
6
7
  import { TOPIC_LABELS } from "../lib/topics";
7
8
 
@@ -10,52 +11,91 @@ type Props = {
10
11
  };
11
12
 
12
13
  const { bookmark } = Astro.props;
13
- const { id, url, author, postedAt, topic, summary, text, keyPoints } = bookmark;
14
+ const { id, url, author, postedAt, savedAt, topic, tldr, summary, text, keyPoints } = bookmark;
14
15
 
15
16
  const dateFormat = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone: "UTC" });
17
+ const isoDay = (date: Date) => date.toISOString().slice(0, 10);
18
+ const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
16
19
  ---
17
20
 
18
- <li id={`b-${id}`} data-bookmark-id={id}>
19
- <Card className="gap-4 py-5 shadow-xs ring-foreground/8 [--card-spacing:--spacing(5)] sm:[--card-spacing:--spacing(6)] sm:py-6">
20
- <CardHeader className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
21
- <p class="flex min-w-0 flex-wrap items-baseline gap-x-1.5 text-sm">
22
- <span class="font-medium text-foreground">{author.name}</span>
23
- <span class="text-muted-foreground">@{author.handle}</span>
24
- </p>
25
- <time datetime={postedAt.toISOString().slice(0, 10)} class="shrink-0 text-xs text-muted-foreground tabular-nums">
26
- {dateFormat.format(postedAt)}
27
- </time>
28
- </CardHeader>
21
+ <li id={`b-${id}`} data-bookmark-id={id} data-url={url} class="group/bookmark scroll-mt-6">
22
+ <Card className="gap-0 py-0 shadow-xs ring-foreground/8 [--card-spacing:--spacing(5)] has-[summary:focus-visible]:ring-2 has-[summary:focus-visible]:ring-ring sm:[--card-spacing:--spacing(6)]">
23
+ <details open class="group/details transition-opacity group-data-[done]/bookmark:opacity-55">
24
+ <summary class="flex cursor-pointer list-none flex-col gap-2 px-(--card-spacing) pt-(--card-spacing) pb-4 outline-none [&::-webkit-details-marker]:hidden">
25
+ <span class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 text-sm">
26
+ <span class="flex min-w-0 flex-wrap items-baseline gap-x-1.5">
27
+ <span class="font-medium text-foreground">{author.name}</span>
28
+ <span class="text-muted-foreground">@{author.handle}</span>
29
+ </span>
30
+ <span class="shrink-0 text-xs text-muted-foreground tabular-nums">
31
+ Saved <time datetime={savedAt.toISOString()}>{dateFormat.format(savedAt)}</time>
32
+ {postedOtherDay && (
33
+ <>
34
+ {" · "}Posted <time datetime={isoDay(postedAt)}>{dateFormat.format(postedAt)}</time>
35
+ </>
36
+ )}
37
+ </span>
38
+ </span>
39
+ <span
40
+ data-review-reason
41
+ class="text-xs font-medium tracking-wide text-muted-foreground uppercase"
42
+ hidden
43
+ ></span>
44
+ <span class="flex items-start justify-between gap-3">
45
+ <h2 class="font-heading text-xl leading-snug font-semibold text-balance text-foreground sm:text-[1.375rem]">
46
+ {tldr}
47
+ </h2>
48
+ <ChevronDownIcon
49
+ className="mt-1.5 size-4 shrink-0 text-muted-foreground transition-transform group-open/details:rotate-180"
50
+ aria-hidden="true"
51
+ />
52
+ </span>
53
+ </summary>
29
54
 
30
- <CardContent className="flex flex-col gap-4">
31
- <p class="font-serif text-[1.0625rem] leading-relaxed text-pretty text-foreground sm:text-lg">{summary}</p>
32
- {keyPoints.length > 0 && (
33
- <ul class="flex flex-col gap-2 border-l-2 border-border pl-4 text-[0.9375rem] leading-relaxed text-foreground/85">
34
- {keyPoints.map((point) => (
35
- <li class="text-pretty">{point}</li>
36
- ))}
37
- </ul>
38
- )}
39
- {text !== "" && (
40
- <figure class="border-t border-border pt-4">
41
- <figcaption class="mb-2 text-xs font-medium text-muted-foreground">Original post</figcaption>
42
- <blockquote class="text-sm leading-relaxed whitespace-pre-wrap text-pretty text-foreground/80">
43
- {text}
44
- </blockquote>
45
- </figure>
46
- )}
47
- </CardContent>
55
+ <div class="flex flex-col gap-4 px-(--card-spacing) pb-4">
56
+ <p class="font-serif text-[1.0625rem] leading-relaxed text-pretty text-foreground/90">{summary}</p>
57
+ {keyPoints.length > 0 && (
58
+ <ul class="flex flex-col gap-2 border-l-2 border-border pl-4 text-[0.9375rem] leading-relaxed text-foreground/85">
59
+ {keyPoints.map((point) => (
60
+ <li class="text-pretty">{point}</li>
61
+ ))}
62
+ </ul>
63
+ )}
64
+ {text !== "" && (
65
+ <figure class="border-t border-border pt-4">
66
+ <figcaption class="mb-2 text-xs font-medium text-muted-foreground">Original post</figcaption>
67
+ <blockquote class="text-sm leading-relaxed whitespace-pre-wrap text-pretty text-foreground/80">
68
+ {text}
69
+ </blockquote>
70
+ </figure>
71
+ )}
72
+ </div>
73
+ </details>
48
74
 
49
- <CardFooter className="justify-between gap-3 border-t-0 bg-transparent pt-1">
75
+ <div class="flex items-center justify-between gap-3 px-(--card-spacing) pb-(--card-spacing)">
50
76
  <Badge variant="secondary">{TOPIC_LABELS[topic]}</Badge>
51
- <a
52
- href={url}
53
- target="_blank"
54
- rel="noopener noreferrer"
55
- class="inline-flex items-center gap-1 rounded-sm text-sm font-medium text-foreground underline decoration-border underline-offset-4 transition-colors hover:decoration-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
56
- >
57
- Open on X<ArrowUpRightIcon className="size-3.5" aria-hidden="true" />
58
- </a>
59
- </CardFooter>
77
+ <div class="flex items-center gap-4">
78
+ <Button
79
+ type="button"
80
+ variant="outline"
81
+ size="sm"
82
+ data-done-toggle
83
+ aria-pressed="false"
84
+ title="Mark done (e)"
85
+ className="rounded-full aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground dark:aria-pressed:bg-primary dark:aria-pressed:hover:bg-primary/80"
86
+ >
87
+ <CheckIcon aria-hidden="true" />
88
+ Done
89
+ </Button>
90
+ <a
91
+ href={url}
92
+ target="_blank"
93
+ rel="noopener noreferrer"
94
+ class="inline-flex items-center gap-1 rounded-sm text-sm font-medium text-foreground underline decoration-border underline-offset-4 transition-colors hover:decoration-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
95
+ >
96
+ Open on X<ArrowUpRightIcon className="size-3.5" aria-hidden="true" />
97
+ </a>
98
+ </div>
99
+ </div>
60
100
  </Card>
61
101
  </li>
@@ -2,5 +2,5 @@ import { type SearchEntry } from "../lib/search.ts";
2
2
  type Props = {
3
3
  entries: SearchEntry[];
4
4
  };
5
- export declare function BookmarkFilters({ entries }: Props): import("react").JSX.Element;
5
+ export declare function BookmarkControls({ entries }: Props): import("react").JSX.Element;
6
6
  export {};
@@ -0,0 +1,226 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ChevronsDownUpIcon, ChevronsUpDownIcon, SearchIcon } from "lucide-react";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { Button } from "./ui/button.js";
5
+ import { Input } from "./ui/input.js";
6
+ import { filterSearch, parseFilter, VIEWS } from "../lib/filter.js";
7
+ import { dailyReview, localDay } from "../lib/review.js";
8
+ import { matchingIds } from "../lib/search.js";
9
+ import { TOPIC_LABELS, TOPICS } from "../lib/topics.js";
10
+ const STORAGE_PREFIX = `grok-digest:${import.meta.env.BASE_URL}:`;
11
+ const DONE_KEY = `${STORAGE_PREFIX}done`;
12
+ const REVIEW_KEY = `${STORAGE_PREFIX}review`;
13
+ const COLLAPSED_KEY = `${STORAGE_PREFIX}collapsed`;
14
+ const VIEW_LABELS = { today: "Today", inbox: "Inbox", done: "Done", all: "All" };
15
+ const FORM_FIELDS = "input, textarea, select, [contenteditable]";
16
+ function readStored(key) {
17
+ try {
18
+ const raw = window.localStorage.getItem(key);
19
+ return raw === null ? null : JSON.parse(raw);
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function writeStored(key, value) {
26
+ try {
27
+ window.localStorage.setItem(key, JSON.stringify(value));
28
+ }
29
+ catch { }
30
+ }
31
+ function isStringArray(value) {
32
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
33
+ }
34
+ function isStoredReview(value) {
35
+ if (typeof value !== "object" || value === null || !("day" in value) || !("picks" in value))
36
+ return false;
37
+ const { day, picks } = value;
38
+ return (typeof day === "string" &&
39
+ Array.isArray(picks) &&
40
+ picks.every((pick) => typeof pick === "object" &&
41
+ pick !== null &&
42
+ "id" in pick &&
43
+ "reason" in pick &&
44
+ typeof pick.id === "string" &&
45
+ typeof pick.reason === "string"));
46
+ }
47
+ function storedDone() {
48
+ const stored = readStored(DONE_KEY);
49
+ return new Set(isStringArray(stored) ? stored : []);
50
+ }
51
+ function todaysReview(entries, done) {
52
+ const today = localDay(new Date());
53
+ const stored = readStored(REVIEW_KEY);
54
+ if (isStoredReview(stored) && stored.day === today) {
55
+ const ids = new Set(entries.map(({ id }) => id));
56
+ return stored.picks.filter(({ id }) => ids.has(id));
57
+ }
58
+ const picks = dailyReview({ bookmarks: entries, done, today });
59
+ writeStored(REVIEW_KEY, { day: today, picks });
60
+ return picks;
61
+ }
62
+ function linkedCard() {
63
+ const target = window.location.hash.slice(1);
64
+ return target === "" ? null : document.querySelector(`[data-bookmark-id][id="${CSS.escape(target)}"]`);
65
+ }
66
+ function initialFilter() {
67
+ const filter = parseFilter(window.location.search);
68
+ return linkedCard() === null ? filter : { ...filter, view: "all" };
69
+ }
70
+ function cards() {
71
+ return [...document.querySelectorAll("[data-bookmark-id]")];
72
+ }
73
+ function focusCard(card) {
74
+ if (card === undefined)
75
+ return;
76
+ card.querySelector("summary")?.focus({ preventScroll: true });
77
+ card.scrollIntoView({ block: "start" });
78
+ }
79
+ function plural(count, noun) {
80
+ return `${count} ${count === 1 ? noun : `${noun}s`}`;
81
+ }
82
+ export function BookmarkControls({ entries }) {
83
+ const [filter, setFilter] = useState(initialFilter);
84
+ const [done, setDone] = useState(storedDone);
85
+ const [review] = useState(() => todaysReview(entries, storedDone()));
86
+ const [collapsed, setCollapsed] = useState(() => readStored(COLLAPSED_KEY) === true);
87
+ const searchRef = useRef(null);
88
+ const { query, topic, view } = filter;
89
+ const searching = query.trim() !== "";
90
+ const reviewIds = useMemo(() => new Set(review.map(({ id }) => id)), [review]);
91
+ const reasons = useMemo(() => new Map(review.map(({ id, reason }) => [id, reason])), [review]);
92
+ const visible = useMemo(() => matchingIds({ entries, filter, triage: { done, review: reviewIds } }), [entries, filter, done, reviewIds]);
93
+ const topicsInUse = useMemo(() => TOPICS.filter((t) => entries.some((entry) => entry.topic === t)), [entries]);
94
+ const doneCount = entries.filter(({ id }) => done.has(id)).length;
95
+ const reviewedCount = review.filter(({ id }) => done.has(id)).length;
96
+ const counts = {
97
+ today: review.length,
98
+ inbox: entries.length - doneCount,
99
+ done: doneCount,
100
+ all: entries.length,
101
+ };
102
+ useEffect(() => {
103
+ const showReasons = view === "today" && !searching;
104
+ for (const card of cards()) {
105
+ const id = card.dataset["bookmarkId"] ?? "";
106
+ card.hidden = !visible.has(id);
107
+ card.toggleAttribute("data-done", done.has(id));
108
+ card.querySelector("[data-done-toggle]")?.setAttribute("aria-pressed", String(done.has(id)));
109
+ const reason = card.querySelector("[data-review-reason]");
110
+ const text = showReasons ? reasons.get(id) : undefined;
111
+ if (reason !== null) {
112
+ reason.hidden = text === undefined;
113
+ reason.textContent = text ?? "";
114
+ }
115
+ }
116
+ }, [visible, done, reasons, view, searching]);
117
+ useEffect(() => {
118
+ for (const details of document.querySelectorAll("[data-bookmark-id] details")) {
119
+ details.open = !collapsed;
120
+ }
121
+ writeStored(COLLAPSED_KEY, collapsed);
122
+ }, [collapsed]);
123
+ useEffect(() => {
124
+ writeStored(DONE_KEY, [...done]);
125
+ }, [done]);
126
+ useEffect(() => {
127
+ const next = `${window.location.pathname}${filterSearch(filter)}${window.location.hash}`;
128
+ const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
129
+ if (current !== next)
130
+ window.history.replaceState(null, "", next);
131
+ }, [filter]);
132
+ useEffect(() => {
133
+ document.querySelector("[data-bookmark-list]")?.toggleAttribute("data-ready", true);
134
+ const linked = linkedCard();
135
+ linked?.querySelector("details")?.setAttribute("open", "");
136
+ linked?.scrollIntoView({ block: "start" });
137
+ }, []);
138
+ useEffect(() => {
139
+ const toggleDone = (card) => {
140
+ const id = card.dataset["bookmarkId"] ?? "";
141
+ setDone((previous) => {
142
+ const next = new Set(previous);
143
+ if (!next.delete(id))
144
+ next.add(id);
145
+ return next;
146
+ });
147
+ };
148
+ const onClick = (event) => {
149
+ if (!(event.target instanceof Element))
150
+ return;
151
+ const card = event.target.closest("[data-done-toggle]")?.closest("[data-bookmark-id]");
152
+ if (card !== null && card !== undefined)
153
+ toggleDone(card);
154
+ };
155
+ const onKeyDown = (event) => {
156
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey)
157
+ return;
158
+ if (event.target instanceof HTMLElement && event.target.closest(FORM_FIELDS) !== null) {
159
+ if (event.key === "Escape")
160
+ event.target.blur();
161
+ return;
162
+ }
163
+ const shown = cards().filter((card) => !card.hidden);
164
+ const current = document.activeElement?.closest("[data-bookmark-id]") ?? null;
165
+ const index = current === null ? -1 : shown.indexOf(current);
166
+ switch (event.key) {
167
+ case "j":
168
+ focusCard(shown[Math.min(index + 1, shown.length - 1)]);
169
+ break;
170
+ case "k":
171
+ focusCard(shown[Math.max(index - 1, 0)]);
172
+ break;
173
+ case "e": {
174
+ if (current === null)
175
+ return;
176
+ const neighbor = shown[index + 1] ?? shown[index - 1];
177
+ toggleDone(current);
178
+ setTimeout(() => {
179
+ if (current.hidden)
180
+ focusCard(neighbor);
181
+ });
182
+ break;
183
+ }
184
+ case "o": {
185
+ const url = current?.dataset["url"];
186
+ if (url !== undefined)
187
+ window.open(url, "_blank", "noopener,noreferrer");
188
+ break;
189
+ }
190
+ case "/":
191
+ searchRef.current?.focus();
192
+ break;
193
+ default:
194
+ return;
195
+ }
196
+ event.preventDefault();
197
+ };
198
+ document.addEventListener("click", onClick);
199
+ document.addEventListener("keydown", onKeyDown);
200
+ return () => {
201
+ document.removeEventListener("click", onClick);
202
+ document.removeEventListener("keydown", onKeyDown);
203
+ };
204
+ }, []);
205
+ const update = (change) => setFilter((previous) => ({ ...previous, ...change }));
206
+ return (_jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs("div", { className: "relative", children: [_jsx(SearchIcon, { className: "pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, type: "search", value: query, onChange: (event) => update({ query: event.target.value }), placeholder: "Search all bookmarks", "aria-label": "Search bookmarks", className: "h-10 rounded-full bg-card pl-9 dark:bg-card" })] }), _jsx("fieldset", { "aria-label": "Choose a view", className: "flex w-fit min-w-0 flex-wrap gap-1 rounded-full bg-muted p-1", children: VIEWS.map((v) => (_jsxs("button", { type: "button", "aria-pressed": view === v && !searching, onClick: () => update({ view: v, query: "" }), className: "inline-flex h-8 items-center gap-1.5 rounded-full px-3.5 text-sm font-medium text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 aria-pressed:bg-card aria-pressed:text-foreground aria-pressed:shadow-xs", children: [VIEW_LABELS[v], _jsx("span", { className: "text-xs text-muted-foreground tabular-nums", children: counts[v] })] }, v))) }), _jsxs("fieldset", { "aria-label": "Filter by topic", className: "flex min-w-0 flex-wrap gap-2", children: [_jsx(TopicChip, { label: "All topics", pressed: topic === null, onClick: () => update({ topic: null }) }), topicsInUse.map((t) => (_jsx(TopicChip, { label: TOPIC_LABELS[t], pressed: topic === t, onClick: () => update({ topic: topic === t ? null : t }) }, t)))] }), _jsxs("div", { className: "flex flex-wrap items-center justify-between gap-x-4 gap-y-2", children: [_jsx("p", { "aria-live": "polite", className: "text-sm text-muted-foreground", children: _jsx(Status, { view: view, searching: searching, visible: visible.size, reviewed: reviewedCount, reviewSize: review.length, inbox: counts.inbox }) }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsxs("p", { className: "hidden text-xs text-muted-foreground sm:block", children: [_jsx("kbd", { children: "j" }), "/", _jsx("kbd", { children: "k" }), " move \u00B7 ", _jsx("kbd", { children: "e" }), " done \u00B7 ", _jsx("kbd", { children: "o" }), " open \u00B7 ", _jsx("kbd", { children: "/" }), " search"] }), _jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setCollapsed(!collapsed), children: [collapsed ? _jsx(ChevronsUpDownIcon, { "aria-hidden": "true" }) : _jsx(ChevronsDownUpIcon, { "aria-hidden": "true" }), collapsed ? "Expand all" : "Collapse all"] })] })] })] }));
207
+ }
208
+ function Status({ view, searching, visible, reviewed, reviewSize, inbox, }) {
209
+ if (searching)
210
+ return visible === 0 ? "No bookmarks match your search." : `${plural(visible, "result")} in all bookmarks`;
211
+ if (view === "today") {
212
+ if (reviewSize === 0)
213
+ return `Nothing to review today. ${plural(inbox, "bookmark")} in your inbox.`;
214
+ if (reviewed === reviewSize)
215
+ return "All caught up for today. Come back tomorrow.";
216
+ return `${reviewed} of ${reviewSize} reviewed today`;
217
+ }
218
+ if (view === "inbox" && inbox === 0)
219
+ return "Inbox zero. Everything is done.";
220
+ if (view === "done" && visible === 0)
221
+ return "Nothing marked done yet.";
222
+ return plural(visible, "bookmark");
223
+ }
224
+ function TopicChip({ label, pressed, onClick }) {
225
+ return (_jsx(Button, { type: "button", size: "sm", variant: pressed ? "default" : "outline", "aria-pressed": pressed, onClick: onClick, className: "rounded-full px-3", children: label }));
226
+ }
@@ -2,7 +2,4 @@ import * as React from "react";
2
2
  declare function Card({ className, size, ...props }: React.ComponentProps<"div"> & {
3
3
  size?: "default" | "sm";
4
4
  }): React.JSX.Element;
5
- declare function CardHeader({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
6
- declare function CardContent({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
7
- declare function CardFooter({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
8
- export { Card, CardHeader, CardFooter, CardContent };
5
+ export { Card };
@@ -4,13 +4,4 @@ import { cn } from "cn";
4
4
  function Card({ className, size = "default", ...props }) {
5
5
  return (_jsx("div", { "data-slot": "card", "data-size": size, className: cn("group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", className), ...props }));
6
6
  }
7
- function CardHeader({ className, ...props }) {
8
- return (_jsx("div", { "data-slot": "card-header", className: cn("group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)", className), ...props }));
9
- }
10
- function CardContent({ className, ...props }) {
11
- return _jsx("div", { "data-slot": "card-content", className: cn("px-(--card-spacing)", className), ...props });
12
- }
13
- function CardFooter({ className, ...props }) {
14
- return (_jsx("div", { "data-slot": "card-footer", className: cn("flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)", className), ...props }));
15
- }
16
- export { Card, CardHeader, CardFooter, CardContent };
7
+ export { Card };
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ const ROUTES = [
16
16
  ];
17
17
  const RENDERER_PACKAGES = ["@astrojs/react", "react", "react-dom"];
18
18
  const PACKAGE_ENTRY = fileURLToPath(import.meta.url);
19
+ const PACKAGE_ROOT = path.resolve(path.dirname(PACKAGE_ENTRY), "..");
19
20
  const isRendererImport = (id) => RENDERER_PACKAGES.some((name) => id === name || id.startsWith(`${name}/`));
20
21
  function replaceInPlace(list, replacement) {
21
22
  list.splice(0, list.length, ...replacement);
@@ -47,6 +48,14 @@ function rendererFromPackage() {
47
48
  },
48
49
  };
49
50
  }
51
+ function servePackageFiles() {
52
+ return {
53
+ name: "grok-digest:serve-package-files",
54
+ configResolved(config) {
55
+ config.server.fs.allow.push(PACKAGE_ROOT);
56
+ },
57
+ };
58
+ }
50
59
  function bookmarksModule({ file, source }) {
51
60
  return {
52
61
  name: "grok-digest:bookmarks",
@@ -70,7 +79,9 @@ export default function grokDigest({ dataDir = "data" } = {}) {
70
79
  const source = path.relative(root, file).split(path.sep).join("/");
71
80
  updateConfig({
72
81
  integrations: [react()],
73
- vite: { plugins: [rendererFromPackage(), tailwindcss(), bookmarksModule({ file, source })] },
82
+ vite: {
83
+ plugins: [rendererFromPackage(), servePackageFiles(), tailwindcss(), bookmarksModule({ file, source })],
84
+ },
74
85
  });
75
86
  for (const { pattern, entrypoint } of ROUTES) {
76
87
  injectRoute({ pattern, entrypoint: new URL(entrypoint, import.meta.url) });
@@ -8,6 +8,7 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
8
8
  handle: z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>, z.ZodString>;
9
9
  }, z.core.$strict>;
10
10
  posted_at: z.ZodUnion<readonly [z.ZodISODate, z.ZodISODateTime]>;
11
+ saved_at: z.ZodISODateTime;
11
12
  topic: z.ZodEnum<{
12
13
  "ai-agents": "ai-agents";
13
14
  "models-releases": "models-releases";
@@ -17,11 +18,13 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
17
18
  design: "design";
18
19
  other: "other";
19
20
  }>;
21
+ tldr: z.ZodString;
20
22
  summary: z.ZodString;
21
23
  text: z.ZodString;
22
24
  key_points: z.ZodArray<z.ZodString>;
23
25
  }, z.core.$strict>, z.ZodTransform<{
24
26
  postedAt: Date;
27
+ savedAt: Date;
25
28
  keyPoints: string[];
26
29
  id: string;
27
30
  url: string;
@@ -30,6 +33,7 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
30
33
  handle: string;
31
34
  };
32
35
  topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
36
+ tldr: string;
33
37
  summary: string;
34
38
  text: string;
35
39
  }, {
@@ -40,7 +44,9 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
40
44
  handle: string;
41
45
  };
42
46
  posted_at: string;
47
+ saved_at: string;
43
48
  topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
49
+ tldr: string;
44
50
  summary: string;
45
51
  text: string;
46
52
  key_points: string[];
@@ -53,6 +59,7 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
53
59
  handle: z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>, z.ZodString>;
54
60
  }, z.core.$strict>;
55
61
  posted_at: z.ZodUnion<readonly [z.ZodISODate, z.ZodISODateTime]>;
62
+ saved_at: z.ZodISODateTime;
56
63
  topic: z.ZodEnum<{
57
64
  "ai-agents": "ai-agents";
58
65
  "models-releases": "models-releases";
@@ -62,11 +69,13 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
62
69
  design: "design";
63
70
  other: "other";
64
71
  }>;
72
+ tldr: z.ZodString;
65
73
  summary: z.ZodString;
66
74
  text: z.ZodString;
67
75
  key_points: z.ZodArray<z.ZodString>;
68
76
  }, z.core.$strict>, z.ZodTransform<{
69
77
  postedAt: Date;
78
+ savedAt: Date;
70
79
  keyPoints: string[];
71
80
  id: string;
72
81
  url: string;
@@ -75,6 +84,7 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
75
84
  handle: string;
76
85
  };
77
86
  topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
87
+ tldr: string;
78
88
  summary: string;
79
89
  text: string;
80
90
  }, {
@@ -85,7 +95,9 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
85
95
  handle: string;
86
96
  };
87
97
  posted_at: string;
98
+ saved_at: string;
88
99
  topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
100
+ tldr: string;
89
101
  summary: string;
90
102
  text: string;
91
103
  key_points: string[];
@@ -4,6 +4,7 @@ export const SCHEMA_VERSION = 1;
4
4
  const X_HOSTNAME = /^(www\.|mobile\.)?(x|twitter)\.com$/;
5
5
  const X_HANDLE = /^[A-Za-z0-9_]{1,15}$/;
6
6
  const nonEmpty = z.string().trim().min(1);
7
+ const instant = z.iso.datetime({ offset: true });
7
8
  export const bookmarkSchema = z
8
9
  .strictObject({
9
10
  id: nonEmpty,
@@ -16,15 +17,18 @@ export const bookmarkSchema = z
16
17
  .transform((handle) => handle.replace(/^@/, ""))
17
18
  .pipe(z.string().regex(X_HANDLE, "must be an X handle such as @jack")),
18
19
  }),
19
- posted_at: z.union([z.iso.date(), z.iso.datetime({ offset: true })]),
20
+ posted_at: z.union([z.iso.date(), instant]),
21
+ saved_at: instant,
20
22
  topic: z.enum(TOPICS),
23
+ tldr: nonEmpty,
21
24
  summary: nonEmpty,
22
25
  text: z.string().trim(),
23
26
  key_points: z.array(nonEmpty),
24
27
  })
25
- .transform(({ posted_at, key_points, ...rest }) => ({
28
+ .transform(({ posted_at, saved_at, key_points, ...rest }) => ({
26
29
  ...rest,
27
30
  postedAt: new Date(posted_at),
31
+ savedAt: new Date(saved_at),
28
32
  keyPoints: key_points,
29
33
  }));
30
34
  export const bookmarksFileSchema = z.array(bookmarkSchema).superRefine((bookmarks, ctx) => {
@@ -45,5 +49,5 @@ export function parseDataFile({ schema, raw, source, }) {
45
49
  return result.data;
46
50
  }
47
51
  export function parseBookmarks({ raw, source }) {
48
- return parseDataFile({ schema: bookmarksFileSchema, raw, source }).toSorted((a, b) => b.postedAt.getTime() - a.postedAt.getTime());
52
+ return parseDataFile({ schema: bookmarksFileSchema, raw, source }).toSorted((a, b) => b.savedAt.getTime() - a.savedAt.getTime());
49
53
  }
package/dist/lib/feed.js CHANGED
@@ -25,13 +25,13 @@ export function topicFromParam(value) {
25
25
  return value;
26
26
  }
27
27
  function feedItem(bookmark, page) {
28
- const { author, summary, text, keyPoints, topic, url, postedAt, id } = bookmark;
28
+ const { author, tldr, summary, text, keyPoints, topic, url, savedAt, id } = bookmark;
29
29
  const link = new URL(page.href);
30
30
  link.hash = `b-${id}`;
31
31
  const points = keyPoints.map((point) => `<li>${escapeHtml(point)}</li>`).join("");
32
32
  const quote = text === "" ? "" : `<blockquote><p>${escapeHtml(text)}</p></blockquote>`;
33
33
  return {
34
- title: summary,
34
+ title: tldr,
35
35
  link: link.href,
36
36
  description: [`@${author.handle}`, summary, text].filter((part) => part !== "").join("\n\n"),
37
37
  content: [
@@ -43,7 +43,7 @@ function feedItem(bookmark, page) {
43
43
  ]
44
44
  .filter((part) => part !== "")
45
45
  .join(""),
46
- pubDate: postedAt,
46
+ pubDate: savedAt,
47
47
  categories: [TOPIC_LABELS[topic]],
48
48
  };
49
49
  }
@@ -1,10 +1,11 @@
1
1
  import { type Topic } from "./topics.ts";
2
- export declare function isTopic(value: string | null | undefined): value is Topic;
3
- export declare function parseFilter(search: string): {
2
+ export declare const VIEWS: readonly ["today", "inbox", "done", "all"];
3
+ export type View = (typeof VIEWS)[number];
4
+ export type Filter = {
4
5
  query: string;
5
6
  topic: Topic | null;
7
+ view: View;
6
8
  };
7
- export declare function filterSearch({ query, topic }: {
8
- query: string;
9
- topic: Topic | null;
10
- }): string;
9
+ export declare function isTopic(value: string | null | undefined): value is Topic;
10
+ export declare function parseFilter(search: string): Filter;
11
+ export declare function filterSearch({ query, topic, view }: Filter): string;