diff --git a/package.json b/package.json index 9815ce9..18223f1 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,9 @@ "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@next/mdx": "16.0.1", + "@orpc/client": "^1.11.3", + "@orpc/server": "^1.11.3", + "@orpc/tanstack-query": "^1.11.3", "@paralleldrive/cuid2": "^3.0.4", "@planetscale/database": "^1.19.0", "@radix-ui/react-accordion": "^1.2.12", @@ -53,10 +56,10 @@ "@tanstack/react-query": "^5.90.7", "@tanstack/react-query-persist-client": "^5.90.9", "@tanstack/react-table": "^8.21.3", - "@trpc/client": "11.7.1", + "@trpc/client": "^11.7.1", "@trpc/next": "11.7.1", - "@trpc/server": "11.7.1", - "@trpc/tanstack-react-query": "11.7.1", + "@trpc/server": "^11.7.1", + "@trpc/tanstack-react-query": "^11.7.1", "@types/mdx": "^2.0.13", "babel-plugin-react-compiler": "19.1.0-rc.2", "better-auth": "^1.3.34", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f0744c..6f1c74e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,15 @@ importers: '@next/mdx': specifier: 16.0.1 version: 16.0.1(@mdx-js/loader@3.1.1(webpack@5.102.1(esbuild@0.25.10)))(@mdx-js/react@3.1.1(@types/react@19.2.2)(react@19.2.0)) + '@orpc/client': + specifier: ^1.11.3 + version: 1.11.3 + '@orpc/server': + specifier: ^1.11.3 + version: 1.11.3(ws@8.18.3) + '@orpc/tanstack-query': + specifier: ^1.11.3 + version: 1.11.3(@orpc/client@1.11.3)(@tanstack/query-core@5.90.7) '@paralleldrive/cuid2': specifier: ^3.0.4 version: 3.0.4 @@ -124,16 +133,16 @@ importers: specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@trpc/client': - specifier: 11.7.1 + specifier: ^11.7.1 version: 11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3) '@trpc/next': specifier: 11.7.1 version: 11.7.1(@tanstack/react-query@5.90.7(react@19.2.0))(@trpc/client@11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.7.1(typescript@5.9.3))(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) '@trpc/server': - specifier: 11.7.1 + specifier: ^11.7.1 version: 11.7.1(typescript@5.9.3) '@trpc/tanstack-react-query': - specifier: 11.7.1 + specifier: ^11.7.1 version: 11.7.1(@tanstack/react-query@5.90.7(react@19.2.0))(@trpc/client@11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.7.1(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) '@types/mdx': specifier: ^2.0.13 @@ -1901,7 +1910,6 @@ packages: '@libsql/linux-x64-musl@0.5.22': resolution: {integrity: sha512-UZ4Xdxm4pu3pQXjvfJiyCzZop/9j/eA2JjmhMaAhe3EVLH2g11Fy4fwyUp9sT1QJYR1kpc2JLuybPM0kuXv/Tg==} - cpu: [x64] os: [linux] '@libsql/win32-x64-msvc@0.5.22': @@ -2086,6 +2094,63 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@orpc/client@1.11.3': + resolution: {integrity: sha512-USuUOvG07odUzrn3/xGE0V+JbK6DV+eYqURa98kMelSoGRLP0ceqomu49s1+paKYgT1fefRDMaCKxo04hgRNhg==} + + '@orpc/contract@1.11.3': + resolution: {integrity: sha512-tEZ2jGVCtSHd6gijl/ASA9RhJOUAtaDtsDtkwARCxeA9gshxcaAHXTcG1l1Vvy4fezcj1xZ1fzS8uYWlcrVF7A==} + + '@orpc/interop@1.11.3': + resolution: {integrity: sha512-NOTXLsp1jkFyHGzZM0qST9LtCrBUr5qN7OEDpslPXm2xV6I1IFok15QoVtxg033vEBXD5AbtTVCkzmaLb5JJ1w==} + + '@orpc/server@1.11.3': + resolution: {integrity: sha512-lgwIAk8VzeoIrR/i9x2VWj/KdmCrg4lqfQeybsXABBR9xJsPAZtW3ClgjNq60+leqiGnVTpj2Xxphja22bGA0A==} + peerDependencies: + crossws: '>=0.3.4' + ws: '>=8.18.1' + peerDependenciesMeta: + crossws: + optional: true + ws: + optional: true + + '@orpc/shared@1.11.3': + resolution: {integrity: sha512-hOPZhNI0oIhw91NNu4ndrmpWLdZyXTGx7tzq/bG5LwtuHuUsl4FalRsUfSIuap/V1ESOnPqSzmmSOdRv+ITcRA==} + peerDependencies: + '@opentelemetry/api': '>=1.9.0' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@orpc/standard-server-aws-lambda@1.11.3': + resolution: {integrity: sha512-LYJkps5hRKtBpeVeXE5xxdXhgPFj8I1wPtl+PJj06LIkuwuNWEmWdlrGH5lcyh5pWtJn8yJSDOIuGqHbuMTB7Q==} + + '@orpc/standard-server-fastify@1.11.3': + resolution: {integrity: sha512-Zom7Q4dDZW27KE4gco9HEH59dmBx2GLIqoRuy8LB97boktsGlbF/CVQ2W1ivcLOZ4yuJ0YXmq4egoWQ20apZww==} + peerDependencies: + fastify: '>=5.6.1' + peerDependenciesMeta: + fastify: + optional: true + + '@orpc/standard-server-fetch@1.11.3': + resolution: {integrity: sha512-wiudo8W/NHaosygIpU/NJGZVBTueSHSRU4y0pIwvAhA0f9ZQ9/aCwnYxR7lnvCizzb2off8kxxKKqkS3xYRepA==} + + '@orpc/standard-server-node@1.11.3': + resolution: {integrity: sha512-PvGKFMs1CGZ/phiftEadUh1KwLZXgN2Q5XEw2NNE8Q8YXAClwPBSLcCRp4dVRMwo06hONznW04uUubh2OA0MWA==} + + '@orpc/standard-server-peer@1.11.3': + resolution: {integrity: sha512-GkINRYjWRTOKQIsPWvqCvbjNjaLnhDAVJLrQNGTaqy7yLTDG8ome7hCrmH3bdjDY4nDlt8OoUaq9oABE/1rMew==} + + '@orpc/standard-server@1.11.3': + resolution: {integrity: sha512-j61f0TqITURN+5zft3vDjuyHjwTkusx91KrTGxfZ3E6B/dP2SLtoPCvTF8aecozxb5KvyhvAvbuDQMPeyqXvDg==} + + '@orpc/tanstack-query@1.11.3': + resolution: {integrity: sha512-oGCoNqQcVs/BH8pCjMQ2UPfuLvXtIkybMlVb3oG7/LKb7KnWPRUvadsCeKNOFGp+8zyrJkUJqkKV2DlHbihW5g==} + peerDependencies: + '@orpc/client': 1.11.3 + '@tanstack/query-core': '>=5.80.2' + '@paralleldrive/cuid2@3.0.4': resolution: {integrity: sha512-sM6M2PWrByOEpN2QYAdulhEbSZmChwj0e52u4hpwB7u4PznFiNAavtE6m7O8tWUlzX+jT2eKKtc5/ZgX+IHrtg==} hasBin: true @@ -3949,6 +4014,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + copy-anything@4.0.5: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} @@ -5352,7 +5421,6 @@ packages: libsql@0.5.22: resolution: {integrity: sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==} - cpu: [x64, arm64, wasm32, arm] os: [darwin, linux, win32] lightningcss-android-arm64@1.30.2: @@ -5914,6 +5982,9 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + optics-ts@2.4.1: resolution: {integrity: sha512-HaYzMHvC80r7U/LqAd4hQyopDezC60PO2qF5GuIwALut2cl5rK1VWHsqTp0oqoJJWjiv6uXKqsO+Q2OO0C3MmQ==} @@ -6254,6 +6325,10 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + radash@12.1.1: + resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==} + engines: {node: '>=14.18.0'} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -9207,6 +9282,103 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@orpc/client@1.11.3': + dependencies: + '@orpc/shared': 1.11.3 + '@orpc/standard-server': 1.11.3 + '@orpc/standard-server-fetch': 1.11.3 + '@orpc/standard-server-peer': 1.11.3 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/contract@1.11.3': + dependencies: + '@orpc/client': 1.11.3 + '@orpc/shared': 1.11.3 + '@standard-schema/spec': 1.0.0 + openapi-types: 12.1.3 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/interop@1.11.3': {} + + '@orpc/server@1.11.3(ws@8.18.3)': + dependencies: + '@orpc/client': 1.11.3 + '@orpc/contract': 1.11.3 + '@orpc/interop': 1.11.3 + '@orpc/shared': 1.11.3 + '@orpc/standard-server': 1.11.3 + '@orpc/standard-server-aws-lambda': 1.11.3 + '@orpc/standard-server-fastify': 1.11.3 + '@orpc/standard-server-fetch': 1.11.3 + '@orpc/standard-server-node': 1.11.3 + '@orpc/standard-server-peer': 1.11.3 + cookie: 1.0.2 + optionalDependencies: + ws: 8.18.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - fastify + + '@orpc/shared@1.11.3': + dependencies: + radash: 12.1.1 + type-fest: 5.2.0 + + '@orpc/standard-server-aws-lambda@1.11.3': + dependencies: + '@orpc/shared': 1.11.3 + '@orpc/standard-server': 1.11.3 + '@orpc/standard-server-fetch': 1.11.3 + '@orpc/standard-server-node': 1.11.3 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/standard-server-fastify@1.11.3': + dependencies: + '@orpc/shared': 1.11.3 + '@orpc/standard-server': 1.11.3 + '@orpc/standard-server-node': 1.11.3 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/standard-server-fetch@1.11.3': + dependencies: + '@orpc/shared': 1.11.3 + '@orpc/standard-server': 1.11.3 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/standard-server-node@1.11.3': + dependencies: + '@orpc/shared': 1.11.3 + '@orpc/standard-server': 1.11.3 + '@orpc/standard-server-fetch': 1.11.3 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/standard-server-peer@1.11.3': + dependencies: + '@orpc/shared': 1.11.3 + '@orpc/standard-server': 1.11.3 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/standard-server@1.11.3': + dependencies: + '@orpc/shared': 1.11.3 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/tanstack-query@1.11.3(@orpc/client@1.11.3)(@tanstack/query-core@5.90.7)': + dependencies: + '@orpc/client': 1.11.3 + '@orpc/shared': 1.11.3 + '@tanstack/query-core': 5.90.7 + transitivePeerDependencies: + - '@opentelemetry/api' + '@paralleldrive/cuid2@3.0.4': dependencies: '@noble/hashes': 2.0.1 @@ -11567,6 +11739,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.0.2: {} + copy-anything@4.0.5: dependencies: is-what: 5.5.0 @@ -13925,6 +14099,8 @@ snapshots: dependencies: mimic-function: 5.0.1 + openapi-types@12.1.3: {} + optics-ts@2.4.1: {} optionator@0.9.4: @@ -14208,6 +14384,8 @@ snapshots: queue-microtask@1.2.3: {} + radash@12.1.1: {} + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 diff --git a/public/manifest.json b/public/app.webmanifest similarity index 92% rename from public/manifest.json rename to public/app.webmanifest index d0830f9..d77ab6b 100644 --- a/public/manifest.json +++ b/public/app.webmanifest @@ -1,4 +1,5 @@ { + "id": "serial", "name": "Serial", "short_name": "Serial", "theme_color": "#fafaf9", @@ -6,7 +7,7 @@ "display": "standalone", "orientation": "any", "scope": "/", - "start_url": "/", + "start_url": "/feed", "icons": [ { "src": "/icon-192.png", diff --git a/src/app/(feed)/feed/RefetchItemsButton.tsx b/src/app/(feed)/feed/RefetchItemsButton.tsx index a1af76d..2430928 100644 --- a/src/app/(feed)/feed/RefetchItemsButton.tsx +++ b/src/app/(feed)/feed/RefetchItemsButton.tsx @@ -1,52 +1,41 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import clsx from "clsx"; import { RefreshCwIcon } from "lucide-react"; import { usePathname } from "next/navigation"; import { ButtonWithShortcut } from "~/components/ButtonWithShortcut"; -import { Button } from "~/components/ui/button"; -import { FETCH_NEW_FEED_ITEMS_KEY } from "~/lib/data/feed-items"; -import { useFetchNewFeedItemsMutation } from "~/lib/data/feed-items/mutations"; +import { useFeedItemsQuery } from "~/lib/data/feed-items"; import { useShortcut } from "~/lib/hooks/useShortcut"; -const ONE_HOUR = 1000 * 60 * 60; - export function RefetchItemsButton() { const pathname = usePathname(); - const { mutateAsync: fetchNewFeedItems, isPending } = - useFetchNewFeedItemsMutation(); - - useQuery({ - queryKey: [FETCH_NEW_FEED_ITEMS_KEY], - queryFn: async () => { - await fetchNewFeedItems(); - return true; - }, - staleTime: ONE_HOUR, - }); + const queryClient = useQueryClient(); + const { fetchStatus } = useFeedItemsQuery(); useShortcut("r", () => { - void fetchNewFeedItems(); + queryClient.invalidateQueries(); }); if (pathname !== "/feed") return null; + const isLoading = fetchStatus === "fetching"; + return ( { - await fetchNewFeedItems(); + onClick={() => { + queryClient.invalidateQueries(); }} - disabled={isPending} + disabled={isLoading} shortcut="r" > Refresh diff --git a/src/app/(feed)/feed/TodayItems.tsx b/src/app/(feed)/feed/TodayItems.tsx index f48ca6c..8b561ba 100644 --- a/src/app/(feed)/feed/TodayItems.tsx +++ b/src/app/(feed)/feed/TodayItems.tsx @@ -27,7 +27,10 @@ import { useHasFetchedFeedItems, } from "~/lib/data/atoms"; import { useFeedCategories } from "~/lib/data/feed-categories"; -import { useFilteredFeedItemsOrder } from "~/lib/data/feed-items"; +import { + useFeedItemsQuery, + useFilteredFeedItemsOrder, +} from "~/lib/data/feed-items"; import { useFeedItemsSetWatchedValueMutation, useFeedItemsSetWatchLaterValueMutation, @@ -35,6 +38,7 @@ import { import { useFeeds } from "~/lib/data/feeds"; import { useViews } from "~/lib/data/views"; import { useDialogStore } from "./dialogStore"; +import { memo } from "react"; function timeAgo(date: string | Date) { const diff = dayjs().diff(date); @@ -130,10 +134,9 @@ function TodayItemsFeedEmptyState() { function LoaderDisplay() { const hasFetchedFeedItems = useHasFetchedFeedItems(); - const { hasFetchedFeeds } = useFeeds(); - const { hasFetchedFeedCategories } = useFeedCategories(); + const { fetchStatus: feedItemsFetchStatus } = useFeedItemsQuery(); - if (hasFetchedFeeds && hasFetchedFeedItems && hasFetchedFeedCategories) { + if (feedItemsFetchStatus === "idle" || hasFetchedFeedItems) { return null; } @@ -153,108 +156,111 @@ function LoaderDisplay() { ); } -function ItemDisplay({ contentId }: { contentId: string }) { - const { feeds } = useFeeds(); - const [item] = useFeedItemGlobalState(contentId); +const ItemDisplay = memo( + function ItemDisplay({ contentId }: { contentId: string }) { + const { feeds } = useFeeds(); + const [item] = useFeedItemGlobalState(contentId); - const { mutateAsync: setWatchedValue } = - useFeedItemsSetWatchedValueMutation(contentId); - const { mutateAsync: setWatchLaterValue } = - useFeedItemsSetWatchLaterValueMutation(contentId); + const { mutateAsync: setWatchedValue } = + useFeedItemsSetWatchedValueMutation(contentId); + const { mutateAsync: setWatchLaterValue } = + useFeedItemsSetWatchLaterValueMutation(contentId); - const feed = feeds.find((f) => f.id === item.feedId); + const feed = feeds.find((f) => f.id === item.feedId); - const itemDestination = item.platform === "website" ? "read" : "watch"; + const itemDestination = item.platform === "website" ? "read" : "watch"; - const shouldOpenInSerial = - feed?.openLocation === "serial" || !feed?.openLocation; + const shouldOpenInSerial = + feed?.openLocation === "serial" || !feed?.openLocation; - const href = shouldOpenInSerial - ? `/feed/${itemDestination}/${item.id}` - : item.url; + const href = shouldOpenInSerial + ? `/feed/${itemDestination}/${item.id}` + : item.url; - const target = shouldOpenInSerial ? undefined : "_blank"; - const rel = shouldOpenInSerial ? undefined : "noopener noreferrer"; + const target = shouldOpenInSerial ? undefined : "_blank"; + const rel = shouldOpenInSerial ? undefined : "noopener noreferrer"; - return ( -
- - {!!item.thumbnail ? ( - {item.title} - ) : !!feed?.imageUrl ? ( -
+ + {!!item.thumbnail ? ( {item.title} -
- ) : ( -
-
-
- )} -
-

- {item.title} -

-

- {item.author || feed?.name} • {timeAgo(item.postedAt)} -

-
- -
- - -
-
- ); -} +
+

+ {item.title} +

+

+ {item.author || feed?.name} • {timeAgo(item.postedAt)} +

+
+ +
+ + +
+ + ); + }, + (prevProps, nextProps) => prevProps.contentId === nextProps.contentId, +); export function TodayItems() { const { feeds, hasFetchedFeeds } = useFeeds(); diff --git a/src/app/(feed)/layout.tsx b/src/app/(feed)/layout.tsx index 28026e9..2f048d1 100644 --- a/src/app/(feed)/layout.tsx +++ b/src/app/(feed)/layout.tsx @@ -30,7 +30,7 @@ export const metadata: Metadata = { telephone: false, }, generator: "Next.js", - manifest: "/manifest.json", + manifest: "/app.webmanifest", keywords: ["video", "rss", "newsletter", "content", "youtube", "podcast"], authors: [ { diff --git a/src/app/(markdown)/layout.tsx b/src/app/(markdown)/layout.tsx index 71ec167..7637246 100644 --- a/src/app/(markdown)/layout.tsx +++ b/src/app/(markdown)/layout.tsx @@ -19,7 +19,7 @@ export const metadata: Metadata = { telephone: false, }, generator: "Next.js", - manifest: "/manifest.json", + manifest: "/app.webmanifest", keywords: ["video", "rss", "newsletter", "content", "youtube", "podcast"], authors: [ { diff --git a/src/app/api/rpc/[[...rest]]/route.ts b/src/app/api/rpc/[[...rest]]/route.ts new file mode 100644 index 0000000..c42e9cf --- /dev/null +++ b/src/app/api/rpc/[[...rest]]/route.ts @@ -0,0 +1,42 @@ +import { RPCHandler } from "@orpc/server/fetch"; +import { onError } from "@orpc/server"; +import { orpcRouter } from "~/server/orpc/router"; +import { headers as getNextHeaders } from "next/headers"; + +import { db } from "~/server/db"; +import { auth } from "~/server/auth"; + +const handler = new RPCHandler(orpcRouter, { + interceptors: [ + onError((error) => { + console.error(error); + }), + ], +}); + +async function handleRequest(request: Request) { + const headers = new Headers(await getNextHeaders()); + + const authResponse = await auth.api.getSession({ + headers, + }); + + const { response } = await handler.handle(request, { + prefix: "/api/rpc", + context: { + headers: headers, + session: authResponse?.session, + user: authResponse?.user, + db, + }, + }); + + return response ?? new Response("Not found", { status: 404 }); +} + +export const HEAD = handleRequest; +export const GET = handleRequest; +export const POST = handleRequest; +export const PUT = handleRequest; +export const PATCH = handleRequest; +export const DELETE = handleRequest; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d0265cc..8e8d3bc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -33,7 +33,7 @@ export const metadata: Metadata = { telephone: false, }, generator: "Next.js", - manifest: "/manifest.json", + manifest: "/app.webmanifest", keywords: ["video", "rss", "newsletter", "content", "youtube", "podcast"], authors: [ { @@ -116,6 +116,10 @@ export default function RootLayout({ /> )} + { document.documentElement.style.setProperty(name, value); @@ -15,7 +16,7 @@ export function useApplyColorThemeOnClientMount() { const { data: auth } = useSession(); const { data } = useQuery( - api.userConfig.getConfig.queryOptions(undefined, { + orpc.userConfig.getConfig.queryOptions({ enabled: !!auth?.session.id ? true : false, }), ); diff --git a/src/components/color-theme/ColorThemePopoverButton.tsx b/src/components/color-theme/ColorThemePopoverButton.tsx index aedb471..dac8eac 100644 --- a/src/components/color-theme/ColorThemePopoverButton.tsx +++ b/src/components/color-theme/ColorThemePopoverButton.tsx @@ -15,6 +15,7 @@ import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group"; import { EnableCustomVideoPlayerToggle } from "./EnableCustomVideoPlayerToggle"; import { ShowShortcutsToggle } from "./ShowShortcutsToggle"; import { ShowArticleStyleToggle } from "./ShowArticleStyleToggle"; +import { orpc } from "~/lib/orpc"; function getCssVariable(name: string) { const value = window @@ -47,9 +48,9 @@ function FormSection({ function EditColorsForm() { const { data } = authClient.useSession(); - const api = useTRPC(); + const { mutate: saveThemeHSLToDatabase } = useMutation( - api.userConfig.setThemeHSL.mutationOptions(), + orpc.userConfig.setThemeHSL.mutationOptions(), ); const { resolvedTheme } = useTheme(); diff --git a/src/lib/data/feed-items/index.ts b/src/lib/data/feed-items/index.ts index 1c2b67d..b9eae33 100644 --- a/src/lib/data/feed-items/index.ts +++ b/src/lib/data/feed-items/index.ts @@ -1,13 +1,14 @@ import { useQuery } from "@tanstack/react-query"; import { atom, useAtomValue, useSetAtom } from "jotai"; import { useEffect, useRef } from "react"; +import { assembleIteratorResult } from "~/lib/iterators"; +import { orpc } from "~/lib/orpc"; import type { ApplicationFeedItem, ApplicationView, DatabaseFeed, DatabaseFeedCategory, } from "~/server/db/schema"; -import { useTRPC } from "~/trpc/react"; import { categoryFilterAtom, dateFilterAtom, @@ -149,6 +150,8 @@ export function useDoesFeedItemMatchAllFilters(item: ApplicationFeedItem) { export const useFilteredFeedItemsOrder = () => useAtomValue(filteredFeedItemsOrderAtom); +const ONE_HOUR = 1000 * 60 * 60; + export function useFeedItemsQuery() { const setHasFetchedFeedItems = useSetAtom(hasFetchedFeedItemsAtom); const setFeedItemsOrder = useSetAtom(feedItemsOrderAtom); @@ -156,18 +159,45 @@ export function useFeedItemsQuery() { const hasUpdatedBasedOnQueryRef = useRef(false); const query = useQuery( - useTRPC().feedItems.getAll.queryOptions(undefined, { - staleTime: Infinity, + orpc.feedItem.getAll.experimental_streamedOptions({ + staleTime: ONE_HOUR, }), ); useEffect(() => { - if (query.isSuccess && hasUpdatedBasedOnQueryRef.current === false) { + if (query.fetchStatus === "fetching") { + const data = assembleIteratorResult(query.data ?? []).sort((a, b) => { + if (a.postedAt <= b.postedAt) return 1; + return -1; + }); + + setFeedItemsOrder((prevItemsOrder) => + data.reduce((acc, item) => { + if (acc.find((id) => id === item.id)) { + return acc; + } + acc.push(item.id); + return acc; + }, prevItemsOrder), + ); + setFeedItemsMap((prevItemsMap) => + data.reduce((acc, item) => ({ ...acc, [item.id]: item }), prevItemsMap), + ); + } else if ( + query.isSuccess && + query.fetchStatus === "idle" && + hasUpdatedBasedOnQueryRef.current === false + ) { + const data = assembleIteratorResult(query.data).sort((a, b) => { + if (a.postedAt <= b.postedAt) return 1; + return -1; + }); + hasUpdatedBasedOnQueryRef.current = true; setHasFetchedFeedItems(true); - setFeedItemsOrder(query.data.map((item) => item.id)); + setFeedItemsOrder(data.map((item) => item.id)); setFeedItemsMap( - query.data.reduce((acc, item) => ({ ...acc, [item.id]: item }), {}), + data.reduce((acc, item) => ({ ...acc, [item.id]: item }), {}), ); } else if (query.isFetching) { hasUpdatedBasedOnQueryRef.current = false; @@ -175,6 +205,7 @@ export function useFeedItemsQuery() { }, [ query.isSuccess, query.isFetching, + query.fetchStatus, query.data, setFeedItemsOrder, setFeedItemsMap, diff --git a/src/lib/data/feed-items/mutations.ts b/src/lib/data/feed-items/mutations.ts index a6d2683..083d75e 100644 --- a/src/lib/data/feed-items/mutations.ts +++ b/src/lib/data/feed-items/mutations.ts @@ -1,45 +1,15 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "~/trpc/react"; import { useFeedItemGlobalState } from "../atoms"; - -export function useFetchNewFeedItemsMutation() { - const api = useTRPC(); - const queryClient = useQueryClient(); - - return useMutation( - api.feedItems.fetchNewItems.mutationOptions({ - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: api.feeds.getAll.queryKey(), - }); - await queryClient.invalidateQueries({ - queryKey: api.feedItems.getAll.queryKey(), - }); - await queryClient.invalidateQueries({ - queryKey: api.feedCategories.getAll.queryKey(), - }); - await queryClient.invalidateQueries({ - queryKey: api.contentCategories.getAll.queryKey(), - }); - await queryClient.invalidateQueries({ - queryKey: api.views.getAll.queryKey(), - }); - await queryClient.invalidateQueries({ - queryKey: api.userConfig.getConfig.queryKey(), - }); - }, - }), - ); -} +import { orpc } from "~/lib/orpc"; export function useFeedItemsSetWatchedValueMutation(contentId: string) { - const api = useTRPC(); const [feedItem, setFeedItem] = useFeedItemGlobalState(contentId); // We're not refetching on success here, as the frequency of // toggling this value makes it very wasteful return useMutation( - api.feedItems.setWatchedValue.mutationOptions({ + orpc.feedItem.setWatchedValue.mutationOptions({ onMutate: ({ isWatched }) => { setFeedItem({ ...feedItem, @@ -51,13 +21,12 @@ export function useFeedItemsSetWatchedValueMutation(contentId: string) { } export function useFeedItemsSetWatchLaterValueMutation(contentId: string) { - const api = useTRPC(); const [feedItem, setFeedItem] = useFeedItemGlobalState(contentId); // We're not refetching on success here, as the frequency of // toggling this value makes it very wasteful return useMutation( - api.feedItems.setWatchLaterValue.mutationOptions({ + orpc.feedItem.setWatchLaterValue.mutationOptions({ onMutate: ({ isWatchLater }) => { setFeedItem({ ...feedItem, diff --git a/src/lib/data/feeds/index.ts b/src/lib/data/feeds/index.ts index d19ab82..c9d18b7 100644 --- a/src/lib/data/feeds/index.ts +++ b/src/lib/data/feeds/index.ts @@ -1,26 +1,54 @@ import { useQuery } from "@tanstack/react-query"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { useEffect } from "react"; -import { useTRPC } from "~/trpc/react"; +import { useEffect, useRef } from "react"; +import { orpc } from "~/lib/orpc"; import { feedsAtom, hasFetchedFeedsAtom } from "../atoms"; +import { assembleIteratorResult } from "~/lib/iterators"; export function useFeedsQuery() { const setHasFetchedFeeds = useSetAtom(hasFetchedFeedsAtom); const setFeeds = useSetAtom(feedsAtom); const query = useQuery( - useTRPC().feeds.getAll.queryOptions(undefined, { + orpc.feed.getAll.experimental_streamedOptions({ staleTime: Infinity, }), ); + const hasUpdatedBasedOnQueryRef = useRef(false); useEffect(() => { - if (query.isSuccess) { + if (query.fetchStatus === "fetching") { + setFeeds((prevFeeds) => + assembleIteratorResult([prevFeeds, ...(query.data ?? [])]).sort( + (a, b) => { + if (a.updatedAt <= b.updatedAt) return 1; + return -1; + }, + ), + ); + } else if ( + query.isSuccess && + query.fetchStatus === "idle" && + hasUpdatedBasedOnQueryRef.current === false + ) { + const data = assembleIteratorResult(query.data).sort((a, b) => { + if (a.updatedAt <= b.updatedAt) return 1; + return -1; + }); + setHasFetchedFeeds(true); - setFeeds(query.data); + hasUpdatedBasedOnQueryRef.current = true; + setFeeds(data); } - }, [query, setHasFetchedFeeds, setFeeds]); + }, [ + query.isSuccess, + query.isFetching, + query.fetchStatus, + query.data, + setHasFetchedFeeds, + setFeeds, + ]); return query; } diff --git a/src/lib/data/feeds/mutations.ts b/src/lib/data/feeds/mutations.ts index a40ba07..a0082c4 100644 --- a/src/lib/data/feeds/mutations.ts +++ b/src/lib/data/feeds/mutations.ts @@ -3,27 +3,24 @@ import { useTRPC } from "~/trpc/react"; import { useFeeds } from "."; import { useAtom } from "jotai"; import { feedItemsMapAtom, feedItemsOrderAtom } from "../atoms"; -import { useFetchNewFeedItemsMutation } from "../feed-items/mutations"; +import { orpc } from "~/lib/orpc"; export function useCreateFeedMutation() { const api = useTRPC(); const queryClient = useQueryClient(); - const { mutateAsync: fetchNewFeedItems } = useFetchNewFeedItemsMutation(); - return useMutation( - api.feeds.create.mutationOptions({ + orpc.feed.create.mutationOptions({ onSuccess: async () => { await queryClient.invalidateQueries({ - queryKey: api.feeds.getAll.queryKey(), + queryKey: orpc.feed.getAll.queryKey(), }); await queryClient.invalidateQueries({ - queryKey: api.feedItems.getAll.queryKey(), + queryKey: orpc.feedItem.getAll.queryKey(), }); await queryClient.invalidateQueries({ queryKey: api.feedCategories.getAll.queryKey(), }); - await fetchNewFeedItems(); }, }), ); @@ -33,28 +30,24 @@ export function useCreateFeedsFromSubscriptionImportMutation() { const api = useTRPC(); const queryClient = useQueryClient(); - const { mutateAsync: fetchNewFeedItems } = useFetchNewFeedItemsMutation(); - return useMutation( - api.feeds.createFeedsFromSubscriptionImport.mutationOptions({ + orpc.feed.createFromSubscriptionImport.mutationOptions({ onSuccess: async () => { await queryClient.invalidateQueries({ - queryKey: api.feeds.getAll.queryKey(), + queryKey: orpc.feed.getAll.queryKey(), }); await queryClient.invalidateQueries({ - queryKey: api.feedItems.getAll.queryKey(), + queryKey: orpc.feedItem.getAll.queryKey(), }); await queryClient.invalidateQueries({ queryKey: api.feedCategories.getAll.queryKey(), }); - await fetchNewFeedItems(); }, }), ); } export function useDeleteFeedMutation() { - const api = useTRPC(); const queryClient = useQueryClient(); const { feeds, setFeeds } = useFeeds(); @@ -62,7 +55,7 @@ export function useDeleteFeedMutation() { const [feedItemsMap, setFeedItemsMap] = useAtom(feedItemsMapAtom); return useMutation( - api.feeds.delete.mutationOptions({ + orpc.feed.delete.mutationOptions({ onSuccess: async (_, feedId) => { setFeeds(feeds.filter((feed) => feed.id !== feedId)); @@ -91,10 +84,10 @@ export function useDeleteFeedMutation() { setFeedItemsMap(updatedFeedItemsMap); await queryClient.invalidateQueries({ - queryKey: api.feeds.getAll.queryKey(), + queryKey: orpc.feed.getAll.queryKey(), }); await queryClient.invalidateQueries({ - queryKey: api.feedItems.getAll.queryKey(), + queryKey: orpc.feedItem.getAll.queryKey(), }); }, }), @@ -106,10 +99,10 @@ export function useEditFeedMutation() { const queryClient = useQueryClient(); return useMutation( - api.feeds.update.mutationOptions({ + orpc.feed.update.mutationOptions({ onSuccess: async () => { await queryClient.invalidateQueries({ - queryKey: api.feeds.getAll.queryKey(), + queryKey: orpc.feed.getAll.queryKey(), }); await queryClient.invalidateQueries({ queryKey: api.feedCategories.getAll.queryKey(), diff --git a/src/lib/data/getItemsAndFeeds.ts b/src/lib/data/getItemsAndFeeds.ts deleted file mode 100644 index bbd3b66..0000000 --- a/src/lib/data/getItemsAndFeeds.ts +++ /dev/null @@ -1,8 +0,0 @@ -"use server"; - -import { getServerApi } from "~/server/api/server"; - -export async function getItemsAndFeeds() { - const api = await getServerApi(); - return await api.feeds.getAllFeedData(); -} diff --git a/src/lib/iterators.ts b/src/lib/iterators.ts new file mode 100644 index 0000000..350edc0 --- /dev/null +++ b/src/lib/iterators.ts @@ -0,0 +1,28 @@ +export function prepareArrayChunks(list: T[], length: number) { + let chunks: T[][] = []; + for (let i = 0; i < list.length; i += length) { + const end = Math.min(i + length, list.length - 1); + chunks.push(list.slice(i, end)); + } + return chunks; +} + +export function assembleIteratorResult< + TId extends string | number, + T extends { id: TId }, +>(data: T[][]) { + return data.reduce((acc, cur) => { + cur.forEach((item) => { + const existingIndex = acc.findIndex( + (existingItem) => existingItem.id === item.id, + ); + if (existingIndex >= 0) { + acc.splice(existingIndex, 1, item); + } else { + acc.push(item); + } + }); + + return acc; + }, []); +} diff --git a/src/lib/orpc.ts b/src/lib/orpc.ts new file mode 100644 index 0000000..d17e304 --- /dev/null +++ b/src/lib/orpc.ts @@ -0,0 +1,21 @@ +import { createORPCClient } from "@orpc/client"; +import { RPCLink } from "@orpc/client/fetch"; +import { RouterClient } from "@orpc/server"; +import { orpcRouter } from "~/server/orpc/router"; +import { createTanstackQueryUtils } from "@orpc/tanstack-query"; + +const link = new RPCLink({ + url: `${typeof window !== "undefined" ? window.location.origin : "http://localhost:3000"}/api/rpc`, + headers: async () => { + if (typeof window !== "undefined") { + return {}; + } + + const { headers } = await import("next/headers"); + return await headers(); + }, +}); + +export const orpcRouterClient: RouterClient = + createORPCClient(link); +export const orpc = createTanstackQueryUtils(orpcRouterClient); diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 7e5ecbd..b394605 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,19 +1,13 @@ -import { feedItemRouter } from "~/server/api/routers/feedItemRouter"; import { contentCategoriesRouter } from "~/server/api/routers/contentCategoriesRouter"; import { feedCategoriesRouter } from "~/server/api/routers/feedCategoriesRouter"; import { createTRPCRouter } from "~/server/api/trpc"; -import { userConfigRouter } from "./routers/userConfigRouter"; import { userRouter } from "./routers/userRouter"; import { viewRouter } from "./routers/viewRouter"; -import { feedRouter } from "./routers/feed-router"; export const appRouter = createTRPCRouter({ user: userRouter, - feeds: feedRouter, - feedItems: feedItemRouter, contentCategories: contentCategoriesRouter, feedCategories: feedCategoriesRouter, - userConfig: userConfigRouter, views: viewRouter, }); diff --git a/src/server/api/routers/feed-router/index.ts b/src/server/api/routers/feed-router/index.ts index e9181d5..cd49a0e 100644 --- a/src/server/api/routers/feed-router/index.ts +++ b/src/server/api/routers/feed-router/index.ts @@ -1,9 +1,8 @@ -import type { inferRouterOutputs } from "@trpc/server"; import { and, desc, eq, inArray, notInArray, sql } from "drizzle-orm"; import { z } from "zod"; import { parseArrayOfSchema } from "~/lib/schemas/utils"; -import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; +import { protectedProcedure } from "~/server/orpc/base"; import { contentCategories, feedCategories, @@ -14,6 +13,7 @@ import { } from "~/server/db/schema"; import { fetchFeedData, fetchNewFeedDetails } from "~/server/rss/fetchFeeds"; import { findExistingFeedThatMatches } from "./utils"; +import { prepareArrayChunks } from "~/lib/iterators"; type BulkImportFromFileSuccess = { feedUrl: string; @@ -28,337 +28,265 @@ export type BulkImportFromFileResult = | BulkImportFromFileError | BulkImportFromFileSuccess; -export const feedRouter = createTRPCRouter({ - create: protectedProcedure - .input( - z.object({ url: z.string().min(5), categoryIds: z.number().array() }), - ) - .mutation(async ({ ctx, input }) => { - const newFeeds = await fetchNewFeedDetails(input.url); - if (!newFeeds.length) { - throw new Error("Unsupported feed URL"); - } - - const errors = ( - await ctx.db.transaction(async (tx) => { - return await Promise.all( - newFeeds.map(async (newFeed) => { - if (!newFeed.url) return "No feed url found."; - - const existingFeed = await findExistingFeedThatMatches(tx, { - feedUrl: newFeed.url, - userId: ctx.auth!.user.id, - }); - - if (existingFeed) { - return "Feed already exists"; - } - - const newFeeds = await tx - .insert(feeds) - .values({ - userId: ctx.auth!.user.id, - ...newFeed, - }) - .returning(); - - const newFeedRow = newFeeds?.[0]; - - if (!!input.categoryIds.length && !!newFeedRow) { - await Promise.all( - input.categoryIds.map(async (categoryId) => { - return await tx.insert(feedCategories).values({ - feedId: Number(newFeedRow.id), - categoryId: categoryId, - }); - }), - ); - } - - return null; - }), - ); - }) - ).filter(Boolean); +export const create = protectedProcedure + .input(z.object({ url: z.string().min(5), categoryIds: z.number().array() })) + .handler(async ({ context, input }) => { + const newFeeds = await fetchNewFeedDetails(input.url); + if (!newFeeds.length) { + throw new Error("Unsupported feed URL"); + } - if (errors.length === newFeeds.length) { - throw new Error(errors[0]); - } - }), - createFeedsFromSubscriptionImport: protectedProcedure - .input( - z.object({ - feeds: z - .object({ - feedUrl: z.string(), - categories: z.string().array(), - }) - .array(), - }), - ) - .mutation(async ({ ctx, input }): Promise => { - if (!input.feeds.length) { - return []; - } - - const promiseResults = await Promise.allSettled( - input.feeds.map(async (feed) => { - return await ctx.db.transaction(async (tx) => { - const newFeedDetails = await fetchNewFeedDetails(feed.feedUrl); - const newFeed = newFeedDetails?.[0]; - - if (!newFeed?.url) { - return { - feedUrl: feed.feedUrl, - success: false, - error: "Unsupported feed URL", - }; - } + const errors = ( + await context.db.transaction(async (tx) => { + return await Promise.all( + newFeeds.map(async (newFeed) => { + if (!newFeed.url) return "No feed url found."; const existingFeed = await findExistingFeedThatMatches(tx, { feedUrl: newFeed.url, - userId: ctx.auth!.user.id, + userId: context.user.id, }); if (existingFeed) { - return { - feedUrl: newFeed.url, - success: false, - error: "Feed already exists", - }; + return "Feed already exists"; } const newFeeds = await tx .insert(feeds) .values({ - userId: ctx.auth!.user.id, + userId: context.user.id, ...newFeed, }) .returning(); + const newFeedRow = newFeeds?.[0]; - if (!newFeedRow) { - return { - feedUrl: newFeed.url, - success: false, - error: "Couldn't find new feed", - }; + if (!!input.categoryIds.length && !!newFeedRow) { + await Promise.all( + input.categoryIds.map(async (categoryId) => { + return await tx.insert(feedCategories).values({ + feedId: Number(newFeedRow.id), + categoryId: categoryId, + }); + }), + ); } - const matchingCategories = await tx - .select() - .from(contentCategories) - .where( - and( - inArray(contentCategories.name, feed.categories), - eq(contentCategories.userId, ctx.auth!.user.id), - ), - ) - .all(); - const matchingCategoryNames = matchingCategories.map( - (category) => category.name, - ); - - const nonMatchingCategories = feed.categories.filter( - (category) => !matchingCategoryNames.includes(category), - ); - - const matchingCategoryPromises = matchingCategories.map( - async (matchingCategory) => { - const categoryId = matchingCategory.id; - - return await tx.insert(feedCategories).values({ - feedId: newFeedRow.id, - categoryId: categoryId, - }); - }, - ); - - const nonMatchingCategoryPromises = nonMatchingCategories.map( - async (nonMatchingCategory) => { - const newContentCategoryList = await tx - .insert(contentCategories) - .values({ - name: nonMatchingCategory, - userId: ctx.auth!.user.id, - }) - .returning(); - const newContentCategory = newContentCategoryList?.[0]; - - if (!newContentCategory?.id) return; - - await tx.insert(feedCategories).values({ - feedId: newFeedRow.id, - categoryId: newContentCategory?.id, - }); - }, - ); - - await Promise.allSettled([ - ...matchingCategoryPromises, - ...nonMatchingCategoryPromises, - ]); + return null; + }), + ); + }) + ).filter(Boolean); + + if (errors.length === newFeeds.length) { + throw new Error(errors[0]); + } + }); + +export const createFromSubscriptionImport = protectedProcedure + .input( + z.object({ + feeds: z + .object({ + feedUrl: z.string(), + categories: z.string().array(), + }) + .array(), + }), + ) + .handler(async ({ context, input }): Promise => { + if (!input.feeds.length) { + return []; + } + const promiseResults = await Promise.allSettled( + input.feeds.map(async (feed) => { + return await context.db.transaction(async (tx) => { + const newFeedDetails = await fetchNewFeedDetails(feed.feedUrl); + const newFeed = newFeedDetails?.[0]; + + if (!newFeed?.url) { return { - feedUrl: newFeed.url, - success: true, + feedUrl: feed.feedUrl, + success: false, + error: "Unsupported feed URL", }; + } + + const existingFeed = await findExistingFeedThatMatches(tx, { + feedUrl: newFeed.url, + userId: context.user.id, }); - }), - ); - const results: BulkImportFromFileResult[] = promiseResults - .map((result) => { - if (result.status === "fulfilled") { - return result.value; + if (existingFeed) { + return { + feedUrl: newFeed.url, + success: false, + error: "Feed already exists", + }; } - return null; - }) - .filter(Boolean); + const newFeeds = await tx + .insert(feeds) + .values({ + userId: context.user.id, + ...newFeed, + }) + .returning(); + const newFeedRow = newFeeds?.[0]; - return results; - }), - delete: protectedProcedure - .input(z.number()) - .mutation(async ({ ctx, input }) => { - await ctx.db.transaction(async (tx) => { - await tx.delete(feedItems).where(eq(feedItems.feedId, input)); - - await tx.delete(feedCategories).where(eq(feedCategories.feedId, input)); - - await tx - .delete(feeds) - .where(and(eq(feeds.id, input), eq(feeds.userId, ctx.auth!.user.id))); - }); - }), - getAllFeedData: protectedProcedure.query(async ({ ctx }) => { - const feedsList = await ctx.db.query.feeds.findMany({ - where: sql`user_id = ${ctx.auth!.user.id}`, - }); + if (!newFeedRow) { + return { + feedUrl: newFeed.url, + success: false, + error: "Couldn't find new feed", + }; + } - if (!feedsList) { - return { - feeds: [], - items: [], - }; - } + const matchingCategories = await tx + .select() + .from(contentCategories) + .where( + and( + inArray(contentCategories.name, feed.categories), + eq(contentCategories.userId, context.user.id), + ), + ) + .all(); + const matchingCategoryNames = matchingCategories.map( + (category) => category.name, + ); - const feedData = await fetchFeedData(feedsList); + const nonMatchingCategories = feed.categories.filter( + (category) => !matchingCategoryNames.includes(category), + ); - if (!feedData) { - return { - feeds: feedsList, - items: [], - }; - } + const matchingCategoryPromises = matchingCategories.map( + async (matchingCategory) => { + const categoryId = matchingCategory.id; + + return await tx.insert(feedCategories).values({ + feedId: newFeedRow.id, + categoryId: categoryId, + }); + }, + ); + + const nonMatchingCategoryPromises = nonMatchingCategories.map( + async (nonMatchingCategory) => { + const newContentCategoryList = await tx + .insert(contentCategories) + .values({ + name: nonMatchingCategory, + userId: context.user.id, + }) + .returning(); + const newContentCategory = newContentCategoryList?.[0]; + + if (!newContentCategory?.id) return; + + await tx.insert(feedCategories).values({ + feedId: newFeedRow.id, + categoryId: newContentCategory?.id, + }); + }, + ); + + await Promise.allSettled([ + ...matchingCategoryPromises, + ...nonMatchingCategoryPromises, + ]); - const feedItemList: (typeof feedItems.$inferInsert)[] = - feedData?.flatMap((feed) => { - return feed.items.map((item) => { return { - feedId: feed.id, - contentId: item.id, - title: item.title ?? "", - author: item.author ?? "", - thumbnail: item.thumbnail ?? "", - url: item.url ?? "", - postedAt: new Date(item.publishedDate), - } satisfies typeof feedItems.$inferInsert; + feedUrl: newFeed.url, + success: true, + }; }); - }) ?? []; + }), + ); - await ctx.db.transaction(async (tx) => { - return await Promise.all( - feedItemList.map(async (item) => { - return await tx - .insert(feedItems) - .values(item) - .onConflictDoUpdate({ - target: [feedItems.url, feedItems.feedId], - set: item, - }); - }), - ); - }); + const results: BulkImportFromFileResult[] = promiseResults + .map((result) => { + if (result.status === "fulfilled") { + return result.value; + } - const feedIds = feedsList.map((feed) => feed.id); - if (!feedIds.length) { - return { - feeds: [], - items: [], - }; - } + return null; + }) + .filter(Boolean); + + return results; + }); + +const deleteFeed = protectedProcedure + .input(z.number()) + .handler(async ({ context, input }) => { + await context.db.transaction(async (tx) => { + await tx.delete(feedItems).where(eq(feedItems.feedId, input)); - const itemsQueryData = await ctx.db - .select() - .from(feedItems) - .where(inArray(feedItems.feedId, feedIds)) - .leftJoin(feedCategories, eq(feedItems.feedId, feedCategories.feedId)) - .leftJoin( - contentCategories, - eq(contentCategories.id, feedCategories.categoryId), - ) - .orderBy(desc(feedItems.postedAt)); - - return { - feeds: feedsList, - items: itemsQueryData, - }; - }), - getAll: protectedProcedure.query(async ({ ctx }) => { - const feedsList = await ctx.db.query.feeds.findMany({ - where: sql`user_id = ${ctx.auth!.user.id}`, + await tx.delete(feedCategories).where(eq(feedCategories.feedId, input)); + + await tx + .delete(feeds) + .where(and(eq(feeds.id, input), eq(feeds.userId, context.user.id))); }); + }); +export { deleteFeed as delete }; - return parseArrayOfSchema(feedsList, feedsSchema); - }), - update: protectedProcedure - .input( - z.object({ - feedId: z.number(), - categoryIds: z.number().array(), - openLocation: openLocationSchema, - }), - ) - .mutation(async ({ ctx, input }) => { - return await ctx.db.transaction(async (tx) => { - // Feed open location - await tx - .update(feeds) - .set({ - openLocation: input.openLocation, - }) - .where( - and( - eq(feeds.userId, ctx.auth!.user.id), - eq(feeds.id, input.feedId), - ), - ); +export const getAll = protectedProcedure.handler(async function* ({ context }) { + const feedsList = await context.db.query.feeds.findMany({ + where: sql`user_id = ${context.user.id}`, + }); - // Feed categories - await tx - .delete(feedCategories) - .where( - and( - eq(feedCategories.feedId, input.feedId), - notInArray(feedCategories.categoryId, input.categoryIds), - ), - ); + const parsed = parseArrayOfSchema(feedsList, feedsSchema); - return await Promise.all( - input.categoryIds.map(async (categoryId) => { - await tx - .insert(feedCategories) - .values({ - feedId: input.feedId, - categoryId, - }) - .onConflictDoNothing(); - }), - ); - }); - }), + for (const chunk of prepareArrayChunks(parsed, 50)) { + yield chunk; + } + + return; }); -export type FeedRouter = inferRouterOutputs; +export const update = protectedProcedure + .input( + z.object({ + feedId: z.number(), + categoryIds: z.number().array(), + openLocation: openLocationSchema, + }), + ) + .handler(async ({ context, input }) => { + return await context.db.transaction(async (tx) => { + // Feed open location + await tx + .update(feeds) + .set({ + openLocation: input.openLocation, + }) + .where( + and(eq(feeds.userId, context.user.id), eq(feeds.id, input.feedId)), + ); + + // Feed categories + await tx + .delete(feedCategories) + .where( + and( + eq(feedCategories.feedId, input.feedId), + notInArray(feedCategories.categoryId, input.categoryIds), + ), + ); + + return await Promise.all( + input.categoryIds.map(async (categoryId) => { + await tx + .insert(feedCategories) + .values({ + feedId: input.feedId, + categoryId, + }) + .onConflictDoNothing(); + }), + ); + }); + }); diff --git a/src/server/api/routers/feedItemRouter.ts b/src/server/api/routers/feedItemRouter.ts index 77d8895..d25b614 100644 --- a/src/server/api/routers/feedItemRouter.ts +++ b/src/server/api/routers/feedItemRouter.ts @@ -1,15 +1,11 @@ import dayjs from "dayjs"; -import { and, desc, eq, gte, inArray, isNull, sql } from "drizzle-orm"; +import { and, desc, eq, gte, inArray } from "drizzle-orm"; import { z } from "zod"; +import { prepareArrayChunks } from "~/lib/iterators"; -import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; -import { checkFeedItemIsVerticalFromThumbnail } from "~/server/checkFeedItemIsVertical"; -import { - type ApplicationFeedItem, - type DatabaseFeedItem, - feedItems, - feeds, -} from "~/server/db/schema"; +import { checkFeedItemIsVerticalFromUrl } from "~/server/checkFeedItemIsVertical"; +import { type ApplicationFeedItem, feedItems, feeds } from "~/server/db/schema"; +import { protectedProcedure } from "~/server/orpc/base"; import { fetchFeedData } from "~/server/rss/fetchFeeds"; const isWithinLastMonth = gte( @@ -17,58 +13,56 @@ const isWithinLastMonth = gte( dayjs().subtract(32, "days").toDate(), ); -export const feedItemRouter = createTRPCRouter({ - getAll: protectedProcedure.query(async ({ ctx }) => { - const feedsData = await ctx.db.query.feeds.findMany({ - where: eq(feeds.userId, ctx.auth!.user.id), - }); - const feedIds = feedsData.map((feed) => feed.id); - - const itemsData = await ctx.db.query.feedItems.findMany({ - where: and(inArray(feedItems.feedId, feedIds), isWithinLastMonth), - orderBy: desc(feedItems.postedAt), - }); - - return itemsData.map((item) => { - const feed = feedsData.find((feed) => feed.id === item.feedId); - - return { - ...item, - platform: feed?.platform ?? "youtube", - } as ApplicationFeedItem; - }); - }), - fetchNewItems: protectedProcedure.mutation(async ({ ctx }) => { - const feedsList = await ctx.db.query.feeds.findMany({ - where: sql`user_id = ${ctx.auth!.user.id}`, - }); - - if (!feedsList) { - return; - } - - const feedData = await fetchFeedData(feedsList); - if (!feedData) { - return; - } - - const feedItemList: (typeof feedItems.$inferInsert)[] = - feedData?.flatMap((feed) => { - return feed.items.map((item) => { - return { - feedId: feed.id, - contentId: item.id, - content: item.content ?? "", - title: item.title ?? "", - author: item.author ?? "", - thumbnail: item.thumbnail ?? "", - url: item.url ?? "", - postedAt: new Date(item.publishedDate), - } satisfies typeof feedItems.$inferInsert; - }); - }) ?? []; - - await ctx.db.transaction(async (tx) => { +export const getAll = protectedProcedure.handler(async function* ({ context }) { + // Get existing items, yield + const feedsList = await context.db.query.feeds.findMany({ + where: eq(feeds.userId, context.user.id), + }); + const feedIds = feedsList.map((feed) => feed.id); + + const itemsData = await context.db.query.feedItems.findMany({ + where: and(inArray(feedItems.feedId, feedIds), isWithinLastMonth), + orderBy: desc(feedItems.postedAt), + }); + + const existingApplicationFeedItems = itemsData.map((item) => { + const feed = feedsList.find((feed) => feed.id === item.feedId); + + return { + ...item, + platform: feed?.platform ?? "youtube", + } as ApplicationFeedItem; + }); + + for (const chunk of prepareArrayChunks(existingApplicationFeedItems, 50)) { + yield chunk; + } + + // Get new items, yield + const feedData = await fetchFeedData(feedsList); + if (!feedData) { + return; + } + + const feedItemList: (typeof feedItems.$inferInsert)[] = + feedData?.flatMap((feed) => { + return feed.items.map((item) => { + return { + feedId: feed.id, + contentId: item.id, + content: item.content ?? "", + title: item.title ?? "", + author: item.author ?? "", + thumbnail: item.thumbnail ?? "", + url: item.url ?? "", + postedAt: new Date(item.publishedDate), + orientation: checkFeedItemIsVerticalFromUrl(item.url), + } satisfies typeof feedItems.$inferInsert; + }); + }) ?? []; + + const feedItemsList = ( + await context.db.transaction(async (tx) => { return await Promise.all( feedItemList.map(async (item) => { try { @@ -78,7 +72,8 @@ export const feedItemRouter = createTRPCRouter({ .onConflictDoUpdate({ target: [feedItems.url, feedItems.feedId], set: item, - }); + }) + .returning(); } catch { // For local testing // console.dir({ ...error }, { depth: null }); @@ -87,87 +82,61 @@ export const feedItemRouter = createTRPCRouter({ return null; }), ); - }); - - // check if items are vertical - const uncategorizedFeedItems = await ctx.db - .select() - .from(feedItems) - .where(and(isNull(feedItems.orientation), isWithinLastMonth)); - - if (uncategorizedFeedItems.length === 0) { - return; - } - - const categorizedFeedItems: (typeof feedItems.$inferInsert)[] = ( - await Promise.all( - uncategorizedFeedItems.map(async (item) => { - const feed = feedsList.find((feed) => feed.id === item.feedId); - let orientation: DatabaseFeedItem["orientation"] = "horizontal"; - if (feed?.platform === "youtube") { - orientation = await checkFeedItemIsVerticalFromThumbnail( - item.thumbnail, - ); - } + }) + ) + .filter(Boolean) + .flat(); - if (orientation !== null) { - return { - ...item, - orientation, - }; - } - }), - ) - ).filter(Boolean); + const newApplicationFeedItems = feedItemsList.map((item) => { + const feed = feedsList.find((feed) => feed.id === item.feedId); - await ctx.db.transaction(async (tx) => { - return await Promise.all( - categorizedFeedItems.map(async (item) => { - return await tx - .insert(feedItems) - .values(item) - .onConflictDoUpdate({ - target: [feedItems.url, feedItems.feedId], - set: item, - }); - }), - ); - }); - }), - setWatchedValue: protectedProcedure - .input( - z.object({ - id: z.string(), - feedId: z.number(), - isWatched: z.boolean(), - }), - ) - .mutation(async ({ ctx, input }) => { - await ctx.db - .update(feedItems) - .set({ - isWatched: input.isWatched, - }) - .where( - and(eq(feedItems.feedId, input.feedId), eq(feedItems.id, input.id)), - ); + return { + ...item, + platform: feed?.platform ?? "youtube", + } as ApplicationFeedItem; + }); + + for (const chunk of prepareArrayChunks(newApplicationFeedItems, 50)) { + yield chunk; + } + + return; +}); + +export const setWatchedValue = protectedProcedure + .input( + z.object({ + id: z.string(), + feedId: z.number(), + isWatched: z.boolean(), }), - setWatchLaterValue: protectedProcedure - .input( - z.object({ - id: z.string(), - feedId: z.number(), - isWatchLater: z.boolean(), - }), - ) - .mutation(async ({ ctx, input }) => { - await ctx.db - .update(feedItems) - .set({ - isWatchLater: input.isWatchLater, - }) - .where( - and(eq(feedItems.feedId, input.feedId), eq(feedItems.id, input.id)), - ); + ) + .handler(async ({ context, input }) => { + await context.db + .update(feedItems) + .set({ + isWatched: input.isWatched, + }) + .where( + and(eq(feedItems.feedId, input.feedId), eq(feedItems.id, input.id)), + ); + }); + +export const setWatchLaterValue = protectedProcedure + .input( + z.object({ + id: z.string(), + feedId: z.number(), + isWatchLater: z.boolean(), }), -}); + ) + .handler(async ({ context, input }) => { + await context.db + .update(feedItems) + .set({ + isWatchLater: input.isWatchLater, + }) + .where( + and(eq(feedItems.feedId, input.feedId), eq(feedItems.id, input.id)), + ); + }); diff --git a/src/server/api/routers/userConfigRouter.ts b/src/server/api/routers/userConfigRouter.ts index 461a1d7..904cc56 100644 --- a/src/server/api/routers/userConfigRouter.ts +++ b/src/server/api/routers/userConfigRouter.ts @@ -1,12 +1,8 @@ import { sql } from "drizzle-orm"; import { z } from "zod"; -import { - createTRPCRouter, - protectedProcedure, - publicProcedure, -} from "~/server/api/trpc"; import { userConfig } from "~/server/db/schema"; +import { protectedProcedure, publicProcedure } from "~/server/orpc/base"; export type UserConfigValues = { lightHSL: number[] | undefined; @@ -21,50 +17,49 @@ function parseHSL(hsl: string | undefined): number[] | undefined { .map(Number); } -export const userConfigRouter = createTRPCRouter({ - setThemeHSL: protectedProcedure - .input( - z.object({ - theme: z.enum(["light", "dark"]), - hsl: z.tuple([z.number(), z.number(), z.number()]), - }), - ) - .mutation(async ({ ctx, input }) => { - let key: keyof typeof userConfig = "lightHSL"; - if (input.theme === "dark") { - key = "darkHSL"; - } +export const setThemeHSL = protectedProcedure + .input( + z.object({ + theme: z.enum(["light", "dark"]), + hsl: z.tuple([z.number(), z.number(), z.number()]), + }), + ) + .handler(async ({ context, input }) => { + let key: keyof typeof userConfig = "lightHSL"; + if (input.theme === "dark") { + key = "darkHSL"; + } - const formattedHSL = `${input.hsl[0]} ${input.hsl[1]}% ${input.hsl[2]}%`; + const formattedHSL = `${input.hsl[0]} ${input.hsl[1]}% ${input.hsl[2]}%`; - await ctx.db - .insert(userConfig) - .values({ - userId: ctx.auth!.user.id, + await context.db + .insert(userConfig) + .values({ + userId: context.user.id, + [key]: formattedHSL, + }) + .onConflictDoUpdate({ + target: userConfig.userId, + set: { [key]: formattedHSL, - }) - .onConflictDoUpdate({ - target: userConfig.userId, - set: { - [key]: formattedHSL, - updatedAt: sql`CURRENT_TIMESTAMP`, - }, - }); - }), - getConfig: publicProcedure.query( - async ({ ctx }): Promise => { - if (!ctx.auth?.user.id) { - return { lightHSL: undefined, darkHSL: undefined }; - } - - const userConfig = await ctx.db.query.userConfig.findFirst({ - where: sql`user_id = ${ctx.auth.user.id}`, + updatedAt: sql`CURRENT_TIMESTAMP`, + }, }); + }); + +export const getConfig = publicProcedure.handler( + async ({ context }): Promise => { + if (!context.user?.id) { + return { lightHSL: undefined, darkHSL: undefined }; + } + + const userConfig = await context.db.query.userConfig.findFirst({ + where: sql`user_id = ${context.user.id}`, + }); - return { - lightHSL: parseHSL(userConfig?.lightHSL), - darkHSL: parseHSL(userConfig?.darkHSL), - }; - }, - ), -}); + return { + lightHSL: parseHSL(userConfig?.lightHSL), + darkHSL: parseHSL(userConfig?.darkHSL), + }; + }, +); diff --git a/src/server/api/server.ts b/src/server/api/server.ts index 80f6b3d..7b74aaf 100644 --- a/src/server/api/server.ts +++ b/src/server/api/server.ts @@ -1,16 +1,25 @@ -import { appRouter } from "./root"; import { headers as getNextHeaders } from "next/headers"; -import { db } from "../db"; import { auth } from "../auth"; +import { db } from "../db"; + +import { createRouterClient } from "@orpc/server"; +import { orpcRouter } from "../orpc/router"; export const getServerApi = async () => { const headers = await getNextHeaders(); - return appRouter.createCaller({ + const authData = await auth.api.getSession({ headers, - auth: await auth.api.getSession({ + }); + + const client = createRouterClient(orpcRouter, { + context: { headers, - }), - db, + session: authData?.session, + user: authData?.user, + db, + }, }); + + return client; }; diff --git a/src/server/checkFeedItemIsVertical.ts b/src/server/checkFeedItemIsVertical.ts index 29e113c..9c4e6a0 100644 --- a/src/server/checkFeedItemIsVertical.ts +++ b/src/server/checkFeedItemIsVertical.ts @@ -1,5 +1,5 @@ import { Jimp } from "jimp"; -import type { DatabaseFeedItem } from "./db/schema"; +import type { ApplicationFeedItem, DatabaseFeedItem } from "./db/schema"; const WIDTH = 480; const HEIGHT = 360; @@ -8,6 +8,12 @@ function isBlackPixel(value: number) { return value === 255; } +export function checkFeedItemIsVerticalFromUrl( + url: string, +): ApplicationFeedItem["orientation"] { + return url.includes("/shorts/") ? "vertical" : "horizontal"; +} + export async function checkFeedItemIsVerticalFromThumbnail( thumbnail?: string, retries = 0, diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 179bf9e..20ecdb2 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -163,6 +163,7 @@ export const applicationFeedItemSchema = feedItemSchema .merge( z.object({ platform: platformsSchema, + orientation: feedItemOrientationSchema.optional(), }), ) .required(); diff --git a/src/server/orpc/base.ts b/src/server/orpc/base.ts new file mode 100644 index 0000000..4c663d2 --- /dev/null +++ b/src/server/orpc/base.ts @@ -0,0 +1,48 @@ +import { ORPCError, os } from "@orpc/server"; +import { headers as getNextHeaders } from "next/headers"; + +import { db } from "~/server/db"; +import { auth } from "~/server/auth"; + +export async function createRPCContext(opts: { headers: Headers }) { + const headers = new Headers(await getNextHeaders()); + + const authResponse = await auth.api.getSession({ + headers, + }); + + return { + headers: opts.headers, + session: authResponse?.session, + user: authResponse?.user, + db, + }; +} + +const o = os.$context>>(); + +const timingMiddleware = o.middleware(async ({ next, path }) => { + const start = Date.now(); + + try { + return await next(); + } finally { + console.log(`[oRPC] ${path} took ${Date.now() - start}ms to execute`); + } +}); + +export const publicProcedure = o.use(timingMiddleware); + +export const protectedProcedure = publicProcedure.use(({ context, next }) => { + if (!context?.session?.id || !context?.user?.id) { + throw new ORPCError("UNAUTHORIZED"); + } + + return next({ + context: { + session: context.session, + user: context.user, + db, + }, + }); +}); diff --git a/src/server/orpc/router.ts b/src/server/orpc/router.ts new file mode 100644 index 0000000..56f64cb --- /dev/null +++ b/src/server/orpc/router.ts @@ -0,0 +1,9 @@ +import * as feedRouter from "~/server/api/routers/feed-router"; +import * as feedItemRouter from "~/server/api/routers/feedItemRouter"; +import * as userConfigRouter from "~/server/api/routers/userConfigRouter"; + +export const orpcRouter = { + feed: feedRouter, + feedItem: feedItemRouter, + userConfig: userConfigRouter, +}; diff --git a/src/styles/globals.css b/src/styles/globals.css index ed61f15..246fe5d 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -397,15 +397,16 @@ dialog:modal { --color-sidebar-ring: hsl(var(--sidebar-ring)); } -/* - ---break--- -*/ - @layer base { * { @apply border-border outline-ring/50; } - body { + body, + html { @apply bg-background text-foreground; } } + +body { + overscroll-behavior-y: contain; +} diff --git a/src/trpc/react.tsx b/src/trpc/react.tsx index 26f6814..65925e6 100644 --- a/src/trpc/react.tsx +++ b/src/trpc/react.tsx @@ -1,10 +1,12 @@ "use client"; -import type { QueryClient } from "@tanstack/react-query"; +import { type QueryClient } from "@tanstack/react-query"; import { createTRPCClient, + httpBatchStreamLink, + httpSubscriptionLink, loggerLink, - unstable_httpBatchStreamLink, + splitLink, } from "@trpc/client"; import { createTRPCContext } from "@trpc/tanstack-react-query"; import { useState } from "react"; @@ -15,8 +17,8 @@ import { createQueryClient } from "./query-client"; import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; import { - type Persister, PersistQueryClientProvider, + type Persister, } from "@tanstack/react-query-persist-client"; const createSerialAsyncStoragePersister = () => { @@ -65,14 +67,31 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) { process.env.NODE_ENV === "development" || (op.direction === "down" && op.result instanceof Error), }), - unstable_httpBatchStreamLink({ - transformer: SuperJSON, - url: getBaseUrl() + "/api/trpc", - headers() { - const headers = new Headers(); - headers.set("x-trpc-source", "nextjs-react"); - return headers; - }, + splitLink({ + // uses the httpSubscriptionLink for subscriptions + condition: (op) => op.type === "subscription", + true: httpSubscriptionLink({ + transformer: SuperJSON, + url: `/api/trpc`, + eventSourceOptions: async ({ op }) => { + return { + // If not on the same domain + // withCredentials: true, + headers: { + "x-trpc-source": "nextjs-react", + }, + }; + }, + }), + false: httpBatchStreamLink({ + transformer: SuperJSON, + url: `/api/trpc`, + headers() { + const headers = new Headers(); + headers.set("x-trpc-source", "nextjs-react"); + return headers; + }, + }), }), ], }), diff --git a/tsconfig.json b/tsconfig.json index 1eb51db..667a430 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,11 +13,7 @@ "noUncheckedIndexedAccess": true, "checkJs": true, /* Bundled projects */ - "lib": [ - "dom", - "dom.iterable", - "es2023" - ], + "lib": ["dom", "dom.iterable", "esnext"], "noEmit": true, "module": "ESNext", "moduleResolution": "Bundler", @@ -31,9 +27,7 @@ /* Path Aliases */ "baseUrl": ".", "paths": { - "~/*": [ - "./src/*" - ] + "~/*": ["./src/*"] } }, "include": [