diff --git a/bruno/bruno.json b/bruno/bruno.json new file mode 100644 index 0000000..565cc3e --- /dev/null +++ b/bruno/bruno.json @@ -0,0 +1,6 @@ +{ + "version": "1", + "name": "bruno", + "type": "collection", + "ignore": ["node_modules", ".git"] +} diff --git a/bruno/collection.bru b/bruno/collection.bru new file mode 100644 index 0000000..e3e7518 --- /dev/null +++ b/bruno/collection.bru @@ -0,0 +1,4 @@ +meta { + name: bruno + type: collection +} diff --git a/bruno/query-posts.bru b/bruno/query-posts.bru new file mode 100644 index 0000000..d0361ff --- /dev/null +++ b/bruno/query-posts.bru @@ -0,0 +1,27 @@ +meta { + name: Query Posts + type: http + seq: 1 +} + +post { + url: http://localhost:4321/api/posts + body: json + auth: none +} + +body:json { + { + "filter": { + "keyword": "scouting" + }, + "sort": { + "mode": "date", + "direction": "desc" + }, + "paginate": { + "page": 1, + "pageSize": 20 + } + } +} diff --git a/package.json b/package.json index 3895ac8..b6cc25b 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,13 @@ "@fortawesome/free-brands-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/react-fontawesome": "^0.2.0", + "@nanostores/persistent": "^1.3.3", "@tailwindcss/vite": "^4.2.1", "@upstash/redis": "^1.36.4", "astro": "^6.0.3", "feedsmith": "2.9.0", "he": "^1.2.0", + "nanostores": "^1.1.1", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwindcss": "^4.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05ed576..754a0d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@fortawesome/react-fontawesome': specifier: ^0.2.0 version: 0.2.6(@fortawesome/fontawesome-svg-core@7.2.0)(react@19.2.4) + '@nanostores/persistent': + specifier: ^1.3.3 + version: 1.3.3(nanostores@1.1.1) '@tailwindcss/vite': specifier: ^4.2.1 version: 4.2.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -47,6 +50,9 @@ importers: he: specifier: ^1.2.0 version: 1.2.0 + nanostores: + specifier: ^1.1.1 + version: 1.1.1 react: specifier: ^19.2.4 version: 19.2.4 @@ -688,6 +694,12 @@ packages: engines: {node: '>=18'} hasBin: true + '@nanostores/persistent@1.3.3': + resolution: {integrity: sha512-+b4I8xrmjhKE3hQ9V7/b4Xa+MBMkM2P4Ulv33zFEF/+2Hucsb24vTjYiWR8R97y8YdRptmRKlL5Qwy0q1Jj5nQ==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + nanostores: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0 + '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} @@ -2218,6 +2230,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.1.1: + resolution: {integrity: sha512-EYJqS25r2iBeTtGQCHidXl1VfZ1jXM7Q04zXJOrMlxVVmD0ptxJaNux92n1mJ7c5lN3zTq12MhH/8x59nP+qmg==} + engines: {node: ^20.0.0 || >=22.0.0} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3683,6 +3699,10 @@ snapshots: - encoding - supports-color + '@nanostores/persistent@1.3.3(nanostores@1.1.1)': + dependencies: + nanostores: 1.1.1 + '@napi-rs/wasm-runtime@1.1.1': dependencies: '@emnapi/core': 1.8.1 @@ -5454,6 +5474,8 @@ snapshots: nanoid@3.3.11: {} + nanostores@1.1.1: {} + natural-compare@1.4.0: {} neotraverse@0.6.18: {} diff --git a/public/xslt/opml.xslt b/public/xslt/opml.xslt index 59bd3a7..23eeb63 100644 --- a/public/xslt/opml.xslt +++ b/public/xslt/opml.xslt @@ -9,11 +9,26 @@ +

+
+ + + Open in the (requires JavaScript). +
+

- First - Prev - {page.currentPage} of {page.lastPage} - Next - Last -
- diff --git a/src/pages/pulse/browse/_page.tsx b/src/pages/pulse/browse/_page.tsx new file mode 100644 index 0000000..ed50cef --- /dev/null +++ b/src/pages/pulse/browse/_page.tsx @@ -0,0 +1,31 @@ +import { CardList } from "@/components/react/cardList"; +import { RenderPost as PostComponent } from "@/components/react/post"; +import { queryPosts } from "@/features/postsQuery/query"; + +export async function BrowsePage() { + const results = await queryPosts({ + filter: {}, + sort: { + mode: "date", + direction: "desc", + }, + paginate: { + maxPageSize: 100, + page: 1, + }, + }); + return ( + <> + + Showing posts {results.pagination.firstItemIndex + 1} -{" "} + {results.pagination.lastItemIndex + 1} of{" "} + {results.pagination.totalItems}. + + + {results.items.map((post) => ( + + ))} + + + ); +} diff --git a/src/pages/pulse/browse/index.astro b/src/pages/pulse/browse/index.astro new file mode 100644 index 0000000..c539a8d --- /dev/null +++ b/src/pages/pulse/browse/index.astro @@ -0,0 +1,8 @@ +--- +import UiLayout from "@/components/layout/UiLayout.astro"; +import { BrowsePage } from "@/pages/pulse/browse/_page"; +--- + + + + diff --git a/src/pages/pulse/sources/[slug]/index.astro b/src/pages/pulse/sources/[slug]/index.astro index 4b12d6c..6461857 100644 --- a/src/pages/pulse/sources/[slug]/index.astro +++ b/src/pages/pulse/sources/[slug]/index.astro @@ -21,15 +21,16 @@ if (!feed) throw new Error(`Feed ${slug} not found`);

{feed.description}

Homepage: {feed.homepageUrl}{feed.urls.homepage}

Indexed posts: {(await feed.posts()).length}

- Subcribe: rss + Subcribe: rss + atom

diff --git a/src/pages/pulse/sources/index.astro b/src/pages/pulse/sources/index.astro index fb9fecd..b422c0d 100644 --- a/src/pages/pulse/sources/index.astro +++ b/src/pages/pulse/sources/index.astro @@ -5,14 +5,14 @@ import { FeedManager } from "@/features/feeds/feedManager";
-

Scouting411 News Sources

+

News Sources

{ FeedManager.feeds.map(async (feed) => (
{feed.name} diff --git a/src/pages/pulse/stats/index.astro b/src/pages/pulse/stats/index.astro index a0e6af3..a1962a3 100644 --- a/src/pages/pulse/stats/index.astro +++ b/src/pages/pulse/stats/index.astro @@ -4,48 +4,53 @@ import { FeedManager } from "@/features/feeds/feedManager"; --- - - - - - - - - - - { - FeedManager.feeds.map(async (feed) => ( - - - - - - )) - } - - - - - - - - -
FeedPostsAdapter Type
- - {feed.name} - - - {Intl.NumberFormat().format((await feed.posts()).length)} - {feed.type.human}
Total: - { - Intl.NumberFormat().format((await FeedManager.allPosts()).length) - } - {}
+
+

Feed Statistics

+ + + + + + + + + + { + FeedManager.feeds.map(async (feed) => ( + + + + + + )) + } + + + + + + + + +
FeedPostsAdapter Type
+ + {feed.name} + + + {Intl.NumberFormat().format((await feed.posts()).length)} + {feed.type.human}
Total: + { + Intl.NumberFormat().format( + (await FeedManager.allPosts()).length, + ) + } + {}
+
diff --git a/src/pages/pulse/subscribe/index.astro b/src/pages/pulse/subscribe/index.astro index 79dc922..f657cf8 100644 --- a/src/pages/pulse/subscribe/index.astro +++ b/src/pages/pulse/subscribe/index.astro @@ -5,13 +5,13 @@ import { FeedManager } from "@/features/feeds/feedManager";
-

Subscribe to Scouting411 Pulse

+

Subscribe with your Feed Reader

- Although the upstream sources on Pulse are published in a variety of - different formats, we have gone to the trouble of re-exporting every - source as a non-paginated RSS feed for you. If you don't know what RSS is, - you can learn more at

- diff --git a/src/stores/postsQuery.ts b/src/stores/postsQuery.ts new file mode 100644 index 0000000..03b1747 --- /dev/null +++ b/src/stores/postsQuery.ts @@ -0,0 +1,19 @@ +import { persistentJSON } from "@nanostores/persistent"; +import type { QueryOpts } from "@/features/postsQuery/query"; + +const defaultQueryOpts: QueryOpts = { + filter: {}, + sort: { + mode: "date", + direction: "desc", + }, + paginate: { + page: 1, + maxPageSize: 20, + }, +}; + +export const $postsQuery = persistentJSON( + "postsQuery", + defaultQueryOpts, +); diff --git a/src/util/paginate.ts b/src/util/paginateArray.ts similarity index 59% rename from src/util/paginate.ts rename to src/util/paginateArray.ts index e0f06f3..9b661a0 100644 --- a/src/util/paginate.ts +++ b/src/util/paginateArray.ts @@ -1,13 +1,15 @@ import { z } from "astro/zod"; /** paginate an array of items */ -export function paginate( +export function paginateArray( data: T[], opts: PaginateOpts, ): PaginatedResults { - const firstItemIndex = (opts.page - 1) * opts.pageSize; - const lastItemIndex = firstItemIndex + opts.pageSize - 1; - const totalPages = Math.ceil(data.length / opts.pageSize); + const firstItemIndex = (opts.page - 1) * opts.maxPageSize; + const lastItemIndex = firstItemIndex + opts.maxPageSize - 1; + /** if the last item would be greater than the length of the array, set it to the last index */ + const realLastItemIndex = Math.min(lastItemIndex, data.length - 1); + const totalPages = Math.ceil(data.length / opts.maxPageSize); const items = data.slice(firstItemIndex, lastItemIndex + 1); @@ -15,9 +17,10 @@ export function paginate( items, pagination: { page: opts.page, - pageSize: opts.pageSize, + maxPageSize: opts.maxPageSize, + pageSize: items.length, firstItemIndex, - lastItemIndex, + lastItemIndex: realLastItemIndex, totalItems: data.length, totalPages, }, @@ -26,10 +29,10 @@ export function paginate( type PaginateOpts = z.infer; export const paginateOptsSchema = z.object({ - /** the page size */ - pageSize: z.number().min(1), + /** the maximum page size */ + maxPageSize: z.coerce.number().min(1), /** the page number */ - page: z.number().min(1), + page: z.coerce.number().min(1), }); export type PaginatedResults = { @@ -41,6 +44,8 @@ type PaginationResultsMetadata = { /** the current page number */ page: number; /** the maximum number of items per page */ + maxPageSize: number; + /** the number of items on the current page */ pageSize: number; /** the start index of the items on this page */ firstItemIndex: number; diff --git a/src/util/utilTypes.ts b/src/util/utilTypes.ts index f15465f..fdbe7f2 100644 --- a/src/util/utilTypes.ts +++ b/src/util/utilTypes.ts @@ -1 +1,4 @@ export type UrlShaped = `${"http" | "https"}://${string}`; + +/** a predicate function that takes an item and returns a boolean */ +export type Predicate = (item: T) => boolean;