From 65553b8a3185fc29e4fbbf4c453ad202b7b4f349 Mon Sep 17 00:00:00 2001 From: Haydn Ewers <27211+haydn@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:41:21 +1000 Subject: [PATCH] display all items in a single list --- src/app/Dialog.tsx | 49 ---- src/app/List.tsx | 192 +++++-------- src/app/Source.tsx | 66 +++++ src/app/{ListForm.tsx => SourceForm.tsx} | 128 +++++---- src/app/api/route.ts | 4 +- src/app/page.tsx | 342 ++++++++--------------- src/dialogs/AddSourceDialog.tsx | 49 ++++ src/dialogs/EditSourceDialog.tsx | 40 +++ src/dialogs/RemoveSourceDialog.tsx | 83 ++++++ src/fetchers/configFetcher.ts | 4 + src/fetchers/sourceFetcher.ts | 10 + src/hooks/useLocation.ts | 11 + src/hooks/useSWRList.ts | 72 +++++ src/{app => }/index.ts | 16 +- src/schemas/sourceSchema.ts | 12 + src/utils/sourceUrlFromConfig.ts | 22 ++ 16 files changed, 637 insertions(+), 463 deletions(-) delete mode 100644 src/app/Dialog.tsx create mode 100644 src/app/Source.tsx rename src/app/{ListForm.tsx => SourceForm.tsx} (67%) create mode 100644 src/dialogs/AddSourceDialog.tsx create mode 100644 src/dialogs/EditSourceDialog.tsx create mode 100644 src/dialogs/RemoveSourceDialog.tsx create mode 100644 src/fetchers/configFetcher.ts create mode 100644 src/fetchers/sourceFetcher.ts create mode 100644 src/hooks/useLocation.ts create mode 100644 src/hooks/useSWRList.ts rename src/{app => }/index.ts (65%) create mode 100644 src/schemas/sourceSchema.ts create mode 100644 src/utils/sourceUrlFromConfig.ts diff --git a/src/app/Dialog.tsx b/src/app/Dialog.tsx deleted file mode 100644 index 3d21097..0000000 --- a/src/app/Dialog.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { type ReactNode, useRef, useState } from "react"; - -type Props = { - children: ReactNode; - render: (closeDialog: () => void) => ReactNode; -}; - -const Dialog = ({ children, render }: Props) => { - const dialogRef = useRef(null); - const [content, setContent] = useState(null); - return ( -
- - { - event.preventDefault(); - setContent(null); - }} - > - - {content} - -
- ); -}; - -export default Dialog; diff --git a/src/app/List.tsx b/src/app/List.tsx index 2617c20..a580934 100644 --- a/src/app/List.tsx +++ b/src/app/List.tsx @@ -1,142 +1,84 @@ "use client"; import { Badge } from "@colonydb/anthill/Badge"; -import { CodeBlock } from "@colonydb/anthill/CodeBlock"; -import { Header } from "@colonydb/anthill/Header"; -import { Heading } from "@colonydb/anthill/Heading"; import { Inline } from "@colonydb/anthill/Inline"; import { Link } from "@colonydb/anthill/Link"; -import { Section } from "@colonydb/anthill/Section"; import { Stack } from "@colonydb/anthill/Stack"; -import { type ReactNode, useEffect, useState } from "react"; -import useSWR from "swr/immutable"; +import { useMemo } from "react"; import { Temporal } from "temporal-polyfill"; -import type { ListConfig, ListResult } from "."; +import { sourceFetcher } from "@/fetchers/sourceFetcher"; +import { useLocation } from "@/hooks/useLocation"; +import { useSWRList } from "@/hooks/useSWRList"; +import { sourceUrlFromConfig } from "@/utils/sourceUrlFromConfig"; +import type { SourceConfig } from ".."; type Props = { - actions?: ReactNode; - debug?: boolean; -} & Omit; - -const fetcher = (url: string) => - fetch(url).then((r) => { - if (!r.ok) { - throw Error(`Failed to fetch data: ${r.status} ${r.statusText}`); - } - return r.json(); - }); - -const List = ({ - actions, - debug = false, - exclude, - include, - itemSelector, - linkSelector, - name, - titleSelector, - url, -}: Props) => { - const [requestUrl, setRequestUrl] = useState(null); - - useEffect(() => { - let timeoutId: NodeJS.Timeout; - - if (window) { - const result = new URL("/api", window.location.href); + sources: Array; +}; - if (exclude) result.searchParams.set("exclude", exclude); - if (include) result.searchParams.set("include", include); - result.searchParams.set("itemSelector", itemSelector); - if (linkSelector) result.searchParams.set("linkSelector", linkSelector); - if (titleSelector) result.searchParams.set("titleSelector", titleSelector); - result.searchParams.set("url", url); +const List = ({ sources }: Props) => { + const location = useLocation(); - timeoutId = setTimeout(() => { - setRequestUrl(result.href); - }, 500); - } + const sourceUrls = useMemo( + () => sources.map((source) => sourceUrlFromConfig(source, location)), + [location, sources], + ); - return () => { - if (timeoutId) clearTimeout(timeoutId); - }; - }, [exclude, include, itemSelector, titleSelector, url, linkSelector]); + const { data, isLoading } = useSWRList(sourceUrls, sourceFetcher); - const { data, error, isLoading } = useSWR(requestUrl, fetcher, { - keepPreviousData: true, - refreshInterval: 1000 * 60 * 5, - }); + const items = data + .flatMap((result, resultIndex) => + (result?.items ?? []).map((item, itemIndex) => ({ + ...item, + key: `${item.title}:${item.url}:${resultIndex}:${itemIndex}`, + source: { + name: sources[resultIndex]?.name ?? "Unknown", + url: sources[resultIndex]?.url ?? "", + }, + })), + ) + .sort( + (a, b) => + Temporal.Instant.compare( + Temporal.Instant.from(b.firstSeen), + Temporal.Instant.from(a.firstSeen), + ) || a.title.localeCompare(b.title), + ); - return ( -
- {Temporal.Instant.from(data?.fetchedAt).toLocaleString(undefined, { - dateStyle: "short", - timeStyle: "long", - })} - - ) : null - } - > - - {name} - - - } - > - {isLoading ? ( -

Loading…

- ) : error ? ( -

Error: {error.message}

- ) : data === undefined || data.items.length === 0 ? ( - - - No results - - {debug && data?.debug ? ( - {JSON.stringify(data.debug, null, 2)} - ) : null} - - ) : ( - - {data.items.map(({ firstSeen, title, url: itemUrl }, index) => { - const key = `${title}:${itemUrl}:${index}`; - return ( -
  • -
    - {Temporal.Now.instant() - .since(Temporal.Instant.from(firstSeen)) - .round({ roundingMode: "trunc", smallestUnit: "hours" }).hours < - (process.env.NODE_ENV === "development" ? 1 : 36) ? ( - <> - New{" "} - - ) : null} - {itemUrl ? {title} : title} -
    -
    - - {name} - {" • "} - {Temporal.Instant.from(firstSeen).toLocaleString(undefined, { - dateStyle: "short", - timeStyle: "long", - })} - -
    -
  • - ); - })} -
    - )} -
    + return isLoading ? ( +

    Loading…

    + ) : data.length === 0 ? ( + + No results + + ) : ( + + {items.map(({ key, source, ...item }) => ( +
  • +
    + {Temporal.Now.instant() + .since(Temporal.Instant.from(item.firstSeen)) + .round({ roundingMode: "trunc", smallestUnit: "hours" }).hours < + (process.env.NODE_ENV === "development" ? 1 : 36) ? ( + <> + New{" "} + + ) : null} + {item.url ? {item.title} : item.title} +
    +
    + + {source.name} + {" • "} + {Temporal.Instant.from(item.firstSeen).toLocaleString(undefined, { + dateStyle: "short", + timeStyle: "long", + })} + +
    +
  • + ))} +
    ); }; diff --git a/src/app/Source.tsx b/src/app/Source.tsx new file mode 100644 index 0000000..af984a3 --- /dev/null +++ b/src/app/Source.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Badge } from "@colonydb/anthill/Badge"; +import { Header } from "@colonydb/anthill/Header"; +import { Icon } from "@colonydb/anthill/Icon"; +import { Inline } from "@colonydb/anthill/Inline"; +import { Link } from "@colonydb/anthill/Link"; +import { type ReactNode, useMemo } from "react"; +import useSWR from "swr/immutable"; +import { Temporal } from "temporal-polyfill"; +import { sourceFetcher } from "@/fetchers/sourceFetcher"; +import { useLocation } from "@/hooks/useLocation"; +import type { SourceConfig, SourceResult } from "@/index"; +import { sourceUrlFromConfig } from "@/utils/sourceUrlFromConfig"; + +type Props = { + actions?: ReactNode; + config: SourceConfig; +}; + +const Source = ({ actions, config }: Props) => { + const location = useLocation(); + + const sourceUrl = useMemo(() => sourceUrlFromConfig(config, location), [config, location]); + + const { data, error } = useSWR(sourceUrl, sourceFetcher); + + return ( +
    + {Temporal.Instant.from(data?.fetchedAt).toLocaleString(undefined, { + dateStyle: "short", + timeStyle: "long", + })} + + ) : null + } + > + {data?.items.some( + ({ firstSeen }) => + Temporal.Now.instant() + .since(Temporal.Instant.from(firstSeen)) + .round({ roundingMode: "trunc", smallestUnit: "hours" }).hours < + (process.env.NODE_ENV === "development" ? 1 : 36), + ) ? ( + <> + New{" "} + + ) : null} + {config.name} + {error ? ( + <> + {" "} + + + + + ) : null} +
    + ); +}; + +export default Source; diff --git a/src/app/ListForm.tsx b/src/app/SourceForm.tsx similarity index 67% rename from src/app/ListForm.tsx rename to src/app/SourceForm.tsx index dd009ca..de52c23 100644 --- a/src/app/ListForm.tsx +++ b/src/app/SourceForm.tsx @@ -15,36 +15,28 @@ import { MultiColumnStack } from "@colonydb/anthill/MultiColumnStack"; import { RegularField } from "@colonydb/anthill/RegularField"; import { Section } from "@colonydb/anthill/Section"; import { Specimen } from "@colonydb/anthill/Specimen"; +import { Stack } from "@colonydb/anthill/Stack"; import { StringInput } from "@colonydb/anthill/StringInput"; import { TabSet } from "@colonydb/anthill/TabSet"; import { useForm } from "@colonydb/anthill/useForm"; import { useState } from "react"; import * as v from "valibot"; -import type { ListConfig } from "."; +import type { SourceConfig } from "@/index"; +import { sourceSchema } from "@/schemas/sourceSchema"; import List from "./List"; +import Source from "./Source"; type Props = { id: string; - list: ListConfig; + initialData: SourceConfig; onCancel: () => void; - onSubmit: (list: ListConfig) => void; + onSubmit: (list: SourceConfig) => void; onSuccess: () => void; title: string; }; -const schema = v.object({ - exclude: v.optional(v.string()), - id: v.string(), - include: v.optional(v.string()), - itemSelector: v.pipe(v.string(), v.nonEmpty()), - linkSelector: v.optional(v.string()), - name: v.pipe(v.string(), v.nonEmpty()), - titleSelector: v.optional(v.string()), - url: v.pipe(v.string(), v.nonEmpty(), v.url()), -}); - -const ListForm = ({ id, list: initialList, onCancel, onSubmit, onSuccess, title }: Props) => { - const [tab, setTab] = useState<"basics" | "filters">("basics"); +export const SourceForm = ({ id, initialData, onCancel, onSubmit, onSuccess, title }: Props) => { + const [tab, setTab] = useState<"items" | "filters">("items"); return (
    { @@ -56,8 +48,8 @@ const ListForm = ({ id, list: initialList, onCancel, onSubmit, onSuccess, title }} onSuccess={onSuccess} id={id} - initialData={initialList} - schema={schema} + initialData={initialData} + schema={sourceSchema} > - +
    Configuration}> + + + + + + { - setTab("basics"); + setTab("items"); }, - selected: tab === "basics", + selected: tab === "items", }, { key: "filters", @@ -114,21 +119,15 @@ const ListForm = ({ id, list: initialList, onCancel, onSubmit, onSuccess, title }, ]} /> - {tab === "basics" ? ( + {tab === "items" ? ( <> - - - - - - CSS selector identifying the items on the page to be listed. - + CSS selector applied to each item to identify its title. If not provided, the text of the entire item will be used. @@ -164,7 +163,7 @@ const ListForm = ({ id, list: initialList, onCancel, onSubmit, onSuccess, title ) : null}
    - +
    @@ -172,29 +171,52 @@ const ListForm = ({ id, list: initialList, onCancel, onSubmit, onSuccess, title }; const Preview = () => { - const { data } = useForm(schema); + const { data } = useForm(sourceSchema); + const [tab, setTab] = useState<"overview" | "items" | "raw">("overview"); return (
    Preview}> - - {v.is(schema, data) ? ( - - ) : ( - - Not configured - - )} - + { + setTab("overview"); + }, + selected: tab === "overview", + }, + { + key: "items", + label: "Items", + onClick: () => { + setTab("items"); + }, + selected: tab === "items", + }, + { + key: "raw", + label: "Raw Data", + onClick: () => { + setTab("raw"); + }, + selected: tab === "raw", + }, + ]} + /> + {v.is(sourceSchema, data) ? ( + <> + + + + + + + + ) : ( + + Not configured + + )}
    ); }; - -export default ListForm; diff --git a/src/app/api/route.ts b/src/app/api/route.ts index a97054a..d1169ce 100644 --- a/src/app/api/route.ts +++ b/src/app/api/route.ts @@ -2,7 +2,7 @@ import { JSDOM, VirtualConsole } from "jsdom"; import { after } from "next/server"; import { createClient } from "redis"; import { toTemporalInstant } from "temporal-polyfill"; -import type { ListResult } from ".."; +import type { SourceResult } from "@/index"; export const GET = async (request: Request) => { const redis = process.env.REDIS_URL ? createClient({ url: process.env.REDIS_URL }) : null; @@ -54,7 +54,7 @@ export const GET = async (request: Request) => { const items = selectAll(dom.window.document, itemSelector); - const result: ListResult = { + const result: SourceResult = { debug: { firstLink: undefined, firstTitle: undefined, diff --git a/src/app/page.tsx b/src/app/page.tsx index 748e8ab..142b50d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,263 +1,151 @@ "use client"; -import { Action } from "@colonydb/anthill/Action"; import { ActionSet } from "@colonydb/anthill/ActionSet"; -import { Button } from "@colonydb/anthill/Button"; -import { Card } from "@colonydb/anthill/Card"; -import { CardContent } from "@colonydb/anthill/CardContent"; -import { Dialog } from "@colonydb/anthill/Dialog"; -import { Form } from "@colonydb/anthill/Form"; -import { FormFooter } from "@colonydb/anthill/FormFooter"; import { Header } from "@colonydb/anthill/Header"; import { Heading } from "@colonydb/anthill/Heading"; -import { Icon } from "@colonydb/anthill/Icon"; import { MultiColumnStack } from "@colonydb/anthill/MultiColumnStack"; import { Section } from "@colonydb/anthill/Section"; +import { Stack } from "@colonydb/anthill/Stack"; import Image from "next/image"; +import { useCallback } from "react"; import useSWR from "swr/immutable"; -import { v4 as uuid } from "uuid"; import * as v from "valibot"; -import type { ListConfig } from "."; +import { AddSourceDialog } from "@/dialogs/AddSourceDialog"; +import { EditSourceDialog } from "@/dialogs/EditSourceDialog"; +import { RemoveSourceDialog } from "@/dialogs/RemoveSourceDialog"; +import { configFetcher } from "@/fetchers/configFetcher"; +import type { SourceConfig } from "@/index"; import List from "./List"; -import ListForm from "./ListForm"; - -const fetcher = (key: string) => { - const value = localStorage.getItem(key); - return value === null ? null : JSON.parse(value); -}; +import Source from "./Source"; const HomePage = () => { const { - data: lists, + data: sources, error, isLoading, - isValidating, mutate, - } = useSWR>("lists", fetcher); - - const addList = async (list: ListConfig) => - mutate( - async (current) => { - v.parse( - v.object({ - exclude: v.optional(v.string()), - id: v.string(), - include: v.optional(v.string()), - itemSelector: v.pipe(v.string(), v.nonEmpty()), - linkSelector: v.optional(v.string()), - name: v.pipe(v.string(), v.nonEmpty()), - titleSelector: v.optional(v.string()), - url: v.pipe(v.string(), v.nonEmpty(), v.url()), - }), - list, - ); - const value = current ? [...current, list] : [list]; - localStorage.setItem("lists", JSON.stringify(value)); - return value; - }, - { revalidate: false }, - ); + } = useSWR, Error>("lists", configFetcher); - const updateList = async (list: ListConfig) => - mutate( - async (current) => { - const value = current ? [...current.filter((x) => x.id !== list.id), list] : [list]; - localStorage.setItem("lists", JSON.stringify(value)); - return value; - }, - { revalidate: false }, - ); + const addSource = useCallback( + async (source: SourceConfig) => + mutate( + async (current) => { + v.parse( + v.object({ + exclude: v.optional(v.string()), + id: v.string(), + include: v.optional(v.string()), + itemSelector: v.pipe(v.string(), v.nonEmpty()), + linkSelector: v.optional(v.string()), + name: v.pipe(v.string(), v.nonEmpty()), + titleSelector: v.optional(v.string()), + url: v.pipe(v.string(), v.nonEmpty(), v.url()), + }), + source, + ); + const value = current ? [...current, source] : [source]; + localStorage.setItem("lists", JSON.stringify(value)); + return value; + }, + { revalidate: false }, + ), + [mutate], + ); - const deleteList = async (list: ListConfig) => - mutate( - async (current) => { - const value = current ? [...current.filter((x) => x.id !== list.id)] : []; - localStorage.setItem("lists", JSON.stringify(value)); - return value; - }, - { revalidate: false }, - ); + const updateSource = useCallback( + async (source: SourceConfig) => + mutate( + async (current) => { + const value = current ? [...current.filter((x) => x.id !== source.id), source] : [source]; + localStorage.setItem("lists", JSON.stringify(value)); + return value; + }, + { revalidate: false }, + ), + [mutate], + ); - if (isLoading) return "One sec…"; - if (isValidating) return "One sec…"; - if (error) return ":("; + const deleteSource = useCallback( + async (source: SourceConfig) => + mutate( + async (current) => { + const value = current ? [...current.filter((x) => x.id !== source.id)] : []; + localStorage.setItem("lists", JSON.stringify(value)); + return value; + }, + { revalidate: false }, + ), + [mutate], + ); return (
    -
    - } - render={(closeDialog) => ( - { - closeDialog(); - }} - onSubmit={async (list) => { - await addList(list); - }} - onSuccess={() => { - setTimeout(() => { - closeDialog(); - }, 1000); - }} - title="Add List" - /> - )} - width="medium" - > - Add list - - - } - > +
    Harvester
    } + spacing="p1" >
    - - {(lists ?? []) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((list) => ( - } - padded - render={(closeDialog) => ( -
    { - await deleteList(list); - return { - ok: true, - data: {}, - }; - }} - onSuccess={() => { - closeDialog(); - }} - id={`deleteList:${list.id}`} - initialData={{}} - schema={v.object({})} - > - } - onClick={() => { - closeDialog(); - }} - title="close" - /> - } - > - Remove List -
    - } - footer={ - { - closeDialog(); - }} - > - Cancel - - } - /> - } - > - - Are you sure you want to remove this list? - - - - )} - width="narrow" - > - Remove list - - ), - key: "remove", - }, - { - content: ( - } - padded - render={(closeDialog) => ( - { - closeDialog(); - }} - onSubmit={async (list) => { - await updateList(list); - }} - onSuccess={() => { - setTimeout(() => { - closeDialog(); - }, 1000); - }} - title="Edit List" - /> - )} - width="medium" - > - Edit list - - ), - key: "edit", - }, - ]} - color={["gray-s1", "gray-t1"]} - title="Edit" - /> - } - key={list.id} - {...list} - /> - ))} - + {isLoading ? ( + "Loading…" + ) : error ? ( + error.message + ) : ( +
    +
    }> + Sources + + } + > + + {(sources ?? []) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((source) => ( + , + key: "remove", + }, + { + content: , + key: "edit", + }, + ]} + color={["gray-s1", "gray-t1"]} + title="Edit" + /> + } + config={source} + key={source.id} + /> + ))} + +
    +
    Items}> + + + +
    +
    + )}
    ); diff --git a/src/dialogs/AddSourceDialog.tsx b/src/dialogs/AddSourceDialog.tsx new file mode 100644 index 0000000..3670116 --- /dev/null +++ b/src/dialogs/AddSourceDialog.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Dialog } from "@colonydb/anthill/Dialog"; +import { Icon } from "@colonydb/anthill/Icon"; +import { v4 as uuid } from "uuid"; +import { SourceForm } from "@/app/SourceForm"; +import type { SourceConfig } from "@/index"; + +type Props = { + add: (source: SourceConfig) => Promise; +}; + +export const AddSourceDialog = ({ add }: Props) => ( + } + render={(closeDialog) => ( + { + closeDialog(); + }} + onSubmit={async (source) => { + await add(source); + }} + onSuccess={() => { + setTimeout(() => { + closeDialog(); + }, 1000); + }} + title="Add Source" + /> + )} + color={["gray-s1", "gray-t1"]} + width="medium" + > + Add + +); diff --git a/src/dialogs/EditSourceDialog.tsx b/src/dialogs/EditSourceDialog.tsx new file mode 100644 index 0000000..26040d5 --- /dev/null +++ b/src/dialogs/EditSourceDialog.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Dialog } from "@colonydb/anthill/Dialog"; +import { Icon } from "@colonydb/anthill/Icon"; +import { SourceForm } from "@/app/SourceForm"; +import type { SourceConfig } from "@/index"; + +type Props = { + source: SourceConfig; + update: (source: SourceConfig) => Promise; +}; + +export const EditSourceDialog = ({ source, update }: Props) => ( + } + padded + render={(closeDialog) => ( + { + closeDialog(); + }} + onSubmit={async (source) => { + await update(source); + }} + onSuccess={() => { + setTimeout(() => { + closeDialog(); + }, 1000); + }} + title="Edit Source" + /> + )} + width="medium" + > + Edit + +); diff --git a/src/dialogs/RemoveSourceDialog.tsx b/src/dialogs/RemoveSourceDialog.tsx new file mode 100644 index 0000000..75cb21a --- /dev/null +++ b/src/dialogs/RemoveSourceDialog.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { Action } from "@colonydb/anthill/Action"; +import { Button } from "@colonydb/anthill/Button"; +import { Card } from "@colonydb/anthill/Card"; +import { CardContent } from "@colonydb/anthill/CardContent"; +import { Dialog } from "@colonydb/anthill/Dialog"; +import { Form } from "@colonydb/anthill/Form"; +import { FormFooter } from "@colonydb/anthill/FormFooter"; +import { Header } from "@colonydb/anthill/Header"; +import { Heading } from "@colonydb/anthill/Heading"; +import { Icon } from "@colonydb/anthill/Icon"; +import * as v from "valibot"; +import type { SourceConfig } from "@/index"; + +type Props = { + remove: (source: SourceConfig) => Promise; + source: SourceConfig; +}; + +export const RemoveSourceDialog = ({ remove, source }: Props) => ( + } + padded + render={(closeDialog) => ( +
    { + await remove(source); + return { + ok: true, + data: {}, + }; + }} + onSuccess={() => { + closeDialog(); + }} + id={`deleteSource:${source.id}`} + initialData={{}} + schema={v.object({})} + > + } + onClick={() => { + closeDialog(); + }} + title="close" + /> + } + > + Remove Source + + } + footer={ + { + closeDialog(); + }} + > + Cancel + + } + /> + } + > + Are you sure you want to remove this source? + +
    + )} + width="narrow" + > + Remove +
    +); diff --git a/src/fetchers/configFetcher.ts b/src/fetchers/configFetcher.ts new file mode 100644 index 0000000..eab6ba7 --- /dev/null +++ b/src/fetchers/configFetcher.ts @@ -0,0 +1,4 @@ +export const configFetcher = (key: string) => { + const value = localStorage.getItem(key); + return value === null ? null : JSON.parse(value); +}; diff --git a/src/fetchers/sourceFetcher.ts b/src/fetchers/sourceFetcher.ts new file mode 100644 index 0000000..5383610 --- /dev/null +++ b/src/fetchers/sourceFetcher.ts @@ -0,0 +1,10 @@ +import type { SourceResult } from "@/index"; + +export const sourceFetcher = async (url: string) => { + const response = await fetch(url); + if (!response.ok) { + throw Error(`Failed to fetch data: ${response.status} ${response.statusText}`); + } + const result = await response.json(); + return result as SourceResult; +}; diff --git a/src/hooks/useLocation.ts b/src/hooks/useLocation.ts new file mode 100644 index 0000000..7079aef --- /dev/null +++ b/src/hooks/useLocation.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from "react"; + +export const useLocation = () => { + const [location, setLocation] = useState(null); + + useEffect(() => { + if (window) setLocation(window.location); + }, []); + + return location; +}; diff --git a/src/hooks/useSWRList.ts b/src/hooks/useSWRList.ts new file mode 100644 index 0000000..5fb0967 --- /dev/null +++ b/src/hooks/useSWRList.ts @@ -0,0 +1,72 @@ +import useSWR, { type Fetcher, type Key, type SWRConfiguration } from "swr"; +import type { BlockingData, IsLoadingResponse } from "swr/_internal"; + +/** + * A rudimentary implementation of the proposed useSWRList. Limited to 20 keys. + * + * @see https://github.com/vercel/swr/discussions/1988 + */ +// biome-ignore lint/suspicious/noExplicitAny: matches the type signature of useSWR +export const useSWRList = ( + keys: Key[], + fetcher: Fetcher | null, + config?: SWRConfiguration, +): { + data: Array< + BlockingData> extends true ? Data : Data | undefined + >; + error: Error | undefined; + isValidating: boolean; + isLoading: IsLoadingResponse>; +} => { + const result00 = useSWR(keys.at(0), fetcher, config); + const result01 = useSWR(keys.at(1), fetcher, config); + const result02 = useSWR(keys.at(2), fetcher, config); + const result03 = useSWR(keys.at(3), fetcher, config); + const result04 = useSWR(keys.at(4), fetcher, config); + const result05 = useSWR(keys.at(5), fetcher, config); + const result06 = useSWR(keys.at(6), fetcher, config); + const result07 = useSWR(keys.at(7), fetcher, config); + const result08 = useSWR(keys.at(8), fetcher, config); + const result09 = useSWR(keys.at(9), fetcher, config); + const result10 = useSWR(keys.at(10), fetcher, config); + const result11 = useSWR(keys.at(11), fetcher, config); + const result12 = useSWR(keys.at(12), fetcher, config); + const result13 = useSWR(keys.at(13), fetcher, config); + const result14 = useSWR(keys.at(14), fetcher, config); + const result15 = useSWR(keys.at(15), fetcher, config); + const result16 = useSWR(keys.at(16), fetcher, config); + const result17 = useSWR(keys.at(17), fetcher, config); + const result18 = useSWR(keys.at(18), fetcher, config); + const result19 = useSWR(keys.at(19), fetcher, config); + + const results = [ + result00, + result01, + result02, + result03, + result04, + result05, + result06, + result07, + result08, + result09, + result10, + result11, + result12, + result13, + result14, + result15, + result16, + result17, + result18, + result19, + ]; + + return { + data: results.map((result) => result.data), + error: results.find((result) => result.error)?.error, + isLoading: results.every((result) => result.isLoading), + isValidating: results.some((result) => result.isValidating), + }; +}; diff --git a/src/app/index.ts b/src/index.ts similarity index 65% rename from src/app/index.ts rename to src/index.ts index e9d73e6..ed43f90 100644 --- a/src/app/index.ts +++ b/src/index.ts @@ -1,4 +1,10 @@ -export type ListConfig = { +export type Item = { + firstSeen: string; + title: string; + url: string | undefined; +}; + +export type SourceConfig = { exclude?: string; id: string; include?: string; @@ -9,7 +15,7 @@ export type ListConfig = { url: string; }; -export type ListResult = { +export type SourceResult = { debug: { itemsFound: number; itemsAfterFilter: number; @@ -17,9 +23,5 @@ export type ListResult = { firstLink: string | undefined; }; fetchedAt: string; - items: Array<{ - firstSeen: string; - title: string; - url: string | undefined; - }>; + items: Array; }; diff --git a/src/schemas/sourceSchema.ts b/src/schemas/sourceSchema.ts new file mode 100644 index 0000000..d7b9c2a --- /dev/null +++ b/src/schemas/sourceSchema.ts @@ -0,0 +1,12 @@ +import * as v from "valibot"; + +export const sourceSchema = v.object({ + exclude: v.optional(v.string()), + id: v.string(), + include: v.optional(v.string()), + itemSelector: v.pipe(v.string(), v.nonEmpty()), + linkSelector: v.optional(v.string()), + name: v.pipe(v.string(), v.nonEmpty()), + titleSelector: v.optional(v.string()), + url: v.pipe(v.string(), v.nonEmpty(), v.url()), +}); diff --git a/src/utils/sourceUrlFromConfig.ts b/src/utils/sourceUrlFromConfig.ts new file mode 100644 index 0000000..811a09d --- /dev/null +++ b/src/utils/sourceUrlFromConfig.ts @@ -0,0 +1,22 @@ +import type { SourceConfig } from ".."; + +export const sourceUrlFromConfig = ( + { exclude, include, itemSelector, linkSelector, titleSelector, url }: SourceConfig, + location: Location | null, +) => { + if (!location) return undefined; + + const result = new URL("/api", location.href); + + if (exclude) result.searchParams.set("exclude", exclude); + if (include) result.searchParams.set("include", include); + + result.searchParams.set("itemSelector", itemSelector); + + if (linkSelector) result.searchParams.set("linkSelector", linkSelector); + if (titleSelector) result.searchParams.set("titleSelector", titleSelector); + + result.searchParams.set("url", url); + + return result.href; +}; -- 2.51.2