grok-digest 0.1.0 → 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.
- package/LICENSE-THIRD-PARTY +24 -0
- package/README.md +12 -18
- package/dist/components/BookmarkCard.astro +82 -42
- package/dist/components/{BookmarkFilters.d.ts → BookmarkControls.d.ts} +1 -1
- package/dist/components/BookmarkControls.js +226 -0
- package/dist/components/ui/card.d.ts +1 -4
- package/dist/components/ui/card.js +1 -10
- package/dist/index.js +12 -1
- package/dist/lib/bookmarks.d.ts +12 -0
- package/dist/lib/bookmarks.js +7 -3
- package/dist/lib/feed.js +3 -3
- package/dist/lib/filter.d.ts +7 -6
- package/dist/lib/filter.js +10 -1
- package/dist/lib/load-bookmarks.d.ts +2 -0
- package/dist/lib/review.d.ts +17 -0
- package/dist/lib/review.js +50 -0
- package/dist/lib/search.d.ts +9 -3
- package/dist/lib/search.js +11 -4
- package/dist/pages/index.astro +11 -3
- package/dist/styles/global.css +1 -1
- package/dist/styles/shadcn-tailwind.css +529 -0
- package/package.json +3 -4
- package/CHANGELOG.md +0 -10
- package/dist/components/BookmarkFilters.js +0 -32
|
@@ -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
|
|
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
|
-
|
|
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
|
|
|
@@ -101,27 +101,21 @@ bun run format
|
|
|
101
101
|
|
|
102
102
|
## Publish
|
|
103
103
|
|
|
104
|
-
Releases are
|
|
104
|
+
Releases are cut from GitHub Actions. Open **Actions** > **Prepare Release** > **Run workflow** and choose:
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
| Input | Meaning |
|
|
107
|
+
| --------- | ------------------------------------------------------------------------------ |
|
|
108
|
+
| `bump` | `patch`, `minor`, or `major`, applied to the version in `package.json`. |
|
|
109
|
+
| `version` | Optional. An exact version such as `0.2.0`, without the `v`. Overrides `bump`. |
|
|
110
|
+
| `dry_run` | Run every check on the new version and stop before changing Git. |
|
|
109
111
|
|
|
110
|
-
|
|
111
|
-
git tag v0.1.0
|
|
112
|
-
git push origin v0.1.0
|
|
113
|
-
```
|
|
112
|
+
`prepare-release.yml` refuses a version that is not newer, whose tag exists, or that is already on npm. It sets the new version, runs `bun run check`, commits `release: v<version>` to `main`, pushes the commit and the `v<version>` tag together, and starts `publish.yml` on that tag.
|
|
114
113
|
|
|
115
|
-
|
|
114
|
+
`publish.yml` checks the tag again, packs the tarball, publishes that exact tarball to npm with provenance, and creates a GitHub release with generated notes and the tarball attached. Rerunning it for a tag that is already on npm skips the publish and finishes the release.
|
|
116
115
|
|
|
117
|
-
npm
|
|
116
|
+
npm accepts the publish through trusted publishing, so the repository holds no npm token. On npmjs.com, the package's **Settings** list `kenryu42/grok-digest` with workflow `publish.yml` as a trusted publisher.
|
|
118
117
|
|
|
119
|
-
|
|
120
|
-
npm login
|
|
121
|
-
bun install --frozen-lockfile
|
|
122
|
-
bun run check
|
|
123
|
-
npm publish --provenance=false
|
|
124
|
-
```
|
|
118
|
+
Version 0.1.0 was published from a maintainer's machine, because npm offers trusted publishing only for a package that already exists.
|
|
125
119
|
|
|
126
120
|
## License
|
|
127
121
|
|
|
@@ -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 {
|
|
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-
|
|
20
|
-
<
|
|
21
|
-
<
|
|
22
|
-
<span class="
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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) });
|
package/dist/lib/bookmarks.d.ts
CHANGED
|
@@ -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[];
|
package/dist/lib/bookmarks.js
CHANGED
|
@@ -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(),
|
|
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.
|
|
52
|
+
return parseDataFile({ schema: bookmarksFileSchema, raw, source }).toSorted((a, b) => b.savedAt.getTime() - a.savedAt.getTime());
|
|
49
53
|
}
|