diff --git a/.env.example b/.env.example index cef645ba..dd242859 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,15 @@ -# Database (only for cloud dbs or local libsql running on a custom port) -# DATABASE_URL='http://127.0.0.1:8080' -# DATABASE_AUTH_TOKEN= +# REQUIRED - Your base domain url (e.g. https://www.example.com). +VITE_PUBLIC_BASE_URL= -# Authentication +# REQUIRED - Authentication # Generate a secret here: https://www.better-auth.com/docs/installation#set-environment-variables +# Alternatively, run `openssl rand -base64 32` in a terminal and use that value. BETTER_AUTH_SECRET= +# Database (only for cloud dbs or local libsql running on a custom port) +# DATABASE_URL='http://127.0.0.1:8080' +# DATABASE_AUTH_TOKEN= + # OAuth (optional, for custom SSO login) # OAUTH_PROVIDER_ID= # OAUTH_PROVIDER_NAME= diff --git a/.env.test.main b/.env.test.main index 221b824b..636355d9 100644 --- a/.env.test.main +++ b/.env.test.main @@ -9,7 +9,7 @@ DATABASE_URL=http://127.0.0.1:8081 # Auth BETTER_AUTH_SECRET=test-secret-key-for-main-tests -BETTER_AUTH_BASE_URL=http://localhost:3002 +VITE_PUBLIC_BASE_URL=http://localhost:3002 # Main instance flag VITE_PUBLIC_IS_MAIN_INSTANCE=true diff --git a/.env.test.self-hosted b/.env.test.self-hosted index 2772782e..a92d1bb8 100644 --- a/.env.test.self-hosted +++ b/.env.test.self-hosted @@ -9,7 +9,7 @@ DATABASE_URL=http://127.0.0.1:8082 # Auth BETTER_AUTH_SECRET=test-secret-key-for-self-hosted-tests -BETTER_AUTH_BASE_URL=http://localhost:3001 +VITE_PUBLIC_BASE_URL=http://localhost:3001 # Self-hosted flag VITE_PUBLIC_IS_MAIN_INSTANCE=false diff --git a/README.md b/README.md index 06115f2b..fe742399 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Self hosting Serial is relatively easy. Here are the current step by step platfo If your preferred platform doesn't have a guide, follow these rough steps: -1. Fork the `hfellerhoff/serial` respository to your own GitHub account. +1. Fork the `megaflorasoftware/serial` respository to your own GitHub account. 2. Use a git-based deployment system to deploy when a new commit happens. This will make it easy to keep your deploment up to date. 3. Set up a custom domain (if desired) 4. Set up your database: diff --git a/docker-compose.arm.yaml b/docker-compose.arm.yaml index 4a7bdc31..c6ba8196 100644 --- a/docker-compose.arm.yaml +++ b/docker-compose.arm.yaml @@ -33,6 +33,7 @@ services: DATABASE_URL: http://libsql:8080 KV_STORE: ioredis REDIS_URL: redis://redis:6379 + VITE_PUBLIC_BASE_URL: ${VITE_PUBLIC_BASE_URL} VITE_PUBLIC_IS_MAIN_INSTANCE: "false" BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} diff --git a/docker-compose.build-arm.yaml b/docker-compose.build-arm.yaml index 39366a16..0d4cefbe 100644 --- a/docker-compose.build-arm.yaml +++ b/docker-compose.build-arm.yaml @@ -37,6 +37,7 @@ services: DATABASE_URL: http://libsql:8080 KV_STORE: ioredis REDIS_URL: redis://redis:6379 + VITE_PUBLIC_BASE_URL: ${VITE_PUBLIC_BASE_URL} VITE_PUBLIC_IS_MAIN_INSTANCE: "false" BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} diff --git a/docker-compose.build-main-instance-ioredis.yaml b/docker-compose.build-main-instance-ioredis.yaml index 825f6646..50dce97f 100644 --- a/docker-compose.build-main-instance-ioredis.yaml +++ b/docker-compose.build-main-instance-ioredis.yaml @@ -24,7 +24,6 @@ services: DATABASE_URL: ${DATABASE_URL} DATABASE_AUTH_TOKEN: ${DATABASE_AUTH_TOKEN} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} - VITE_PUBLIC_IS_MAIN_INSTANCE: "true" KV_STORE: ioredis REDIS_URL: redis://redis:6379 POLAR_ENVIRONMENT: ${POLAR_ENVIRONMENT} @@ -41,9 +40,11 @@ services: FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} RESEND_API_KEY: ${RESEND_API_KEY} SENDGRID_API_KEY: ${SENDGRID_API_KEY} - VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID} INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET} + VITE_PUBLIC_IS_MAIN_INSTANCE: "true" + VITE_PUBLIC_BASE_URL: ${VITE_PUBLIC_BASE_URL} + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} VITE_PUBLIC_IS_MAINTENANCE_MODE: ${VITE_PUBLIC_IS_MAINTENANCE_MODE} depends_on: redis: diff --git a/docker-compose.build-main-instance-upstash.yaml b/docker-compose.build-main-instance-upstash.yaml index 45e9e718..40e6c0d9 100644 --- a/docker-compose.build-main-instance-upstash.yaml +++ b/docker-compose.build-main-instance-upstash.yaml @@ -11,7 +11,6 @@ services: DATABASE_URL: ${DATABASE_URL} DATABASE_AUTH_TOKEN: ${DATABASE_AUTH_TOKEN} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} - VITE_PUBLIC_IS_MAIN_INSTANCE: "true" KV_STORE: upstash UPSTASH_REDIS_REST_URL: ${UPSTASH_REDIS_REST_URL} UPSTASH_REDIS_REST_TOKEN: ${UPSTASH_REDIS_REST_TOKEN} @@ -29,9 +28,11 @@ services: FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} RESEND_API_KEY: ${RESEND_API_KEY} SENDGRID_API_KEY: ${SENDGRID_API_KEY} - VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID} INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET} + VITE_PUBLIC_BASE_URL: ${VITE_PUBLIC_BASE_URL} + VITE_PUBLIC_IS_MAIN_INSTANCE: "true" + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} VITE_PUBLIC_IS_MAINTENANCE_MODE: ${VITE_PUBLIC_IS_MAINTENANCE_MODE} ports: - 3000:3000 diff --git a/docker-compose.build-standalone.yaml b/docker-compose.build-standalone.yaml index ec8903ad..abd1b17b 100644 --- a/docker-compose.build-standalone.yaml +++ b/docker-compose.build-standalone.yaml @@ -11,6 +11,7 @@ services: DATABASE_URL: ${DATABASE_URL} DATABASE_AUTH_TOKEN: ${DATABASE_AUTH_TOKEN} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + VITE_PUBLIC_BASE_URL: ${VITE_PUBLIC_BASE_URL} VITE_PUBLIC_IS_MAIN_INSTANCE: "false" KV_STORE: ${KV_STORE} REDIS_URL: ${REDIS_URL} diff --git a/docker-compose.build.yaml b/docker-compose.build.yaml index 5340e36c..b6479b30 100644 --- a/docker-compose.build.yaml +++ b/docker-compose.build.yaml @@ -37,6 +37,7 @@ services: DATABASE_URL: http://libsql:8080 KV_STORE: ioredis REDIS_URL: redis://redis:6379 + VITE_PUBLIC_BASE_URL: ${VITE_PUBLIC_BASE_URL} VITE_PUBLIC_IS_MAIN_INSTANCE: "false" BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} diff --git a/docker-compose.standalone.yaml b/docker-compose.standalone.yaml index 859b8ff9..6e9790ac 100644 --- a/docker-compose.standalone.yaml +++ b/docker-compose.standalone.yaml @@ -6,6 +6,7 @@ services: DATABASE_URL: ${DATABASE_URL} DATABASE_AUTH_TOKEN: ${DATABASE_AUTH_TOKEN} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + VITE_PUBLIC_BASE_URL: ${VITE_PUBLIC_BASE_URL} VITE_PUBLIC_IS_MAIN_INSTANCE: "false" KV_STORE: ${KV_STORE} REDIS_URL: ${REDIS_URL} diff --git a/docker-compose.yaml b/docker-compose.yaml index 4aa74e87..d73ad317 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -33,14 +33,15 @@ services: DATABASE_URL: http://libsql:8080 KV_STORE: ioredis REDIS_URL: redis://redis:6379 - VITE_PUBLIC_IS_MAIN_INSTANCE: "false" BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} RESEND_API_KEY: ${RESEND_API_KEY} SENDGRID_API_KEY: ${SENDGRID_API_KEY} - VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID} INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET} + VITE_PUBLIC_BASE_URL: ${VITE_PUBLIC_BASE_URL} + VITE_PUBLIC_IS_MAIN_INSTANCE: "false" + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} VITE_PUBLIC_IS_MAINTENANCE_MODE: ${VITE_PUBLIC_IS_MAINTENANCE_MODE} depends_on: libsql: diff --git a/docs/hosting/coolify.md b/docs/hosting/coolify.md index a0bf21bf..af58e451 100644 --- a/docs/hosting/coolify.md +++ b/docs/hosting/coolify.md @@ -10,7 +10,7 @@ 2. Create or pick a project for Serial to live in. 3. Create a new resource, then choose `Public Repository`. 4. Choose the server to deploy Serial on. If you run Coolify on a single VPS, this will be `localhost`. -5. For the repository, enter `https://github.com/hfellerhoff/serial`. +5. For the repository, enter `https://github.com/megaflorasoftware/serial`. 6. Update `Build Pack` to be `Docker Compose` and choose a compose file: - Use `docker-compose.yaml` to use a local DB on an x86 architecture (default) - Use `docker-compose.arm.yaml` to use a local DB on an ARM architecture @@ -28,7 +28,7 @@ You can access the app through the domain you added (which can always be found in the `Links` header item). To update the app in the future, hit `Redeploy` in Coolify. -If you'd like to support additional features, [see this section](https://github.com/hfellerhoff/serial#enabling-additional-features)! +If you'd like to support additional features, [see this section](https://github.com/megaflorasoftware/serial#enabling-additional-features)! ## Deploy using Docker Compose file @@ -41,9 +41,9 @@ If you'd like to support additional features, [see this section](https://github. 3. Create a new resource, then choose `Docker Compose Empty`. 4. Choose the server to deploy Serial on. If you run Coolify on a single VPS, this will be `localhost`. 5. Determine the best Serial docker compose file for your needs: - - Use [`docker-compose.yaml`](https://raw.githubusercontent.com/hfellerhoff/serial/refs/heads/main/docker-compose.yaml) to use a local DB on an x86 architecture (default) - - Use [`docker-compose.arm.yaml`](https://raw.githubusercontent.com/hfellerhoff/serial/refs/heads/main/docker-compose.arm.yaml) to use a local DB on an ARM architecture - - Use [`docker-compose.standalone.yaml`](https://raw.githubusercontent.com/hfellerhoff/serial/refs/heads/main/docker-compose.standalone.yaml) for standalone deployments (for plugging in your own cloud services or external DB) + - Use [`docker-compose.yaml`](https://raw.githubusercontent.com/megaflorasoftware/serial/refs/heads/main/docker-compose.yaml) to use a local DB on an x86 architecture (default) + - Use [`docker-compose.arm.yaml`](https://raw.githubusercontent.com/megaflorasoftware/serial/refs/heads/main/docker-compose.arm.yaml) to use a local DB on an ARM architecture + - Use [`docker-compose.standalone.yaml`](https://raw.githubusercontent.com/megaflorasoftware/serial/refs/heads/main/docker-compose.standalone.yaml) for standalone deployments (for plugging in your own cloud services or external DB) 6. Paste the file content into Coolify 7. (optional) Add your custom domain: - Click `Settings` for the `Serial` service @@ -56,11 +56,11 @@ If you'd like to support additional features, [see this section](https://github. You can access the app through the domain you added (which can always be found in the `Links` header item). To update the app in the future, hit `Redeploy` in Coolify. -If you'd like to support additional features, [see this section](https://github.com/hfellerhoff/serial#enabling-additional-features)! +If you'd like to support additional features, [see this section](https://github.com/megaflorasoftware/serial#enabling-additional-features)! ## Deploy using Private Repository (with GitHub App) -1. Fork the `hfellerhoff/serial` respository to your own GitHub account. +1. Fork the `megaflorasoftware/serial` respository to your own GitHub account. 2. If you don't have a [Coolify](https://coolify.io/) instance set up: 1. Set up a locally hosted server, or purchase a VPS. - The cheapest option (that I know of in January 2026) is through Hetzner, where a VPS based in Germany or Finland will be around $4 a month. An `x86` architecure is recommended, but not required. @@ -86,4 +86,4 @@ If you'd like to support additional features, [see this section](https://github. You can access the app through the domain you added (which can always be found in the `Links` header item). To update the app in the future, sync your branch with the upstream repository and Coolify will redeploy it automatically. -If you'd like to support additional features, [see this section](https://github.com/hfellerhoff/serial#enabling-additional-features)! +If you'd like to support additional features, [see this section](https://github.com/megaflorasoftware/serial#enabling-additional-features)! diff --git a/docs/hosting/vercel.md b/docs/hosting/vercel.md index 7630e227..7b0fd3bd 100644 --- a/docs/hosting/vercel.md +++ b/docs/hosting/vercel.md @@ -2,7 +2,7 @@ > Note: Only cloud database hosting is available on Vercel deployments. -1. Fork the `hfellerhoff/serial` respository to your own GitHub account. +1. Fork the `megaflorasoftware/serial` respository to your own GitHub account. 2. Login to [Vercel](https://vercel.com/) and follow the onboarding to link your GitHub account. 3. Choose the `serial` repository and hit deploy. Your initial deployment will fail – that's okay. 4. Within your project, navigate to `Settings > Domains`. You have a few options for project domains: @@ -18,4 +18,4 @@ 6. Navigate to [Better Auth](https://www.better-auth.com/docs/installation#set-environment-variables) and generate an auth secret. Set this as `BETTER_AUTH_SECRET` in your environment variables. 7. That's it! Head on over to `Deployments` in the top navigation bar, choose `Create Deployment` in the top right menu, and head on over to your project URL once it's done! -If you'd like to support additional features, [see this section](https://github.com/hfellerhoff/serial#enabling-additional-features)! +If you'd like to support additional features, [see this section](https://github.com/megaflorasoftware/serial#enabling-additional-features)! diff --git a/public/blog/images/youtube-subscription-import/step_1.png b/public/guides/images/youtube-subscription-import/step_1.png similarity index 100% rename from public/blog/images/youtube-subscription-import/step_1.png rename to public/guides/images/youtube-subscription-import/step_1.png diff --git a/public/blog/images/youtube-subscription-import/step_2.png b/public/guides/images/youtube-subscription-import/step_2.png similarity index 100% rename from public/blog/images/youtube-subscription-import/step_2.png rename to public/guides/images/youtube-subscription-import/step_2.png diff --git a/public/blog/images/youtube-subscription-import/step_3.png b/public/guides/images/youtube-subscription-import/step_3.png similarity index 100% rename from public/blog/images/youtube-subscription-import/step_3.png rename to public/guides/images/youtube-subscription-import/step_3.png diff --git a/public/blog/images/youtube-subscription-import/step_4.png b/public/guides/images/youtube-subscription-import/step_4.png similarity index 100% rename from public/blog/images/youtube-subscription-import/step_4.png rename to public/guides/images/youtube-subscription-import/step_4.png diff --git a/public/blog/images/youtube-subscription-import/step_5.png b/public/guides/images/youtube-subscription-import/step_5.png similarity index 100% rename from public/blog/images/youtube-subscription-import/step_5.png rename to public/guides/images/youtube-subscription-import/step_5.png diff --git a/public/blog/images/youtube-subscription-import/step_6.png b/public/guides/images/youtube-subscription-import/step_6.png similarity index 100% rename from public/blog/images/youtube-subscription-import/step_6.png rename to public/guides/images/youtube-subscription-import/step_6.png diff --git a/public/welcome/screenshot-desktop-dark.jpeg b/public/welcome/screenshot-desktop-dark.jpeg new file mode 100644 index 00000000..5a43beb8 Binary files /dev/null and b/public/welcome/screenshot-desktop-dark.jpeg differ diff --git a/public/welcome/screenshot-desktop-light.jpeg b/public/welcome/screenshot-desktop-light.jpeg new file mode 100644 index 00000000..845ad328 Binary files /dev/null and b/public/welcome/screenshot-desktop-light.jpeg differ diff --git a/public/welcome/screenshot-mobile-dark.jpeg b/public/welcome/screenshot-mobile-dark.jpeg new file mode 100644 index 00000000..d593a84f Binary files /dev/null and b/public/welcome/screenshot-mobile-dark.jpeg differ diff --git a/public/welcome/screenshot-mobile-light.jpeg b/public/welcome/screenshot-mobile-light.jpeg new file mode 100644 index 00000000..8d18398c Binary files /dev/null and b/public/welcome/screenshot-mobile-light.jpeg differ diff --git a/src/app/_app.import.tsx b/src/app/_app.import.tsx index 6123c835..f90edeff 100644 --- a/src/app/_app.import.tsx +++ b/src/app/_app.import.tsx @@ -6,6 +6,7 @@ import { CheckIcon, ExternalLinkIcon, GlobeIcon, + Loader2Icon, MinusIcon, PauseIcon, PlayCircleIcon, @@ -13,13 +14,17 @@ import { } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { useSetAtom } from "jotai"; import { ImportDropzone } from "../components/feed/import/ImportDropzone"; import { getInitialFeedDataFromFileInputElement } from "../components/feed/import/utils/getInitialFeedDataFromFileInputElement"; -import type { ImportFeedDataItem } from "../components/feed/import/utils/shared"; +import type { + ImportFeedDataFromFilesError, + ImportFeedDataItem, +} from "../components/feed/import/utils/shared"; import type { CardRadioOption } from "~/components/ui/card-radio-group"; import type { FeedPlatform } from "~/server/db/schema"; import { YoutubeIcon } from "~/components/brand-icons"; -import { getBlogUrl } from "~/lib/constants"; +import { getGuidesUrl } from "~/lib/constants"; import { ImportLoading } from "~/components/ImportLoading"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; @@ -35,6 +40,7 @@ import { feedItemsStore } from "~/lib/data/store"; import { useImportResults, useLoadingMode } from "~/lib/data/loading-machine"; import { dataSubscriptionActions } from "~/lib/data/useDataSubscription"; import { useDialogStore } from "~/components/feed/dialogStore"; +import { shouldAlwaysKeepSSEConnectionAlive } from "~/lib/data/atoms"; function ImportedFeedStatus({ feedUrl, @@ -105,7 +111,12 @@ function EditFeedsPage() { >(null); const [hasStartedImport, setHasStartedImport] = useState(false); const [isImportComplete, setIsImportComplete] = useState(false); + const [isImportPending, setIsImportPending] = useState(false); const [importMode, setImportMode] = useState("views"); + + const [fileInputErrorList, setFileInputErrorList] = + useState(null); + // Signal to Playwright tests that React has hydrated and the onChange handler // is attached to the file input, so file-chooser interactions are reliable. useEffect(() => { @@ -124,6 +135,30 @@ function EditFeedsPage() { const { launchDialog } = useDialogStore(); const isPostImportScreen = isImportComplete || hasStartedImport; + const setShouldAlwaysKeepSSEConnectionAlive = useSetAtom( + shouldAlwaysKeepSSEConnectionAlive, + ); + + // Keep SSE open during import so visibility changes don't disconnect the + // streaming import. Reset when the import loader is hidden. + useEffect(() => { + if (!isFetchingRss && hasStartedImport) { + setShouldAlwaysKeepSSEConnectionAlive(false); + } + }, [isFetchingRss, hasStartedImport, setShouldAlwaysKeepSSEConnectionAlive]); + + useEffect(() => { + return () => { + setShouldAlwaysKeepSSEConnectionAlive(false); + }; + }, [setShouldAlwaysKeepSSEConnectionAlive]); + + useEffect(() => { + if (isImportPending && loading.mode === "importing" && !hasStartedImport) { + const id = requestAnimationFrame(() => setHasStartedImport(true)); + return () => cancelAnimationFrame(id); + } + }, [isImportPending, loading.mode, hasStartedImport]); useEffect(() => { if (isImportComplete && importDeactivatedCount > 0) { @@ -158,15 +193,17 @@ function EditFeedsPage() { ), })); setFeedsFoundFromFile(feedsWithImportStatus); + setFileInputErrorList(null); + } else { + setFileInputErrorList(feedResult); } }; const onFeedImport = async () => { if (!feedsFoundFromFile?.length) return; - setTimeout(() => { - setHasStartedImport(true); - }, 500); + setShouldAlwaysKeepSSEConnectionAlive(true); + setIsImportPending(true); const channelsToImport = feedsFoundFromFile .filter((channel) => channel.shouldImport) @@ -207,12 +244,15 @@ function EditFeedsPage() { ]); setIsImportComplete(true); + setIsImportPending(false); }; const onReset = () => { setFeedsFoundFromFile(null); setHasStartedImport(false); setIsImportComplete(false); + setIsImportPending(false); + setShouldAlwaysKeepSSEConnectionAlive(false); }; if (isFetchingRss) { @@ -232,7 +272,7 @@ function EditFeedsPage() { {" "} files from{" "} + {!!fileInputErrorList?.errors?.length && ( +
+ {fileInputErrorList.errors.map((error) => ( +
{error}
+ ))} +
+ )} {!!feedsFoundFromFile && ( <> {!isPostImportScreen && @@ -459,12 +506,19 @@ function EditFeedsPage() {
diff --git a/src/app/blog.$slug.tsx b/src/app/_web.guides.$slug.tsx similarity index 67% rename from src/app/blog.$slug.tsx rename to src/app/_web.guides.$slug.tsx index 38bedeee..81b91ed1 100644 --- a/src/app/blog.$slug.tsx +++ b/src/app/_web.guides.$slug.tsx @@ -3,31 +3,29 @@ import dayjs from "dayjs"; import { BookOpenIcon, PenLineIcon, RssIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { YoutubeIcon } from "~/components/brand-icons"; -import { DemoColorThemePopoverButton } from "~/components/color-theme/ColorThemePopoverButton"; import { Markdown } from "~/components/Markdown"; - import { Button } from "~/components/ui/button"; -import { getBlogPostWithSlug } from "~/lib/markdown/loaders"; +import { getGuidePostWithSlug } from "~/lib/markdown/loaders"; import { fetchIsAuthed } from "~/server/auth/endpoints"; -export const Route = createFileRoute("/blog/$slug")({ +export const Route = createFileRoute("/_web/guides/$slug")({ component: RouteComponent, loader: async ({ params }) => { const isAuthed = await fetchIsAuthed(); - const post = getBlogPostWithSlug(params.slug); + const post = getGuidePostWithSlug(params.slug); return { post, isAuthed }; }, }); -const BLOG_ICONS: Record = { +const GUIDE_ICONS: Record = { youtube: YoutubeIcon, rss: RssIcon, "book-open": BookOpenIcon, "pen-line": PenLineIcon, }; -function BlogIcon({ name }: { name: string }) { - const Icon = BLOG_ICONS[name]; +function GuideIcon({ name }: { name: string }) { + const Icon = GUIDE_ICONS[name]; if (!Icon) return null; return ( @@ -38,30 +36,13 @@ function BlogIcon({ name }: { name: string }) { } function RouteComponent() { - const { post, isAuthed } = Route.useLoaderData(); + const { post } = Route.useLoaderData(); return (
-
- - ← Back to {isAuthed ? "App" : "Home"} - -
- -
- - ↑ All Posts - -
- {post.icon && } + {post.icon && }

{post.title}

@@ -80,7 +61,7 @@ function RouteComponent() { )}

- +
diff --git a/src/app/_web.guides.index.tsx b/src/app/_web.guides.index.tsx new file mode 100644 index 00000000..546d9194 --- /dev/null +++ b/src/app/_web.guides.index.tsx @@ -0,0 +1,59 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import dayjs from "dayjs"; +import { BookIcon } from "lucide-react"; +import { WebsiteHeader } from "~/components/welcome/WebsiteHeader"; +import { getAllGuidePosts } from "~/lib/markdown/loaders"; +import { fetchIsAuthed } from "~/server/auth/endpoints"; + +export const Route = createFileRoute("/_web/guides/")({ + component: RouteComponent, + loader: async () => { + const isAuthed = await fetchIsAuthed(); + + return { + isAuthed, + posts: getAllGuidePosts(), + }; + }, +}); + +function RouteComponent() { + const { posts } = Route.useLoaderData(); + + return ( +
+ +
+
    + {!posts.length && ( +

    Nothing to see here. Check back soon!

    + )} + {posts.map(({ slug, title, description, publish_date }) => { + return ( +
  • +
    + + {title} + +

    + {dayjs(publish_date).format("MMMM DD, YYYY")} +

    +
    + {description &&

    {description}

    } +
  • + ); + })} +
+
+
+ ); +} diff --git a/src/app/blog.tsx b/src/app/_web.guides.tsx similarity index 51% rename from src/app/blog.tsx rename to src/app/_web.guides.tsx index b073d475..15a96747 100644 --- a/src/app/blog.tsx +++ b/src/app/_web.guides.tsx @@ -1,21 +1,15 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; import { BASE_SIGNED_OUT_URL, IS_MAIN_INSTANCE } from "~/lib/constants"; -export const Route = createFileRoute("/blog")({ +export const Route = createFileRoute("/_web/guides")({ beforeLoad: () => { if (!IS_MAIN_INSTANCE) { throw redirect({ to: BASE_SIGNED_OUT_URL }); } }, - component: BlogLayout, + component: GuidesLayout, }); -function BlogLayout() { - return ( -
-
- -
-
- ); +function GuidesLayout() { + return ; } diff --git a/src/app/_web.releases.$slug.tsx b/src/app/_web.releases.$slug.tsx index 13f18395..1a2d974b 100644 --- a/src/app/_web.releases.$slug.tsx +++ b/src/app/_web.releases.$slug.tsx @@ -1,5 +1,8 @@ import { createFileRoute, Link } from "@tanstack/react-router"; +import dayjs from "dayjs"; +import { NotebookTextIcon } from "lucide-react"; import { Markdown } from "~/components/Markdown"; +import { Button } from "~/components/ui/button"; import { getReleaseWithSlug } from "~/lib/markdown/loaders"; import { fetchIsAuthed } from "~/server/auth/endpoints"; @@ -13,54 +16,44 @@ export const Route = createFileRoute("/_web/releases/$slug")({ }); function RouteComponent() { - const { release, isAuthed } = Route.useLoaderData(); - const supportEmail = import.meta.env.VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS; + const { release } = Route.useLoaderData(); return ( -
-
- {isAuthed && ( - <> - ⭠ Back to App - ↑ All Releases - - )} - {!isAuthed && ( - <> - ⭠ Back to Home - ↑ All Releases - - )} -
-

{release.publish_date}

-

{release.title}

-

{release.description}

-
- - {isAuthed && ( - <> -

- Thanks for checking out the release log! - {supportEmail && ( - <> - {" "} - If you have any questions or feedback, feel free to send me an - email at {supportEmail}. - - )} -

- Return to the app → - - )} - {!isAuthed && ( - <> -

- Thanks for checking out the release log! If you think Serial would - be a great fit for you, you can{" "} - sign up here. +

+
+
+
+ +
+

+ {release.title} +

+ {release.description && ( +

{release.description}

+ )} +

+ {dayjs(release.publish_date).format("MMMM DD, YYYY")}

- - )} -
+
+ +
+
+
+

Ready to take back control of your content?

+
+ + + + + + +
+
+
+
); } diff --git a/src/app/_web.releases.index.tsx b/src/app/_web.releases.index.tsx index 55379b3d..ce8a262d 100644 --- a/src/app/_web.releases.index.tsx +++ b/src/app/_web.releases.index.tsx @@ -1,5 +1,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import dayjs from "dayjs"; +import { ContainerIcon } from "lucide-react"; +import { WebsiteHeader } from "~/components/welcome/WebsiteHeader"; import { getAllReleases } from "~/lib/markdown/loaders"; import { fetchIsAuthed } from "~/server/auth/endpoints"; @@ -16,39 +18,38 @@ export const Route = createFileRoute("/_web/releases/")({ }); function RouteComponent() { - const { isAuthed, releases } = Route.useLoaderData(); + const { releases } = Route.useLoaderData(); return (
-

- - ⭠ Back to {isAuthed ? "App" : "Home"} - -

-

Releases

-
    - {!releases.length &&

    Nothing to see here. Check back soon!

    } - {releases.map(({ slug, title, description, publish_date }) => { - return ( -
  • -
    - - {title} - -

    - {dayjs(publish_date).format("MMMM DD, YYYY")} -

    -
    -

    {description}

    -
  • - ); - })} -
+ +
+
    + {!releases.length && ( +

    Nothing to see here. Check back soon!

    + )} + {releases.map(({ slug, title, description, publish_date }) => { + return ( +
  • +
    + + {title} + +

    + {dayjs(publish_date).format("MMMM DD, YYYY")} +

    +
    + {description &&

    {description}

    } +
  • + ); + })} +
+
); } diff --git a/src/app/_web.tsx b/src/app/_web.tsx index fc51268c..a96656d1 100644 --- a/src/app/_web.tsx +++ b/src/app/_web.tsx @@ -1,13 +1,28 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; +import { RecentReleaseBanner } from "~/components/welcome/RecentReleaseBanner"; +import { WebsiteNavigation } from "~/components/welcome/WebsiteNavigation"; +import { getMostRecentRelease } from "~/lib/markdown/loaders"; +import { fetchIsAuthed } from "~/server/auth/endpoints"; export const Route = createFileRoute("/_web")({ component: RootLayout, + loader: async () => { + const isAuthed = await fetchIsAuthed(); + const mostRecentRelease = getMostRecentRelease(); + return { isAuthed, mostRecentRelease }; + }, }); function RootLayout() { + const { isAuthed, mostRecentRelease } = Route.useLoaderData(); + return ( -
- -
+
+ + +
+ +
+
); } diff --git a/src/app/blog.index.tsx b/src/app/blog.index.tsx deleted file mode 100644 index 313d1aeb..00000000 --- a/src/app/blog.index.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; -import dayjs from "dayjs"; -import { DemoColorThemePopoverButton } from "~/components/color-theme/ColorThemePopoverButton"; -import { getAllBlogPosts } from "~/lib/markdown/loaders"; -import { fetchIsAuthed } from "~/server/auth/endpoints"; - -export const Route = createFileRoute("/blog/")({ - component: RouteComponent, - loader: async () => { - const isAuthed = await fetchIsAuthed(); - - return { - isAuthed, - posts: getAllBlogPosts(), - }; - }, -}); - -function RouteComponent() { - const { isAuthed, posts } = Route.useLoaderData(); - - return ( -
-
- - ← Back to {isAuthed ? "App" : "Home"} - -
- -
-
-

Blog

-
    - {!posts.length && ( -

    Nothing to see here. Check back soon!

    - )} - {posts.map(({ slug, title, description, publish_date }) => { - return ( -
  • -
    - - {title} - -

    - {dayjs(publish_date).format("MMMM DD, YYYY")} -

    -
    - {description &&

    {description}

    } -
  • - ); - })} -
-
- ); -} diff --git a/src/app/pricing.tsx b/src/app/pricing.tsx new file mode 100644 index 00000000..002f30f3 --- /dev/null +++ b/src/app/pricing.tsx @@ -0,0 +1,236 @@ +import { createFileRoute, Link, redirect } from "@tanstack/react-router"; +import { + CheckIcon, + CoinsIcon, + ExternalLinkIcon, + SproutIcon, + TreeDeciduousIcon, + TreesIcon, +} from "lucide-react"; +import { + QUOTA_DISPLAY_NAMES, + STANDARD_PLAN_IDS, +} from "~/components/feed/subscription-dialog/constants"; +import { getPlanFeatures } from "~/components/feed/subscription-dialog/utils"; +import { Button } from "~/components/ui/button"; +import { Card } from "~/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip"; +import { RecentReleaseBanner } from "~/components/welcome/RecentReleaseBanner"; +import { WebsiteHeader } from "~/components/welcome/WebsiteHeader"; +import { WebsiteNavigation } from "~/components/welcome/WebsiteNavigation"; +import { BASE_SIGNED_OUT_URL, IS_MAIN_INSTANCE } from "~/lib/constants"; +import { getMostRecentRelease } from "~/lib/markdown/loaders"; +import { AUTH_PAGE_URL } from "~/server/auth/constants"; +import { fetchIsAuthed } from "~/server/auth/endpoints"; +import { PLANS } from "~/server/subscriptions/plans"; + +const PLAN_PRICES = { + "standard-small": { monthly: 4, annual: 40 }, + "standard-medium": { monthly: 6, annual: 60 }, + "standard-large": { monthly: 8, annual: 80 }, + pro: { monthly: 16, annual: 160 }, +} as const; + +function getMonthlyFromAnnual(annual: number): string { + const monthly = annual / 12; + const withCents = monthly.toFixed(2); + return withCents.endsWith(".00") ? withCents.slice(0, -3) : withCents; +} + +export const Route = createFileRoute("/pricing")({ + beforeLoad: () => { + if (!IS_MAIN_INSTANCE) { + throw redirect({ to: BASE_SIGNED_OUT_URL }); + } + }, + component: RouteComponent, + loader: async () => { + const isAuthed = await fetchIsAuthed(); + const mostRecentRelease = getMostRecentRelease(); + return { isAuthed, mostRecentRelease }; + }, +}); + +function RouteComponent() { + const { isAuthed, mostRecentRelease } = Route.useLoaderData(); + const supportEmail = import.meta.env.VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS; + + return ( +
+ + +
+ +
+
+ +
+ +

{PLANS.free.name}

+
+
    + {getPlanFeatures(PLANS.free).map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ + +
+ +

Standard

+
+
    + {getPlanFeatures(PLANS["standard-small"]) + .filter((f) => !f.startsWith("Up to")) + .map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ {STANDARD_PLAN_IDS.map((id) => { + const plan = PLANS[id]; + + return ( +
+
+ + {QUOTA_DISPLAY_NAMES[id]} + + + ${PLAN_PRICES[id].monthly}/mo ·{" "} + + + + ${PLAN_PRICES[id].annual}/yr + + + + ${getMonthlyFromAnnual(PLAN_PRICES[id].annual)}/mo + + + +
+

+ Up to {plan.maxActiveFeeds.toLocaleString()} active + feeds +

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

{PLANS.pro.name}

+
+
+ ${PLAN_PRICES.pro.monthly}/mo ·{" "} + + + + ${PLAN_PRICES.pro.annual}/yr + + + + ${getMonthlyFromAnnual(PLAN_PRICES.pro.annual)}/mo + + +
+
+
    + {getPlanFeatures(PLANS.pro).map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+
+
+ +
+

+ If the cost of Serial is too much for you, anyone can run an + instance of Serial for themselves. You won't need to pay us + anything, but you will need to have a dedicated computer to run it + on, which can be as cheap as $5-6 a month. +

+

+ This can be a great option for users who are very privacy-conscious, + or for those looking to provide Serial as a service for their + friends or family. +

+

+ + Here is the step-by-step guide + {" "} + on how to host your own Serial instance. +

+

+ If Serial is cost-prohibitive and self-hosting is not feasible for + you, don't hesitate to{" "} + + get in touch + {" "} + – we'd be happy to work something out. +

+
+
+
+

Ready to take back control of your content?

+
+ + + + + + +
+
+
+ {supportEmail && ( +
+

+ Have a question? Reach us at{" "} + + {supportEmail} + +

+
+ )} +
+
+ ); +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index cde3c6f7..cdfb2c15 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { IS_MAIN_INSTANCE } from "~/lib/constants"; -import { getAllBlogPosts, getAllReleases } from "~/lib/markdown/loaders"; +import { getAllGuidePosts, getAllReleases } from "~/lib/markdown/loaders"; export const Route = createFileRoute("/sitemap")({ server: { @@ -12,7 +12,7 @@ export const Route = createFileRoute("/sitemap")({ if (IS_MAIN_INSTANCE) { const releases = getAllReleases(); - const blogPosts = getAllBlogPosts(); + const guidePosts = getAllGuidePosts(); urls.push( { loc: "/welcome" }, @@ -21,9 +21,9 @@ export const Route = createFileRoute("/sitemap")({ loc: `/releases/${r.slug}`, lastmod: r.publish_date, })), - { loc: "/blog" }, - ...blogPosts.map((p) => ({ - loc: `/blog/${p.slug}`, + { loc: "/guides" }, + ...guidePosts.map((p) => ({ + loc: `/guides/${p.slug}`, lastmod: p.updated_at ?? p.publish_date, })), ); diff --git a/src/app/welcome.tsx b/src/app/welcome.tsx index 86f687d9..ee411c3f 100644 --- a/src/app/welcome.tsx +++ b/src/app/welcome.tsx @@ -1,11 +1,13 @@ import { createFileRoute, Link, redirect } from "@tanstack/react-router"; +import { ExternalLinkIcon } from "lucide-react"; import { DemoColorThemePopoverButton } from "~/components/color-theme/ColorThemePopoverButton"; import { Button } from "~/components/ui/button"; -import { DemoCarousel } from "~/components/welcome/DemoCarousel"; import { RecentReleaseBanner } from "~/components/welcome/RecentReleaseBanner"; +import { WebsiteNavigation } from "~/components/welcome/WebsiteNavigation"; import { BASE_SIGNED_OUT_URL, IS_MAIN_INSTANCE } from "~/lib/constants"; import { getMostRecentRelease } from "~/lib/markdown/loaders"; import { AUTH_PAGE_URL } from "~/server/auth/constants"; +import { fetchIsAuthed } from "~/server/auth/endpoints"; export const Route = createFileRoute("/welcome")({ beforeLoad: () => { @@ -14,24 +16,30 @@ export const Route = createFileRoute("/welcome")({ } }, component: RouteComponent, - loader: () => { + loader: async () => { + const isAuthed = await fetchIsAuthed(); const mostRecentRelease = getMostRecentRelease(); - return { mostRecentRelease }; + return { isAuthed, mostRecentRelease }; }, staleTime: 1000 * 60 * 60, }); function RouteComponent() { - const { mostRecentRelease } = Route.useLoaderData(); + const { isAuthed, mostRecentRelease } = Route.useLoaderData(); const supportEmail = import.meta.env.VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS; return (
-
- + +
-

+ Serial logo +

Serial

@@ -39,15 +47,60 @@ function RouteComponent() { customization options and great support for video content. Fully open source and easily self-hostable.

- - - +
+ + + + + + +
+
+
+
+ A screenshot of the Serial desktop site in light mode + A screenshot of the Serial mobile site in light mode +
+
+
+
+
+ A screenshot of the Serial mobile site in dark mode +
+
+
+ A screenshot of the Serial desktop site in dark mode +
-
-
+ +
+

Our digital lives are spread across many platforms, publications, and channels. @@ -136,13 +189,14 @@ function RouteComponent() {

Pricing Transparency

- Serial is currently free while in beta, and there will be a small - subscription after that period ends for users over 100 feeds. + You can use Serial for free with up to 40 feeds. After that, most + people can get enough feeds for $4 to $6 a month.

- {/*

- You can have up to 100 different feeds on Serial for free. After - that, it's $2 a month or $20 a year. -

*/} + + +
@@ -150,7 +204,7 @@ function RouteComponent() { If the cost of Serial is too much for you, anyone can run an instance of Serial for themselves. You won't need to pay us anything, but you will need to have a dedicated computer to run it on, which can be - as cheap as $3-4 a month. + as cheap as $5-6 a month.

This can be a great option for users who are very privacy-conscious, @@ -160,7 +214,7 @@ function RouteComponent() {

Here is the step-by-step guide {" "} @@ -177,12 +231,13 @@ function RouteComponent() { -

diff --git a/src/components/LeftSidebarBottomNav.tsx b/src/components/LeftSidebarBottomNav.tsx index d19d9066..a07c6706 100644 --- a/src/components/LeftSidebarBottomNav.tsx +++ b/src/components/LeftSidebarBottomNav.tsx @@ -47,7 +47,7 @@ export function LeftSidebarBottomNav() { Report Issue @@ -59,7 +59,7 @@ export function LeftSidebarBottomNav() { Share Idea diff --git a/src/components/feed/UserProfileEditDialog/DeleteAccountSection.tsx b/src/components/feed/UserProfileEditDialog/DeleteAccountSection.tsx index 88e29949..1dab7df9 100644 --- a/src/components/feed/UserProfileEditDialog/DeleteAccountSection.tsx +++ b/src/components/feed/UserProfileEditDialog/DeleteAccountSection.tsx @@ -1,9 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; import { useRouter } from "@tanstack/react-router"; +import { AlertTriangleIcon } from "lucide-react"; import { useState } from "react"; import { z } from "zod"; +import { useDialogStore } from "../dialogStore"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; +import { Skeleton } from "~/components/ui/skeleton"; import { useDeleteAccountMutation } from "~/lib/data/user/useDeleteAccountMutation"; +import { orpc } from "~/lib/orpc"; function DeleteAccountInitialSection({ onClickDelete, @@ -79,6 +85,44 @@ function DeleteAccountConfirmationSection({ export function DeleteAccountSection() { const [isConfirmation, setIsConfirmation] = useState(false); + const { launchDialog } = useDialogStore(); + + const { data: subscriptionSummary, isFetched: hasFetchedSummary } = useQuery({ + ...orpc.subscription.getSubscriptionSummary.queryOptions(), + }); + + const { data: pendingSwitch, isFetched: hasFetchedPendingSwitch } = useQuery({ + ...orpc.subscription.getPendingSwitch.queryOptions(), + }); + + const hasActivePlan = + !!subscriptionSummary?.planId && pendingSwitch?.planId !== "free"; + + if (!hasFetchedSummary || !hasFetchedPendingSwitch) { + return ; + } + + if (hasActivePlan) { + return ( + + + You have an active plan + + Please cancel your active plan before deleting your account. + + + + ); + } return (
diff --git a/src/components/feed/import/utils/getInitialFeedDataFromFileInputElement.ts b/src/components/feed/import/utils/getInitialFeedDataFromFileInputElement.ts index 572ef089..bb294a1f 100644 --- a/src/components/feed/import/utils/getInitialFeedDataFromFileInputElement.ts +++ b/src/components/feed/import/utils/getInitialFeedDataFromFileInputElement.ts @@ -1,8 +1,9 @@ import { getInitialFeedDataFromCSVInput } from "./getInitialFeedDataFromCSVInput"; import { getInitialFeedDataFromOPMLInput } from "./getInitialFeedDataFromOPMLInput"; -import { formError, formSuccess } from "./shared"; +import { formError, formErrors, formSuccess } from "./shared"; import type { ImportFeedDataFromFileResult, + ImportFeedDataFromFilesResult, ImportFeedDataItem, } from "./shared"; @@ -30,9 +31,9 @@ async function getInitialFeedDataFromFile( export async function getInitialFeedDataFromFileInputElement( inputElement: HTMLInputElement, -): Promise { +): Promise { if (!inputElement.files || inputElement.files.length === 0) { - return formError("Couldn't find a file."); + return formErrors(["Couldn't find a file."]); } const files = Array.from(inputElement.files); @@ -56,7 +57,7 @@ export async function getInitialFeedDataFromFileInputElement( } if (allFeeds.length === 0 && errors.length > 0) { - return formError(errors.join(" ")); + return formErrors(errors); } return formSuccess(allFeeds); diff --git a/src/components/feed/import/utils/shared.ts b/src/components/feed/import/utils/shared.ts index 56381eaa..5af9ce6b 100644 --- a/src/components/feed/import/utils/shared.ts +++ b/src/components/feed/import/utils/shared.ts @@ -21,6 +21,14 @@ export type ImportFeedDataFromFileResult = | ImportFeedDataFromFileError | ImportFeedDataFromFileSuccess; +export type ImportFeedDataFromFilesError = { + success: false; + errors: string[]; +}; +export type ImportFeedDataFromFilesResult = + | ImportFeedDataFromFilesError + | ImportFeedDataFromFileSuccess; + export function formError( error: ImportFeedDataFromFileError["error"], ): ImportFeedDataFromFileError { @@ -30,6 +38,15 @@ export function formError( }; } +export function formErrors( + errors: ImportFeedDataFromFilesError["errors"], +): ImportFeedDataFromFilesError { + return { + success: false, + errors, + }; +} + export function formSuccess( data: ImportFeedDataFromFileSuccess["data"], ): ImportFeedDataFromFileSuccess { diff --git a/src/components/feed/subscription-dialog/SubscriptionDialog.tsx b/src/components/feed/subscription-dialog/SubscriptionDialog.tsx index 0f5cbe91..bb8af87a 100644 --- a/src/components/feed/subscription-dialog/SubscriptionDialog.tsx +++ b/src/components/feed/subscription-dialog/SubscriptionDialog.tsx @@ -354,7 +354,7 @@ export function SubscriptionDialog({ {" "} or{" "}
)} - - {/* Free plan */} - - {/* Paid plans */} - - {/* Pro plan */}
)} diff --git a/src/components/welcome/GitHubButton.tsx b/src/components/welcome/GitHubButton.tsx index 0579484a..0a01a6a4 100644 --- a/src/components/welcome/GitHubButton.tsx +++ b/src/components/welcome/GitHubButton.tsx @@ -5,7 +5,7 @@ export function GitHubButton() { return (
+ + {props.isAuthed ? ( + + + + ) : ( + + + + )} +
+ + + ); +} diff --git a/src/content/blog/how-to-export-youtube-subscriptions.md b/src/content/blog/how-to-export-youtube-subscriptions.md index c476ab49..7dc9cad0 100644 --- a/src/content/blog/how-to-export-youtube-subscriptions.md +++ b/src/content/blog/how-to-export-youtube-subscriptions.md @@ -12,37 +12,37 @@ If you want to import your YouTube subscriptions into Serial, you'll first need Navigate to [Google Takeout](https://takeout.google.com). Once there, hit **"Deselect all"** to exclude unneeded app data. This ensures we only export what we need, keeping the process fast. -![Step 1: Deselect all in Google Takeout](/blog/images/youtube-subscription-import/step_1.png) +![Step 1: Deselect all in Google Takeout](/guides/images/youtube-subscription-import/step_1.png) ## Step 2: Select YouTube Data Scroll down and check **"YouTube and YouTube Music"**, then click on **"All YouTube data included"**. -![Step 2: Select YouTube and YouTube Music](/blog/images/youtube-subscription-import/step_2.png) +![Step 2: Select YouTube and YouTube Music](/guides/images/youtube-subscription-import/step_2.png) ## Step 3: Select Only Subscriptions In the popup, deselect all options, then check only **"subscriptions"** and hit **"OK"**. -![Step 3: Select only subscriptions](/blog/images/youtube-subscription-import/step_3.png) +![Step 3: Select only subscriptions](/guides/images/youtube-subscription-import/step_3.png) ## Step 4: Create the Export Hit **"Next step"**, then hit **"Create export"**. Google will start preparing your data. -![Step 4: Create the export](/blog/images/youtube-subscription-import/step_4.png) +![Step 4: Create the export](/guides/images/youtube-subscription-import/step_4.png) ## Step 5: Wait for the Email Soon, you will receive an email from Google with your subscription data. Since we deselected everything we didn't need, this should be nearly instant. -![Step 5: Wait for the email](/blog/images/youtube-subscription-import/step_5.png) +![Step 5: Wait for the email](/guides/images/youtube-subscription-import/step_5.png) ## Step 6: Find Your File Download and unzip the file from Google. You will find the `subscriptions.csv` file nested a few folders inside the archive. -![Step 6: Find subscriptions.csv](/blog/images/youtube-subscription-import/step_6.png) +![Step 6: Find subscriptions.csv](/guides/images/youtube-subscription-import/step_6.png) ## That's it! diff --git a/src/content/releases/2025-04-12.md b/src/content/releases/2025-04-12.md index 886de28e..6b64ecf3 100644 --- a/src/content/releases/2025-04-12.md +++ b/src/content/releases/2025-04-12.md @@ -7,7 +7,7 @@ public: true #### Features -- Serial is now open source! You can [check out the repository](https://github.com/hfellerhoff/serial) for more information. +- Serial is now open source! You can [check out the repository](https://github.com/megaflorasoftware/serial) for more information. - Removes previous auth provider [Clerk](https://clerk.com/) in exchange for auth solution [Better Auth](https://www.better-auth.com/) - If you run into issues with the migration, don't hesitate to reach out to the email at the bottom of the page! - Changed default video player to the serial player and added a number of nice-to-have shortcuts from YouTube, namely: diff --git a/src/content/releases/2025-05-17.md b/src/content/releases/2025-05-17.md index e6b4a1af..f172e003 100644 --- a/src/content/releases/2025-05-17.md +++ b/src/content/releases/2025-05-17.md @@ -24,16 +24,16 @@ This update introduces views, a new way to customize your feed. Views allow you - You can now delete categories in the sidebar - Improves the experience of working with feeds - Removes the dedicated feeds page - - You can now edit feeds in the sidebar [(#56)](https://github.com/hfellerhoff/serial/issues/56) + - You can now edit feeds in the sidebar [(#56)](https://github.com/megaflorasoftware/serial/issues/56) - Assigning categories now takes place here - - You can now delete feeds in the sidebar [(#56)](https://github.com/hfellerhoff/serial/issues/56) + - You can now delete feeds in the sidebar [(#56)](https://github.com/megaflorasoftware/serial/issues/56) - Feeds can now be searched through - Relvant feeds are now clearly denoted at the top of the list -- Adds PeerTube support [(#62)](https://github.com/hfellerhoff/serial/issues/62) +- Adds PeerTube support [(#62)](https://github.com/megaflorasoftware/serial/issues/62) #### Improvements -- Adds the ability to go to a video's YouTube page from the watch page [(#55)](https://github.com/hfellerhoff/serial/issues/55) +- Adds the ability to go to a video's YouTube page from the watch page [(#55)](https://github.com/megaflorasoftware/serial/issues/55) - Adds an initial data fetching loader element - Moves manual date and visibility filters into dropdowns (to make room for views) - Improves the onboarding experience a touch diff --git a/src/content/releases/2026-01-18.md b/src/content/releases/2026-01-18.md index 33082a78..74f4a0d0 100644 --- a/src/content/releases/2026-01-18.md +++ b/src/content/releases/2026-01-18.md @@ -11,12 +11,12 @@ public: true Serial has been "self hostable" for a while, but only through the libsql host [Turso](https://turso.tech/). Turso is a great service if you're looking to run Serial at scale, but most people are not. You now can run Serial entirely locally, while still leveraging some of the performance benefits of libsql. -Check out the [self hosting instructions](https://github.com/hfellerhoff/serial#self-hosting) to learn more! +Check out the [self hosting instructions](https://github.com/megaflorasoftware/serial#self-hosting) to learn more! In addition, there is now updated self-hosting documentation for a handful of platforms: -- [Vercel](https://github.com/hfellerhoff/serial/blob/main/docs/hosting/vercel.md) (only hostable through Turso) -- [Coolify](https://github.com/hfellerhoff/serial/blob/main/docs/hosting/coolify.md) (hostable through either). +- [Vercel](https://github.com/megaflorasoftware/serial/blob/main/docs/hosting/vercel.md) (only hostable through Turso) +- [Coolify](https://github.com/megaflorasoftware/serial/blob/main/docs/hosting/coolify.md) (hostable through either). **Added a dedicated feed management page** diff --git a/src/content/releases/2026-05-05.md b/src/content/releases/2026-05-05.md new file mode 100644 index 00000000..851ae696 --- /dev/null +++ b/src/content/releases/2026-05-05.md @@ -0,0 +1,62 @@ +--- +title: "A new chapter for Serial" +description: "Many performance improvements and a message about project funding." +publish_date: "2026-05-05" +public: true +--- + +## A note about subscriptions + +Starting today (May 5th, 2026), Serial will begin to offer paid plans on the primary instance. This is a decision made with the goal of of being able to sustainably create open-source software that is built to last, and will help to support both current costs and future development. + +The [new pricing](/pricing) was chosen to support all kinds of people, from those who just want a nice interface for a few feeds to users who want a single app for all their web content. The pricing is designed to give more price flexibility than many RSS readers on the market, and should be an especially good middle ground for those who are not "RSS power users" but still want the benefits of a well-supported and designed reader. + +This pricing only applies to the main instance; self-hosted instances will always be free to run. I'll be writing additional guides on data export and self-hosting throughout the month to make sure this is a real option for all kinds of people, not just those who are technical or self-hosting hobbyists. If you'd like to try this today, you can follow the existing step-by-step guide [here](https://github.com/megaflorasoftware/serial?tab=readme-ov-file#self-hosting). + +Even if you don't upgrade today, your current feeds will stay active through (at least) June 1st. If you have any questions at all, don't hestiate [to reach out](mailto:serial@megaflora.net). I know it's a tough time for many right now, and I never want ability to pay to be the reason someone can't use Serial. + +As a thank you for being here along the way, you can use the code `THANKYOUBETA` to get 50% off any plan (monthly or annual) for the first year. You'll be able to use this code up until extra active feeds are flipped off on June 1st. + +Here's to the next chapter of Serial! + +\- Henry + +--- + +## Improvements + +### Progress towards a more "local-first" experience + +- Serial now caches local data on desktop and PWA (progressive web app) installations. +- Initial loads should feel much snappier, especially when going to read or watch previously added items. + +### Feed data caching + +- Feeds are now cached in the user's KV store of choice up until that feed's `ttl`. In practicality, this means that popular and recently fetched feeds will be retreived faster when fetched again by someone else. + +### Background feed streaming + +- For users that have feeds that refresh in the background, Serial will stream new data in to the application automatically when the app is open. + - Tip: hover over the refresh button to get the time until your next feed data refresh! + +### Invite link improvements + +- Invite links can now be named +- Invite links can now be edited after creation + +## Fixes + +- Fixed an issue where .opml files sometimes would not be uploadable on mobile +- Fixed an issue where feed items posted at the same time would switch with each other rapidly on the page +- Updated database indexes to improve certain performance issues + +## Changes + +- Moved /blog to /guides +- Updated [landing page](/welcome) with updated screenshots and pricing info +- Added [pricing page](/pricing) + +## Updates for self-hosters + +- **BREAKING**: Serial now requires `VITE_PUBLIC_BASE_URL` to be set for each deployment. This is the url that is used for features like user invites and other essential functionality. +- Added a new `VITE_PUBLIC_IS_MAINTENANCE_MODE` environment variable, useful for shutting off application access while performing database maintenance or other tasks. diff --git a/src/env.js b/src/env.js index 87ce6a58..5090044e 100644 --- a/src/env.js +++ b/src/env.js @@ -8,6 +8,7 @@ export const env = createEnv({ * These are exposed to the browser via Vite's VITE_PUBLIC_ prefix. */ client: { + VITE_PUBLIC_BASE_URL: z.url(), VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: z.string().email().optional(), VITE_PUBLIC_SENTRY_DSN_WEB: z.string().url().optional(), VITE_PUBLIC_IS_MAINTENANCE_MODE: z.string().optional().default("false"), @@ -25,10 +26,6 @@ export const env = createEnv({ (str) => !(!!str && process.env.DATABASE_URL?.includes("https://")), "A DATABASE_AUTH_TOKEN is needed.", ), - BETTER_AUTH_BASE_URL: z - .string() - .optional() - .default("http://localhost:3000"), BETTER_AUTH_SECRET: z.string(), RESEND_API_KEY: z.string().optional(), SENDGRID_API_KEY: z.string().optional(), @@ -103,7 +100,8 @@ export const env = createEnv({ DATABASE_URL: process.env.DATABASE_URL, DATABASE_AUTH_TOKEN: process.env.DATABASE_AUTH_TOKEN, NODE_ENV: process.env.NODE_ENV, - BETTER_AUTH_BASE_URL: process.env.BETTER_AUTH_BASE_URL, + VITE_PUBLIC_BASE_URL: + import.meta.env?.VITE_PUBLIC_BASE_URL ?? process.env.VITE_PUBLIC_BASE_URL, BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, RESEND_API_KEY: process.env.RESEND_API_KEY, SENDGRID_API_KEY: process.env.SENDGRID_API_KEY, diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index ba2c65bd..b7cbcf04 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -5,6 +5,7 @@ import { genericOAuthClient, } from "better-auth/client/plugins"; import { polarClient } from "@polar-sh/better-auth/client"; +import { env } from "~/env"; const plugins = [ adminClient(), @@ -15,6 +16,7 @@ const plugins = [ export const authClient = createAuthClient({ plugins, + baseURL: env.VITE_PUBLIC_BASE_URL, }); export const { signIn, signOut, signUp, useSession, resetPassword } = diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 01537b78..51b4fb55 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -8,9 +8,9 @@ export const BASE_SIGNED_OUT_URL = IS_MAIN_INSTANCE ? "/welcome" : "/auth/sign-in"; -export function getBlogUrl(path = "") { - if (IS_MAIN_INSTANCE) return `/blog${path}`; - return `https://serial.tube/blog${path}`; +export function getGuidesUrl(path = "") { + if (IS_MAIN_INSTANCE) return `/guides${path}`; + return `https://serial.tube/guides${path}`; } /** diff --git a/src/lib/data/atoms.ts b/src/lib/data/atoms.ts index 710da7b6..ac5df70e 100644 --- a/src/lib/data/atoms.ts +++ b/src/lib/data/atoms.ts @@ -66,3 +66,6 @@ export const altKeyHeldAtom = atom(false); /** When true, the header and footer bars should be hidden (e.g. scrolling down in article view). */ export const barsHiddenAtom = atom(false); + +/** When true, the SSE connection should stay open even when the page is hidden/defocused. */ +export const shouldAlwaysKeepSSEConnectionAlive = atom(false); diff --git a/src/lib/data/useDataSubscription.ts b/src/lib/data/useDataSubscription.ts index 55b2e046..e064421b 100644 --- a/src/lib/data/useDataSubscription.ts +++ b/src/lib/data/useDataSubscription.ts @@ -1,10 +1,12 @@ "use client"; import { useCallback, useEffect, useRef } from "react"; +import { getDefaultStore } from "jotai"; import { orpcRouterClient } from "../orpc"; import { buildViewManifests } from "./buildViewManifests"; import { loadingActor } from "./loading-machine"; import { feedItemsStore } from "./store"; +import { shouldAlwaysKeepSSEConnectionAlive } from "./atoms"; import type { PublishedChunk } from "~/server/api/publisher"; import type { VisibilityFilter } from "./atoms"; import type { @@ -139,16 +141,21 @@ export function useDataSubscription() { // Disconnect on page hide, reconnect on refocus. Keeping the SSE // pipe open while the tab is hidden wastes server resources and the // connection often goes stale anyway. - const handleVisibilityChange = () => { + const updateConnectionState = () => { if (controller.signal.aborted) return; - if (document.visibilityState === "hidden") { + const shouldStayAlive = getDefaultStore().get( + shouldAlwaysKeepSSEConnectionAlive, + ); + const wasPaused = paused; + + if (document.visibilityState === "hidden" && !shouldStayAlive) { paused = true; connectionController?.abort(); - } else if (document.visibilityState === "visible") { - // Reset the machine so stale in-flight state doesn't confuse - // the freshly requested data stream. - loadingActor.send({ type: "RESET" }); + } else if ( + document.visibilityState === "visible" || + (document.visibilityState === "hidden" && shouldStayAlive) + ) { paused = false; visibilityReconnect = true; // If the loop is waiting on the paused promise, the @@ -156,13 +163,33 @@ export function useDataSubscription() { // If it's in a backoff sleep, the next iteration will // see paused=false and proceed normally. } + + // Only reset the loading machine when transitioning from paused + // to unpaused (i.e. the SSE is actually resuming after being + // disconnected due to visibility rules). + if (wasPaused && !paused) { + loadingActor.send({ type: "RESET" }); + } + }; + + const handleVisibilityChange = () => { + updateConnectionState(); }; document.addEventListener("visibilitychange", handleVisibilityChange); + // Recompute connection logic when the keep-alive atom changes + const unsubscribeAtom = getDefaultStore().sub( + shouldAlwaysKeepSSEConnectionAlive, + () => { + updateConnectionState(); + }, + ); + subscribe(); return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); + unsubscribeAtom(); controller.abort(); isConnectedRef.current = false; // Cancel any pending RAF flush diff --git a/src/lib/markdown/loaders.ts b/src/lib/markdown/loaders.ts index aa91f3d7..ced89490 100644 --- a/src/lib/markdown/loaders.ts +++ b/src/lib/markdown/loaders.ts @@ -3,46 +3,46 @@ import { allBlogPosts, allReleases } from "content-collections"; import type { BlogPost, Release } from "content-collections"; const releases = allReleases; -const blogPosts = allBlogPosts; +const guidePosts = allBlogPosts; -function sortReleases(a: Release, b: Release) { +function sortGuidePosts(a: BlogPost, b: BlogPost) { if (a.publish_date < b.publish_date) return 1; return -1; } -export function getMostRecentRelease() { - return releases.filter((release) => release.public).sort(sortReleases)[0]; +function sortReleases(a: Release, b: Release) { + if (a.publish_date < b.publish_date) return 1; + return -1; } -export function getReleaseWithSlug(slug: string) { - const release = releases.filter((r) => r.public).find((p) => p.slug === slug); +export function getGuidePostWithSlug(slug: string) { + const post = guidePosts.filter((p) => p.public).find((p) => p.slug === slug); - if (!release) { + if (!post) { throw notFound(); } - return release; + return post; } -export function getAllReleases() { - return releases.filter((release) => release.public).sort(sortReleases); +export function getAllGuidePosts() { + return guidePosts.filter((post) => post.public).sort(sortGuidePosts); } -function sortBlogPosts(a: BlogPost, b: BlogPost) { - if (a.publish_date < b.publish_date) return 1; - return -1; +export function getMostRecentRelease() { + return releases.filter((release) => release.public).sort(sortReleases)[0]; } -export function getBlogPostWithSlug(slug: string) { - const post = blogPosts.filter((p) => p.public).find((p) => p.slug === slug); +export function getReleaseWithSlug(slug: string) { + const release = releases.filter((r) => r.public).find((p) => p.slug === slug); - if (!post) { + if (!release) { throw notFound(); } - return post; + return release; } -export function getAllBlogPosts() { - return blogPosts.filter((post) => post.public).sort(sortBlogPosts); +export function getAllReleases() { + return releases.filter((release) => release.public).sort(sortReleases); } diff --git a/src/lib/orpc.ts b/src/lib/orpc.ts index 83cb0aca..c7f6b786 100644 --- a/src/lib/orpc.ts +++ b/src/lib/orpc.ts @@ -3,9 +3,10 @@ import { RPCLink } from "@orpc/client/fetch"; import { createTanstackQueryUtils } from "@orpc/tanstack-query"; import type { RouterClient } from "@orpc/server"; import type { orpcRouter } from "~/server/orpc/router"; +import { env } from "~/env"; const link = new RPCLink({ - url: `${typeof window !== "undefined" ? window.location.origin : (process.env.BETTER_AUTH_BASE_URL ?? "http://localhost:3000")}/api/rpc`, + url: `${typeof window !== "undefined" ? window.location.origin : env.VITE_PUBLIC_BASE_URL}/api/rpc`, }); export const orpcRouterClient: RouterClient = diff --git a/src/lib/sortFeedItems.ts b/src/lib/sortFeedItems.ts index d0339395..40f824ee 100644 --- a/src/lib/sortFeedItems.ts +++ b/src/lib/sortFeedItems.ts @@ -9,7 +9,23 @@ export function sortFeedItemsOrderByDate( if (!itemA || !itemB) return 0; - if (itemA.postedAt < itemB.postedAt) return 1; - return -1; + const timeA = + itemA.postedAt instanceof Date + ? itemA.postedAt.getTime() + : new Date(itemA.postedAt).getTime(); + const timeB = + itemB.postedAt instanceof Date + ? itemB.postedAt.getTime() + : new Date(itemB.postedAt).getTime(); + + if (timeB !== timeA) { + return timeB - timeA; + } + + if (itemA.title !== itemB.title) { + return itemA.title.localeCompare(itemB.title); + } + + return itemA.id.localeCompare(itemB.id); }; } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 685d1cd9..5b5c4f9e 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,29 +11,30 @@ import { Route as rootRouteImport } from './app/__root' import { Route as WelcomeRouteImport } from './app/welcome' import { Route as SitemapRouteImport } from './app/sitemap' +import { Route as PricingRouteImport } from './app/pricing' import { Route as MaintenanceRouteImport } from './app/maintenance' -import { Route as BlogRouteImport } from './app/blog' import { Route as AuthRouteImport } from './app/auth' import { Route as WebRouteImport } from './app/_web' import { Route as AppRouteImport } from './app/_app' -import { Route as BlogIndexRouteImport } from './app/blog.index' import { Route as AppIndexRouteImport } from './app/_app.index' -import { Route as BlogSlugRouteImport } from './app/blog.$slug' import { Route as AuthVerifyEmailRouteImport } from './app/auth.verify-email' import { Route as AuthSignUpRouteImport } from './app/auth.sign-up' import { Route as AuthSignInRouteImport } from './app/auth.sign-in' import { Route as AuthResetRouteImport } from './app/auth.reset' import { Route as ApiHealthRouteImport } from './app/api/health' +import { Route as WebGuidesRouteImport } from './app/_web.guides' import { Route as AppViewsRouteImport } from './app/_app.views' import { Route as AppTagsRouteImport } from './app/_app.tags' import { Route as AppImportRouteImport } from './app/_app.import' import { Route as AppFeedsRouteImport } from './app/_app.feeds' import { Route as AppDebugRouteImport } from './app/_app.debug' import { Route as WebReleasesIndexRouteImport } from './app/_web.releases.index' +import { Route as WebGuidesIndexRouteImport } from './app/_web.guides.index' import { Route as AppAdminIndexRouteImport } from './app/_app.admin.index' import { Route as ApiRpcSplatRouteImport } from './app/api/rpc.$' import { Route as ApiAuthSplatRouteImport } from './app/api/auth.$' import { Route as WebReleasesSlugRouteImport } from './app/_web.releases.$slug' +import { Route as WebGuidesSlugRouteImport } from './app/_web.guides.$slug' import { Route as AppWatchIdRouteImport } from './app/_app.watch.$id' import { Route as AppReadIdRouteImport } from './app/_app.read.$id' import { Route as AppAdminUsersRouteImport } from './app/_app.admin.users' @@ -53,16 +54,16 @@ const SitemapRoute = SitemapRouteImport.update({ path: '/sitemap', getParentRoute: () => rootRouteImport, } as any) +const PricingRoute = PricingRouteImport.update({ + id: '/pricing', + path: '/pricing', + getParentRoute: () => rootRouteImport, +} as any) const MaintenanceRoute = MaintenanceRouteImport.update({ id: '/maintenance', path: '/maintenance', getParentRoute: () => rootRouteImport, } as any) -const BlogRoute = BlogRouteImport.update({ - id: '/blog', - path: '/blog', - getParentRoute: () => rootRouteImport, -} as any) const AuthRoute = AuthRouteImport.update({ id: '/auth', path: '/auth', @@ -76,21 +77,11 @@ const AppRoute = AppRouteImport.update({ id: '/_app', getParentRoute: () => rootRouteImport, } as any) -const BlogIndexRoute = BlogIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => BlogRoute, -} as any) const AppIndexRoute = AppIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => AppRoute, } as any) -const BlogSlugRoute = BlogSlugRouteImport.update({ - id: '/$slug', - path: '/$slug', - getParentRoute: () => BlogRoute, -} as any) const AuthVerifyEmailRoute = AuthVerifyEmailRouteImport.update({ id: '/verify-email', path: '/verify-email', @@ -116,6 +107,11 @@ const ApiHealthRoute = ApiHealthRouteImport.update({ path: '/api/health', getParentRoute: () => rootRouteImport, } as any) +const WebGuidesRoute = WebGuidesRouteImport.update({ + id: '/guides', + path: '/guides', + getParentRoute: () => WebRoute, +} as any) const AppViewsRoute = AppViewsRouteImport.update({ id: '/views', path: '/views', @@ -146,6 +142,11 @@ const WebReleasesIndexRoute = WebReleasesIndexRouteImport.update({ path: '/releases/', getParentRoute: () => WebRoute, } as any) +const WebGuidesIndexRoute = WebGuidesIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => WebGuidesRoute, +} as any) const AppAdminIndexRoute = AppAdminIndexRouteImport.update({ id: '/admin/', path: '/admin/', @@ -166,6 +167,11 @@ const WebReleasesSlugRoute = WebReleasesSlugRouteImport.update({ path: '/releases/$slug', getParentRoute: () => WebRoute, } as any) +const WebGuidesSlugRoute = WebGuidesSlugRouteImport.update({ + id: '/$slug', + path: '/$slug', + getParentRoute: () => WebGuidesRoute, +} as any) const AppWatchIdRoute = AppWatchIdRouteImport.update({ id: '/watch/$id', path: '/watch/$id', @@ -210,8 +216,8 @@ const AppAdminUserIdRoute = AppAdminUserIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof AppIndexRoute '/auth': typeof AuthRouteWithChildren - '/blog': typeof BlogRouteWithChildren '/maintenance': typeof MaintenanceRoute + '/pricing': typeof PricingRoute '/sitemap': typeof SitemapRoute '/welcome': typeof WelcomeRoute '/debug': typeof AppDebugRoute @@ -219,13 +225,12 @@ export interface FileRoutesByFullPath { '/import': typeof AppImportRoute '/tags': typeof AppTagsRoute '/views': typeof AppViewsRoute + '/guides': typeof WebGuidesRouteWithChildren '/api/health': typeof ApiHealthRoute '/auth/reset': typeof AuthResetRoute '/auth/sign-in': typeof AuthSignInRoute '/auth/sign-up': typeof AuthSignUpRoute '/auth/verify-email': typeof AuthVerifyEmailRoute - '/blog/$slug': typeof BlogSlugRoute - '/blog/': typeof BlogIndexRoute '/admin/info': typeof AppAdminInfoRoute '/admin/invites': typeof AppAdminInvitesRoute '/admin/settings': typeof AppAdminSettingsRoute @@ -233,10 +238,12 @@ export interface FileRoutesByFullPath { '/admin/users': typeof AppAdminUsersRoute '/read/$id': typeof AppReadIdRoute '/watch/$id': typeof AppWatchIdRoute + '/guides/$slug': typeof WebGuidesSlugRoute '/releases/$slug': typeof WebReleasesSlugRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute '/admin/': typeof AppAdminIndexRoute + '/guides/': typeof WebGuidesIndexRoute '/releases/': typeof WebReleasesIndexRoute '/admin/user/$id': typeof AppAdminUserIdRoute } @@ -244,6 +251,7 @@ export interface FileRoutesByTo { '/': typeof AppIndexRoute '/auth': typeof AuthRouteWithChildren '/maintenance': typeof MaintenanceRoute + '/pricing': typeof PricingRoute '/sitemap': typeof SitemapRoute '/welcome': typeof WelcomeRoute '/debug': typeof AppDebugRoute @@ -256,8 +264,6 @@ export interface FileRoutesByTo { '/auth/sign-in': typeof AuthSignInRoute '/auth/sign-up': typeof AuthSignUpRoute '/auth/verify-email': typeof AuthVerifyEmailRoute - '/blog/$slug': typeof BlogSlugRoute - '/blog': typeof BlogIndexRoute '/admin/info': typeof AppAdminInfoRoute '/admin/invites': typeof AppAdminInvitesRoute '/admin/settings': typeof AppAdminSettingsRoute @@ -265,10 +271,12 @@ export interface FileRoutesByTo { '/admin/users': typeof AppAdminUsersRoute '/read/$id': typeof AppReadIdRoute '/watch/$id': typeof AppWatchIdRoute + '/guides/$slug': typeof WebGuidesSlugRoute '/releases/$slug': typeof WebReleasesSlugRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute '/admin': typeof AppAdminIndexRoute + '/guides': typeof WebGuidesIndexRoute '/releases': typeof WebReleasesIndexRoute '/admin/user/$id': typeof AppAdminUserIdRoute } @@ -277,8 +285,8 @@ export interface FileRoutesById { '/_app': typeof AppRouteWithChildren '/_web': typeof WebRouteWithChildren '/auth': typeof AuthRouteWithChildren - '/blog': typeof BlogRouteWithChildren '/maintenance': typeof MaintenanceRoute + '/pricing': typeof PricingRoute '/sitemap': typeof SitemapRoute '/welcome': typeof WelcomeRoute '/_app/debug': typeof AppDebugRoute @@ -286,14 +294,13 @@ export interface FileRoutesById { '/_app/import': typeof AppImportRoute '/_app/tags': typeof AppTagsRoute '/_app/views': typeof AppViewsRoute + '/_web/guides': typeof WebGuidesRouteWithChildren '/api/health': typeof ApiHealthRoute '/auth/reset': typeof AuthResetRoute '/auth/sign-in': typeof AuthSignInRoute '/auth/sign-up': typeof AuthSignUpRoute '/auth/verify-email': typeof AuthVerifyEmailRoute - '/blog/$slug': typeof BlogSlugRoute '/_app/': typeof AppIndexRoute - '/blog/': typeof BlogIndexRoute '/_app/admin/info': typeof AppAdminInfoRoute '/_app/admin/invites': typeof AppAdminInvitesRoute '/_app/admin/settings': typeof AppAdminSettingsRoute @@ -301,10 +308,12 @@ export interface FileRoutesById { '/_app/admin/users': typeof AppAdminUsersRoute '/_app/read/$id': typeof AppReadIdRoute '/_app/watch/$id': typeof AppWatchIdRoute + '/_web/guides/$slug': typeof WebGuidesSlugRoute '/_web/releases/$slug': typeof WebReleasesSlugRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute '/_app/admin/': typeof AppAdminIndexRoute + '/_web/guides/': typeof WebGuidesIndexRoute '/_web/releases/': typeof WebReleasesIndexRoute '/_app/admin/user/$id': typeof AppAdminUserIdRoute } @@ -313,8 +322,8 @@ export interface FileRouteTypes { fullPaths: | '/' | '/auth' - | '/blog' | '/maintenance' + | '/pricing' | '/sitemap' | '/welcome' | '/debug' @@ -322,13 +331,12 @@ export interface FileRouteTypes { | '/import' | '/tags' | '/views' + | '/guides' | '/api/health' | '/auth/reset' | '/auth/sign-in' | '/auth/sign-up' | '/auth/verify-email' - | '/blog/$slug' - | '/blog/' | '/admin/info' | '/admin/invites' | '/admin/settings' @@ -336,10 +344,12 @@ export interface FileRouteTypes { | '/admin/users' | '/read/$id' | '/watch/$id' + | '/guides/$slug' | '/releases/$slug' | '/api/auth/$' | '/api/rpc/$' | '/admin/' + | '/guides/' | '/releases/' | '/admin/user/$id' fileRoutesByTo: FileRoutesByTo @@ -347,6 +357,7 @@ export interface FileRouteTypes { | '/' | '/auth' | '/maintenance' + | '/pricing' | '/sitemap' | '/welcome' | '/debug' @@ -359,8 +370,6 @@ export interface FileRouteTypes { | '/auth/sign-in' | '/auth/sign-up' | '/auth/verify-email' - | '/blog/$slug' - | '/blog' | '/admin/info' | '/admin/invites' | '/admin/settings' @@ -368,10 +377,12 @@ export interface FileRouteTypes { | '/admin/users' | '/read/$id' | '/watch/$id' + | '/guides/$slug' | '/releases/$slug' | '/api/auth/$' | '/api/rpc/$' | '/admin' + | '/guides' | '/releases' | '/admin/user/$id' id: @@ -379,8 +390,8 @@ export interface FileRouteTypes { | '/_app' | '/_web' | '/auth' - | '/blog' | '/maintenance' + | '/pricing' | '/sitemap' | '/welcome' | '/_app/debug' @@ -388,14 +399,13 @@ export interface FileRouteTypes { | '/_app/import' | '/_app/tags' | '/_app/views' + | '/_web/guides' | '/api/health' | '/auth/reset' | '/auth/sign-in' | '/auth/sign-up' | '/auth/verify-email' - | '/blog/$slug' | '/_app/' - | '/blog/' | '/_app/admin/info' | '/_app/admin/invites' | '/_app/admin/settings' @@ -403,10 +413,12 @@ export interface FileRouteTypes { | '/_app/admin/users' | '/_app/read/$id' | '/_app/watch/$id' + | '/_web/guides/$slug' | '/_web/releases/$slug' | '/api/auth/$' | '/api/rpc/$' | '/_app/admin/' + | '/_web/guides/' | '/_web/releases/' | '/_app/admin/user/$id' fileRoutesById: FileRoutesById @@ -415,8 +427,8 @@ export interface RootRouteChildren { AppRoute: typeof AppRouteWithChildren WebRoute: typeof WebRouteWithChildren AuthRoute: typeof AuthRouteWithChildren - BlogRoute: typeof BlogRouteWithChildren MaintenanceRoute: typeof MaintenanceRoute + PricingRoute: typeof PricingRoute SitemapRoute: typeof SitemapRoute WelcomeRoute: typeof WelcomeRoute ApiHealthRoute: typeof ApiHealthRoute @@ -440,6 +452,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SitemapRouteImport parentRoute: typeof rootRouteImport } + '/pricing': { + id: '/pricing' + path: '/pricing' + fullPath: '/pricing' + preLoaderRoute: typeof PricingRouteImport + parentRoute: typeof rootRouteImport + } '/maintenance': { id: '/maintenance' path: '/maintenance' @@ -447,13 +466,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MaintenanceRouteImport parentRoute: typeof rootRouteImport } - '/blog': { - id: '/blog' - path: '/blog' - fullPath: '/blog' - preLoaderRoute: typeof BlogRouteImport - parentRoute: typeof rootRouteImport - } '/auth': { id: '/auth' path: '/auth' @@ -475,13 +487,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppRouteImport parentRoute: typeof rootRouteImport } - '/blog/': { - id: '/blog/' - path: '/' - fullPath: '/blog/' - preLoaderRoute: typeof BlogIndexRouteImport - parentRoute: typeof BlogRoute - } '/_app/': { id: '/_app/' path: '/' @@ -489,13 +494,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppIndexRouteImport parentRoute: typeof AppRoute } - '/blog/$slug': { - id: '/blog/$slug' - path: '/$slug' - fullPath: '/blog/$slug' - preLoaderRoute: typeof BlogSlugRouteImport - parentRoute: typeof BlogRoute - } '/auth/verify-email': { id: '/auth/verify-email' path: '/verify-email' @@ -531,6 +529,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiHealthRouteImport parentRoute: typeof rootRouteImport } + '/_web/guides': { + id: '/_web/guides' + path: '/guides' + fullPath: '/guides' + preLoaderRoute: typeof WebGuidesRouteImport + parentRoute: typeof WebRoute + } '/_app/views': { id: '/_app/views' path: '/views' @@ -573,6 +578,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WebReleasesIndexRouteImport parentRoute: typeof WebRoute } + '/_web/guides/': { + id: '/_web/guides/' + path: '/' + fullPath: '/guides/' + preLoaderRoute: typeof WebGuidesIndexRouteImport + parentRoute: typeof WebGuidesRoute + } '/_app/admin/': { id: '/_app/admin/' path: '/admin' @@ -601,6 +613,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WebReleasesSlugRouteImport parentRoute: typeof WebRoute } + '/_web/guides/$slug': { + id: '/_web/guides/$slug' + path: '/$slug' + fullPath: '/guides/$slug' + preLoaderRoute: typeof WebGuidesSlugRouteImport + parentRoute: typeof WebGuidesRoute + } '/_app/watch/$id': { id: '/_app/watch/$id' path: '/watch/$id' @@ -698,12 +717,28 @@ const AppRouteChildren: AppRouteChildren = { const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) +interface WebGuidesRouteChildren { + WebGuidesSlugRoute: typeof WebGuidesSlugRoute + WebGuidesIndexRoute: typeof WebGuidesIndexRoute +} + +const WebGuidesRouteChildren: WebGuidesRouteChildren = { + WebGuidesSlugRoute: WebGuidesSlugRoute, + WebGuidesIndexRoute: WebGuidesIndexRoute, +} + +const WebGuidesRouteWithChildren = WebGuidesRoute._addFileChildren( + WebGuidesRouteChildren, +) + interface WebRouteChildren { + WebGuidesRoute: typeof WebGuidesRouteWithChildren WebReleasesSlugRoute: typeof WebReleasesSlugRoute WebReleasesIndexRoute: typeof WebReleasesIndexRoute } const WebRouteChildren: WebRouteChildren = { + WebGuidesRoute: WebGuidesRouteWithChildren, WebReleasesSlugRoute: WebReleasesSlugRoute, WebReleasesIndexRoute: WebReleasesIndexRoute, } @@ -726,24 +761,12 @@ const AuthRouteChildren: AuthRouteChildren = { const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) -interface BlogRouteChildren { - BlogSlugRoute: typeof BlogSlugRoute - BlogIndexRoute: typeof BlogIndexRoute -} - -const BlogRouteChildren: BlogRouteChildren = { - BlogSlugRoute: BlogSlugRoute, - BlogIndexRoute: BlogIndexRoute, -} - -const BlogRouteWithChildren = BlogRoute._addFileChildren(BlogRouteChildren) - const rootRouteChildren: RootRouteChildren = { AppRoute: AppRouteWithChildren, WebRoute: WebRouteWithChildren, AuthRoute: AuthRouteWithChildren, - BlogRoute: BlogRouteWithChildren, MaintenanceRoute: MaintenanceRoute, + PricingRoute: PricingRoute, SitemapRoute: SitemapRoute, WelcomeRoute: WelcomeRoute, ApiHealthRoute: ApiHealthRoute, diff --git a/src/server/api/routers/admin/invitations.ts b/src/server/api/routers/admin/invitations.ts index 5c8b9337..64103f77 100644 --- a/src/server/api/routers/admin/invitations.ts +++ b/src/server/api/routers/admin/invitations.ts @@ -1,6 +1,5 @@ import { randomBytes } from "node:crypto"; import { ORPCError } from "@orpc/server"; -import { getRequest } from "@tanstack/react-start/server"; import { createElement } from "react"; import { render } from "@react-email/components"; import { count, desc, eq } from "drizzle-orm"; @@ -14,27 +13,8 @@ import { invitation, invitationRedemption, user } from "~/server/db/schema"; import { db } from "~/server/db"; import { IS_EMAIL_ENABLED, sendEmail } from "~/server/email"; -function getInviteUrl(token: string, origin?: string) { - const baseUrl = origin || env.BETTER_AUTH_BASE_URL; - return `${baseUrl}/auth/sign-up?token=${token}`; -} - -function getOriginFromRequest(): string | undefined { - try { - const request = getRequest(); - const host = request.headers.get("host"); - if (host) { - const isLocalhost = - host.startsWith("localhost") || host.startsWith("127.0.0.1"); - const fallbackProtocol = isLocalhost ? "http" : "https"; - const protocol = - request.headers.get("x-forwarded-proto") ?? fallbackProtocol; - return `${protocol}://${host}`; - } - return new URL(request.url).origin; - } catch { - return undefined; - } +function getInviteUrl(token: string) { + return `${env.VITE_PUBLIC_BASE_URL}/auth/sign-up?token=${token}`; } // Create an invitation link @@ -48,7 +28,6 @@ export const createInvitation = adminProcedure ) .handler(async ({ context, input }) => { const token = randomBytes(32).toString("base64url"); - const origin = getOriginFromRequest(); const [created] = await db .insert(invitation) @@ -68,14 +47,13 @@ export const createInvitation = adminProcedure }); } - const inviteUrl = getInviteUrl(created.token, origin); + const inviteUrl = getInviteUrl(created.token); return { id: created.id, inviteUrl }; }); // List all invitations export const listInvitations = adminProcedure.handler(async () => { - const origin = getOriginFromRequest(); const rows = await db .select({ id: invitation.id, @@ -100,7 +78,7 @@ export const listInvitations = adminProcedure.handler(async () => { const invitations = rows.map(({ token, ...rest }) => ({ ...rest, - inviteUrl: getInviteUrl(token, origin), + inviteUrl: getInviteUrl(token), })); return { invitations }; @@ -148,8 +126,7 @@ export const sendInvitationEmail = adminProcedure }); } - const origin = getOriginFromRequest(); - const inviteUrl = getInviteUrl(inv.token, origin); + const inviteUrl = getInviteUrl(inv.token); const html = await render( createElement(InviteUserEmail, { diff --git a/src/server/api/routers/subscriptionRouter.ts b/src/server/api/routers/subscriptionRouter.ts index 98100782..127aeb2a 100644 --- a/src/server/api/routers/subscriptionRouter.ts +++ b/src/server/api/routers/subscriptionRouter.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { eq } from "drizzle-orm"; -import type { PaidPlanId } from "~/server/subscriptions/plans"; -import { protectedProcedure } from "~/server/orpc/base"; + +import { protectedProcedure, publicProcedure } from "~/server/orpc/base"; import { getUserPlanId, getUserPlanLimits, @@ -20,18 +20,18 @@ import { redis, syncPolarDataToKV, } from "~/server/subscriptions/kv"; +import { fetchProducts } from "~/server/subscriptions/products"; import { user } from "~/server/db/schema"; import { IS_EMAIL_ENABLED } from "~/server/email"; import { captureException } from "~/server/logger"; - -const BASE_URL = process.env.BETTER_AUTH_BASE_URL ?? "http://localhost:3000"; +import { env } from "~/env"; function getValidatedOrigin(headers: Headers): string { const origin = headers.get("origin") ?? headers.get("referer"); if (origin) { try { const parsed = new URL(origin); - const base = new URL(BASE_URL); + const base = new URL(env.VITE_PUBLIC_BASE_URL); if (parsed.origin === base.origin) { return base.origin; } @@ -39,26 +39,9 @@ function getValidatedOrigin(headers: Headers): string { // invalid URL, fall through } } - return BASE_URL; + return env.VITE_PUBLIC_BASE_URL; } -type CachedProducts = { - data: PlanProduct[]; - expiresAt: number; -}; - -type PlanProduct = { - planId: PaidPlanId; - planName: string; - monthlyPrice: number | null; - annualPrice: number | null; - monthlyProductId: string | null; - annualProductId: string | null; -}; - -let productsCache: CachedProducts | null = null; -const CACHE_TTL_MS = 3 * 60 * 1000; // 3 minutes - /** Cooldown window for syncAfterCheckout per user (seconds). */ const SYNC_COOLDOWN_SECONDS = 30; @@ -83,90 +66,11 @@ export const refreshStatus = protectedProcedure.handler(async ({ context }) => { }); export const getProducts = protectedProcedure.handler(async () => { - if (!IS_BILLING_ENABLED || !polarClient) { - return []; - } - - // Check cache - if (productsCache && Date.now() < productsCache.expiresAt) { - return productsCache.data; - } - - const productIds = PAID_PLAN_IDS.flatMap((planId) => { - const ids = getPolarProductIds(planId); - return [ids.monthly, ids.annual]; - }).filter(Boolean); - - if (productIds.length === 0) { - return []; - } - - try { - const results: PlanProduct[] = []; - - for (const planId of PAID_PLAN_IDS) { - const plan = PLANS[planId]; - const planProductIds = getPolarProductIds(planId); - - // Skip plans that have no Polar product IDs configured - if (!planProductIds.monthly && !planProductIds.annual) continue; - - let monthlyPrice: number | null = null; - let annualPrice: number | null = null; - - if (planProductIds.monthly) { - try { - const product = await polarClient.products.get({ - id: planProductIds.monthly, - }); - const price = product.prices?.[0]; - if (price && "amountType" in price && price.amountType === "fixed") { - monthlyPrice = (price as { priceAmount: number }).priceAmount; - } - } catch (e) { - captureException(e); - } - } - - if (planProductIds.annual) { - try { - const product = await polarClient.products.get({ - id: planProductIds.annual, - }); - const price = product.prices?.[0]; - if (price && "amountType" in price && price.amountType === "fixed") { - annualPrice = (price as { priceAmount: number }).priceAmount; - } - } catch (e) { - captureException(e); - console.error( - `[subscription] Failed to fetch annual product for ${planId}:`, - e, - ); - } - } - - results.push({ - planId, - planName: plan.name, - monthlyPrice, - annualPrice, - monthlyProductId: planProductIds.monthly, - annualProductId: planProductIds.annual, - }); - } - - productsCache = { - data: results, - expiresAt: Date.now() + CACHE_TTL_MS, - }; + return fetchProducts(); +}); - return results; - } catch (e) { - captureException(e); - console.error("[subscription] Failed to fetch products:", e); - return []; - } +export const getPublicProducts = publicProcedure.handler(async () => { + return fetchProducts(); }); export const createCheckout = protectedProcedure diff --git a/src/server/api/routers/userRouter.ts b/src/server/api/routers/userRouter.ts index 3c0a0c5c..e30f9abf 100644 --- a/src/server/api/routers/userRouter.ts +++ b/src/server/api/routers/userRouter.ts @@ -1,10 +1,13 @@ import { and, eq, like } from "drizzle-orm"; +import { ORPCError } from "@orpc/server"; import { z } from "zod"; import { userNameSchema } from "../schemas"; import { protectedProcedure, publicProcedure } from "~/server/orpc/base"; import { auth } from "~/server/auth"; import { getOtpCooldownRemaining, OTP_COOLDOWN_SECONDS } from "~/server/otp"; +import { getUserPlanId } from "~/server/subscriptions/helpers"; +import { IS_BILLING_ENABLED } from "~/server/subscriptions/polar"; import { user } from "~/server/db/schema"; export const checkIsLegacyUser = publicProcedure @@ -42,6 +45,16 @@ export const updateName = protectedProcedure }); export const deleteUser = protectedProcedure.handler(async ({ context }) => { + if (IS_BILLING_ENABLED) { + const planId = await getUserPlanId(context.user.id); + if (planId !== "free") { + throw new ORPCError("PRECONDITION_FAILED", { + message: + "Please cancel your active subscription before deleting your account.", + }); + } + } + await context.db.delete(user).where(eq(user.id, context.user.id)); }); diff --git a/src/server/rss/feedCache.ts b/src/server/rss/feedCache.ts new file mode 100644 index 00000000..91801f15 --- /dev/null +++ b/src/server/rss/feedCache.ts @@ -0,0 +1,176 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; +import type { FeedFetchMetadata, RSSFeedWithMetadata } from "./types"; +import { getKV } from "~/server/kv"; + +const ONE_HOUR_MS = 60 * 60 * 1000; +const MAX_INTERVAL_MS = 24 * ONE_HOUR_MS; +const DEFAULT_INTERVAL_MS = ONE_HOUR_MS; +const ERROR_BACKOFF_MS = 60 * 60 * 1000; + +const feedFetchMetadataSchema = z.object({ + etag: z.string().optional(), + lastModified: z.string().optional(), + cacheControlMaxAge: z.number().optional(), + expires: z.coerce.date().optional(), + ttl: z.number().optional(), + updatePeriod: z + .enum(["hourly", "daily", "weekly", "monthly", "yearly"]) + .optional(), + updateFrequency: z.number().optional(), +}); + +const rssContentSchema = z.object({ + id: z.string(), + title: z.string(), + subtitle: z.string().optional(), + publishedDate: z.string(), + author: z.string(), + url: z.string(), + thumbnail: z.string().optional(), + content: z.string().optional(), + contentSnippet: z.string().optional(), + source: z + .object({ + title: z.string().optional(), + description: z.string().optional(), + link: z.string().optional(), + feedUrl: z.string().optional(), + image: z + .object({ + link: z.string().optional(), + url: z.string().optional(), + title: z.string().optional(), + width: z.string().optional(), + height: z.string().optional(), + }) + .optional(), + }) + .optional(), +}); + +const cachedFeedResultSchema = z.union([ + z.object({ + status: z.literal("success"), + data: z.object({ + title: z.string(), + url: z.string(), + items: z.array(rssContentSchema), + fetchMetadata: feedFetchMetadataSchema, + }), + }), + z.object({ + status: z.literal("empty"), + fetchMetadata: feedFetchMetadataSchema, + }), + z.object({ + status: z.literal("error"), + message: z.string(), + fetchMetadata: feedFetchMetadataSchema.optional(), + }), +]); + +export type CachedFeedResult = + | { + status: "success"; + data: Omit; + } + | { + status: "empty"; + fetchMetadata: FeedFetchMetadata; + } + | { + status: "error"; + fetchMetadata?: FeedFetchMetadata; + message: string; + }; + +/** + * Normalize a feed URL so that trailing slashes are stripped and the + * string is canonical. Two URLs that differ only by a trailing slash + * will produce the same normalized string. + */ +export function normalizeFeedUrl(url: string): string { + try { + const parsed = new URL(url); + // Strip trailing slash from pathname, but preserve root "/" + if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) { + parsed.pathname = parsed.pathname.slice(0, -1); + } + // Sort search params for stability + parsed.searchParams.sort(); + return parsed.toString(); + } catch { + // If URL parsing fails, fall back to a simple trailing-slash strip + return url.endsWith("/") ? url.slice(0, -1) : url; + } +} + +/** + * Build a stable cache key for a feed URL. + */ +export function getFeedCacheKey(url: string): string { + const normalized = normalizeFeedUrl(url); + const hash = createHash("sha256").update(normalized).digest("hex"); + return `feed:rss:${hash}`; +} + +/** + * Calculate how many seconds a feed result should be cached. + */ +export function getFeedCacheTtlSeconds( + status: CachedFeedResult["status"], + fetchMetadata?: FeedFetchMetadata, +): number { + if (status === "error") { + return Math.floor(ERROR_BACKOFF_MS / 1000); + } + + if (fetchMetadata?.ttl !== undefined && fetchMetadata.ttl > 0) { + return Math.min(fetchMetadata.ttl * 60, Math.floor(MAX_INTERVAL_MS / 1000)); + } + + return Math.floor(DEFAULT_INTERVAL_MS / 1000); +} + +/** + * Read a cached feed result from the global KV store. + */ +export async function getCachedFeedResult( + url: string, +): Promise { + const key = getFeedCacheKey(url); + const kv = await getKV(); + const raw = await kv.get(key); + if (!raw) return null; + + try { + const parsed = JSON.parse(raw); + const validated = cachedFeedResultSchema.parse(parsed); + return validated; + } catch { + return null; + } +} + +/** + * Write a feed result to the global KV store. + */ +export async function setCachedFeedResult( + url: string, + result: CachedFeedResult, +): Promise { + const key = getFeedCacheKey(url); + const kv = await getKV(); + + const fetchMetadata = + result.status === "success" + ? result.data.fetchMetadata + : result.status === "empty" + ? result.fetchMetadata + : result.fetchMetadata; + + const ttlSeconds = getFeedCacheTtlSeconds(result.status, fetchMetadata); + + await kv.set(key, JSON.stringify(result), ttlSeconds); +} diff --git a/src/server/rss/fetchFeeds.ts b/src/server/rss/fetchFeeds.ts index e6d5f6ee..55b02749 100644 --- a/src/server/rss/fetchFeeds.ts +++ b/src/server/rss/fetchFeeds.ts @@ -4,6 +4,7 @@ import { feedItems, feeds } from "../db/schema"; import { buildConflictUpdateColumns } from "../db/utils"; import { logMessage } from "../logger"; import { calculateNextFetch } from "./calculateNextFetch"; +import { getCachedFeedResult, setCachedFeedResult } from "./feedCache"; import { fetchNebulaFeedData, fetchNebulaFeedDetails } from "./parsers/nebula"; import { fetchPeerTubeFeedData } from "./parsers/peertube"; import { fetchUnknownRssFeed } from "./parsers/unknown"; @@ -19,8 +20,10 @@ import type { ConditionalHeaders, FeedFetchResult, NewFeedDetails, + RSSContent, RSSFeedWithMetadata, } from "./types"; +import { env } from "~/env"; import { dbSemaphore } from "~/lib/semaphore"; /** How long to back off a feed after a fetch error, to avoid cascading retries. */ @@ -28,13 +31,52 @@ const ERROR_BACKOFF_MS = 60 * 60 * 1000; // 1 hour export type FetchFeedsStatus = "success" | "empty" | "error" | "skipped"; +function assertValidFeedUrl(url: string) { + let parsed: URL; + try { + parsed = new URL(url); + } catch (e) { + throw new Error("Invalid URL", { cause: e }); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Invalid URL protocol"); + } + + const hostname = parsed.hostname.toLowerCase(); + if ( + (env.NODE_ENV === "production" && hostname === "localhost") || + hostname.endsWith(".localhost") + ) { + throw new Error("Localhost URLs are not allowed"); + } + if ( + env.NODE_ENV === "production" && + /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) + ) { + throw new Error("Feeds hosted on IPV4 addresses are not allowed"); + } +} + export async function fetchNewFeedDetails( url: string, ): Promise { + assertValidFeedUrl(url); + let urls = [url]; // process url - if (url.includes("youtube.com/@") || url.includes("youtube.com/channel/")) { + const parsedUrl = new URL(url); + const hostname = parsedUrl.hostname.toLowerCase(); + const isYouTubeHost = + hostname === "youtube.com" || + hostname === "www.youtube.com" || + hostname.endsWith(".youtube.com"); + + if ( + isYouTubeHost && + (url.includes("youtube.com/@") || url.includes("youtube.com/channel/")) + ) { const feed = await fetch(url); const text = await feed.text(); @@ -50,10 +92,21 @@ export async function fetchNewFeedDetails( const feedDetailList = ( await Promise.all( urls.map(async (feedUrl) => { - if (feedUrl.includes("youtube.com")) { + assertValidFeedUrl(feedUrl); + const feedHostname = new URL(feedUrl).hostname.toLowerCase(); + const isYouTube = + feedHostname === "youtube.com" || + feedHostname === "www.youtube.com" || + feedHostname.endsWith(".youtube.com"); + if (isYouTube) { return fetchYouTubeFeedDetails(feedUrl); } - if (feedUrl.includes("nebula.tv") || feedUrl.includes("nebula.app")) { + if ( + feedHostname === "nebula.tv" || + feedHostname === "nebula.app" || + feedHostname.endsWith(".nebula.tv") || + feedHostname.endsWith(".nebula.app") + ) { return fetchNebulaFeedDetails(feedUrl); } return fetchUnknownRssFeed(feedUrl); @@ -70,17 +123,118 @@ type FeedResult = status: "success"; feedItems: ApplicationFeedItem[]; id: number; + fromCache?: boolean; } | { status: "empty" | "skipped"; id: number; + fromCache?: boolean; } | { status: "error"; id: number; error: unknown; + fromCache?: boolean; }; +async function insertFeedItems( + context: { db: typeof Database }, + feedId: number, + items: RSSContent[], + databaseFeeds: DatabaseFeed[], +): Promise { + if (!items.length) { + return []; + } + + const feedItemList: Array = items.map( + (item) => { + return { + feedId, + contentId: item.id, + content: item.content, + contentSnippet: item.contentSnippet, + 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; + }, + ); + + // Compute content hash for each incoming item + const feedItemListWithHash = feedItemList.map((item) => ({ + ...item, + contentHash: computeItemHash(item), + })); + + // Diff against existing hashes to avoid unnecessary writes. + const incomingUrls = feedItemListWithHash.map((item) => item.url); + const existingItems = await dbSemaphore.run(() => + context.db + .select({ + url: feedItems.url, + contentHash: feedItems.contentHash, + }) + .from(feedItems) + .where( + and(eq(feedItems.feedId, feedId), inArray(feedItems.url, incomingUrls)), + ) + .all(), + ); + + const existingByUrl = new Map(existingItems.map((item) => [item.url, item])); + + const changedItems = feedItemListWithHash.filter((incoming) => { + const existing = existingByUrl.get(incoming.url); + if (!existing) return true; // new item + // null hash means pre-migration row — force re-write to populate hash + return existing.contentHash !== incoming.contentHash; + }); + + if (changedItems.length === 0) { + return []; + } + + const feedItemsList = ( + await dbSemaphore.run(() => + context.db + .insert(feedItems) + .values(changedItems) + .onConflictDoUpdate({ + target: [feedItems.url, feedItems.feedId], + set: buildConflictUpdateColumns(feedItems, [ + "author", + "content", + "contentHash", + "contentId", + "contentSnippet", + "createdAt", + "orientation", + "postedAt", + "thumbnail", + "title", + "url", + ]), + }) + .returning(), + ) + ) + .filter(Boolean) + .flat(); + + return feedItemsList.map((item) => { + const itemFeed = databaseFeeds.find((f) => f.id === item.feedId); + + return { + ...item, + platform: itemFeed?.platform ?? "youtube", + } as ApplicationFeedItem; + }); +} + export async function* fetchAndInsertFeedData( context: { db: typeof Database }, databaseFeeds: DatabaseFeed[], @@ -98,6 +252,91 @@ export async function* fetchAndInsertFeedData( }; } + if (!feed.isActive) { + return { + status: "skipped", + id: feed.id, + }; + } + + // Check cross-user cache + const cachedResult = await getCachedFeedResult(feed.url); + + if (cachedResult) { + if (cachedResult.status === "error") { + const errorBackoffAt = new Date(now.getTime() + ERROR_BACKOFF_MS); + await dbSemaphore.run(() => + context.db + .update(feeds) + .set({ nextFetchAt: errorBackoffAt }) + .where(eq(feeds.id, feed.id)), + ); + return { + status: "error", + id: feed.id, + error: new Error(cachedResult.message), + fromCache: true, + }; + } + + if (cachedResult.status === "empty") { + const nextFetchAt = calculateNextFetch( + cachedResult.fetchMetadata, + now, + ); + await dbSemaphore.run(() => + context.db + .update(feeds) + .set({ + lastFetchedAt: now, + nextFetchAt, + etag: cachedResult.fetchMetadata.etag ?? null, + lastModifiedHeader: + cachedResult.fetchMetadata.lastModified ?? null, + }) + .where(eq(feeds.id, feed.id)), + ); + return { + status: "empty", + id: feed.id, + fromCache: true, + }; + } + + // cached success + const nextFetchAt = calculateNextFetch( + cachedResult.data.fetchMetadata, + now, + ); + await dbSemaphore.run(() => + context.db + .update(feeds) + .set({ + lastFetchedAt: now, + nextFetchAt, + etag: cachedResult.data.fetchMetadata.etag ?? null, + lastModifiedHeader: + cachedResult.data.fetchMetadata.lastModified ?? null, + }) + .where(eq(feeds.id, feed.id)), + ); + + const applicationFeedItems = await insertFeedItems( + context, + feed.id, + cachedResult.data.items, + databaseFeeds, + ); + + return { + status: "success", + feedItems: applicationFeedItems, + id: feed.id, + fromCache: true, + }; + } + + // Cache miss — proceed with HTTP fetch const cached: ConditionalHeaders = { etag: feed.etag, lastModifiedHeader: feed.lastModifiedHeader, @@ -123,12 +362,17 @@ export async function* fetchAndInsertFeedData( .set({ nextFetchAt: errorBackoffAt }) .where(eq(feeds.id, feed.id)), ); + const error = new Error( + `No feed data returned for platform: ${feed.platform}`, + ); + await setCachedFeedResult(feed.url, { + status: "error", + message: error.message, + }); return { status: "error", id: feed.id, - error: new Error( - `No feed data returned for platform: ${feed.platform}`, - ), + error, }; } @@ -153,14 +397,50 @@ export async function* fetchAndInsertFeedData( // At this point feedData is a full RSSFeedWithMetadata (not notModified) const completedFeed = feedData as RSSFeedWithMetadata; - // Calculate next fetch time and update feed timestamps + conditional headers + if (!completedFeed.items.length) { + await setCachedFeedResult(feed.url, { + status: "empty", + fetchMetadata: completedFeed.fetchMetadata, + }); + const nextFetchAt = calculateNextFetch( + completedFeed.fetchMetadata, + now, + ); + await dbSemaphore.run(() => + context.db + .update(feeds) + .set({ + lastFetchedAt: now, + nextFetchAt: nextFetchAt, + etag: completedFeed.fetchMetadata.etag ?? null, + lastModifiedHeader: + completedFeed.fetchMetadata.lastModified ?? null, + }) + .where(eq(feeds.id, feed.id)), + ); + return { + status: "empty", + id: feed.id, + }; + } + + await setCachedFeedResult(feed.url, { + status: "success", + data: { + title: completedFeed.title, + url: completedFeed.url, + items: completedFeed.items, + fetchMetadata: completedFeed.fetchMetadata, + }, + }); + const nextFetchAt = calculateNextFetch(completedFeed.fetchMetadata, now); await dbSemaphore.run(() => context.db .update(feeds) .set({ lastFetchedAt: now, - nextFetchAt: nextFetchAt, + nextFetchAt, etag: completedFeed.fetchMetadata.etag ?? null, lastModifiedHeader: completedFeed.fetchMetadata.lastModified ?? null, @@ -168,108 +448,13 @@ export async function* fetchAndInsertFeedData( .where(eq(feeds.id, feed.id)), ); - if (!completedFeed.items.length) { - return { - status: "empty", - id: feed.id, - }; - } - - const feedItemList: Array = - completedFeed.items.map((item) => { - return { - feedId: feed.id, - contentId: item.id, - content: item.content, - contentSnippet: item.contentSnippet, - 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; - }); - - // Compute content hash for each incoming item - const feedItemListWithHash = feedItemList.map((item) => ({ - ...item, - contentHash: computeItemHash(item), - })); - - // Diff against existing hashes to avoid unnecessary writes. - const incomingUrls = feedItemListWithHash.map((item) => item.url); - const existingItems = await dbSemaphore.run(() => - context.db - .select({ - url: feedItems.url, - contentHash: feedItems.contentHash, - }) - .from(feedItems) - .where( - and( - eq(feedItems.feedId, feed.id), - inArray(feedItems.url, incomingUrls), - ), - ) - .all(), - ); - - const existingByUrl = new Map( - existingItems.map((item) => [item.url, item]), + const applicationFeedItems = await insertFeedItems( + context, + feed.id, + completedFeed.items, + databaseFeeds, ); - const changedItems = feedItemListWithHash.filter((incoming) => { - const existing = existingByUrl.get(incoming.url); - if (!existing) return true; // new item - // null hash means pre-migration row — force re-write to populate hash - return existing.contentHash !== incoming.contentHash; - }); - - if (changedItems.length === 0) { - return { - status: "success", - feedItems: [], - id: feed.id, - }; - } - - const feedItemsList = ( - await dbSemaphore.run(() => - context.db - .insert(feedItems) - .values(changedItems) - .onConflictDoUpdate({ - target: [feedItems.url, feedItems.feedId], - set: buildConflictUpdateColumns(feedItems, [ - "author", - "content", - "contentHash", - "contentId", - "contentSnippet", - "createdAt", - "orientation", - "postedAt", - "thumbnail", - "title", - "url", - ]), - }) - .returning(), - ) - ) - .filter(Boolean) - .flat(); - - const applicationFeedItems = feedItemsList.map((item) => { - const itemFeed = databaseFeeds.find((f) => f.id === item.feedId); - - return { - ...item, - platform: itemFeed?.platform ?? "youtube", - } as ApplicationFeedItem; - }); - return { status: "success", feedItems: applicationFeedItems, @@ -288,6 +473,10 @@ export async function* fetchAndInsertFeedData( } catch { // Best-effort — don't let the backoff update mask the original error } + await setCachedFeedResult(feed.url, { + status: "error", + message: e instanceof Error ? e.message : String(e), + }); return { status: "error", id: feed.id, @@ -296,7 +485,8 @@ export async function* fetchAndInsertFeedData( } }); - let cachedCount = 0; + let skippedCount = 0; + let crossUserCacheCount = 0; let fetchedCount = 0; const totalFeeds = databaseFeeds.length; const fetchedFeedNames: string[] = []; @@ -309,7 +499,9 @@ export async function* fetchAndInsertFeedData( feedIds.splice(resultIndex, 1); if (result.status === "skipped") { - cachedCount++; + skippedCount++; + } else if (result.fromCache) { + crossUserCacheCount++; } else { fetchedCount++; const feedName = databaseFeeds.find((f) => f.id === result.id)?.name; @@ -323,9 +515,11 @@ export async function* fetchAndInsertFeedData( // Log fetch statistics if (totalFeeds > 0) { - const cachedPercent = ((cachedCount / totalFeeds) * 100).toFixed(1); + const cacheHitPercent = ((crossUserCacheCount / totalFeeds) * 100).toFixed( + 1, + ); logMessage( - `[Feed Fetch] ${cachedCount} cached, ${fetchedCount} fetched (${cachedPercent}% cached) out of ${totalFeeds} feeds`, + `[Feed Fetch] ${skippedCount} skipped, ${crossUserCacheCount} cross-user cached (${cacheHitPercent}%), ${fetchedCount} fetched out of ${totalFeeds} feeds`, ); } diff --git a/src/server/subscriptions/fetchProducts.ts b/src/server/subscriptions/fetchProducts.ts new file mode 100644 index 00000000..6741119a --- /dev/null +++ b/src/server/subscriptions/fetchProducts.ts @@ -0,0 +1,8 @@ +import { createServerFn } from "@tanstack/react-start"; +import { fetchProducts } from "./products"; + +export const fetchProductsServerFn = createServerFn({ + method: "GET", +}).handler(async () => { + return fetchProducts(); +}); diff --git a/src/server/subscriptions/products.ts b/src/server/subscriptions/products.ts new file mode 100644 index 00000000..42248f3e --- /dev/null +++ b/src/server/subscriptions/products.ts @@ -0,0 +1,108 @@ +import { getPolarProductIds, IS_BILLING_ENABLED, polarClient } from "./polar"; +import { PAID_PLAN_IDS, PLANS } from "./plans"; +import type { PaidPlanId } from "./plans"; +import { captureException } from "~/server/logger"; + +export type PlanProduct = { + planId: PaidPlanId; + planName: string; + monthlyPrice: number | null; + annualPrice: number | null; + monthlyProductId: string | null; + annualProductId: string | null; +}; + +type CachedProducts = { + data: PlanProduct[]; + expiresAt: number; +}; + +let productsCache: CachedProducts | null = null; +const CACHE_TTL_MS = 3 * 60 * 1000; // 3 minutes + +export async function fetchProducts(): Promise { + if (!IS_BILLING_ENABLED || !polarClient) { + return []; + } + + // Check cache + if (productsCache && Date.now() < productsCache.expiresAt) { + return productsCache.data; + } + + const productIds = PAID_PLAN_IDS.flatMap((planId) => { + const ids = getPolarProductIds(planId); + return [ids.monthly, ids.annual]; + }).filter(Boolean); + + if (productIds.length === 0) { + return []; + } + + try { + const results: PlanProduct[] = []; + + for (const planId of PAID_PLAN_IDS) { + const plan = PLANS[planId]; + const planProductIds = getPolarProductIds(planId); + + // Skip plans that have no Polar product IDs configured + if (!planProductIds.monthly && !planProductIds.annual) continue; + + let monthlyPrice: number | null = null; + let annualPrice: number | null = null; + + if (planProductIds.monthly) { + try { + const product = await polarClient.products.get({ + id: planProductIds.monthly, + }); + const price = product.prices?.[0]; + if (price && "amountType" in price && price.amountType === "fixed") { + monthlyPrice = (price as { priceAmount: number }).priceAmount; + } + } catch (e) { + captureException(e); + } + } + + if (planProductIds.annual) { + try { + const product = await polarClient.products.get({ + id: planProductIds.annual, + }); + const price = product.prices?.[0]; + if (price && "amountType" in price && price.amountType === "fixed") { + annualPrice = (price as { priceAmount: number }).priceAmount; + } + } catch (e) { + captureException(e); + console.error( + `[subscription] Failed to fetch annual product for ${planId}:\n`, + e, + ); + } + } + + results.push({ + planId, + planName: plan.name, + monthlyPrice, + annualPrice, + monthlyProductId: planProductIds.monthly, + annualProductId: planProductIds.annual, + }); + } + + productsCache = { + data: results, + expiresAt: Date.now() + CACHE_TTL_MS, + }; + + return results; + } catch (e) { + captureException(e); + console.error("[subscription] Failed to fetch products:", e); + return []; + } +} diff --git a/src/styles/globals.css b/src/styles/globals.css index 0d316928..0a093941 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -34,6 +34,7 @@ @layer base { html { background: hsl(var(--sidebar)); + overflow-y: scroll; } :root { @@ -341,100 +342,100 @@ @apply pb-8 md:pb-10; } - .blog { + .guides { color: var(--foreground); @apply mx-auto max-w-2xl; } - .blog h1 { + .guides h1 { @apply font-sans text-3xl leading-relaxed font-bold md:text-4xl; } - .blog h2 { + .guides h2 { @apply font-sans text-2xl leading-relaxed font-bold md:text-3xl; } - .blog h3 { + .guides h3 { @apply font-sans text-xl leading-relaxed font-bold md:text-2xl; } - .blog h4 { + .guides h4 { @apply font-sans text-lg leading-relaxed font-bold md:text-xl; } - .blog h5 { + .guides h5 { @apply font-sans text-lg leading-relaxed font-bold; } - .blog h6 { + .guides h6 { @apply font-sans text-lg leading-relaxed font-bold; } - .blog a { + .guides a { @apply text-foreground underline; } - .blog h1 a, - .blog h2 a, - .blog h3 a, - .blog h4 a, - .blog h5 a, - .blog h6 a { + .guides h1 a, + .guides h2 a, + .guides h3 a, + .guides h4 a, + .guides h5 a, + .guides h6 a { @apply pointer-events-none no-underline; } - .blog hr { + .guides hr { @apply my-12; } - .blog ul { + .guides ul { @apply text-foreground my-1 list-disc pl-5 text-lg; } - .blog ol { + .guides ol { @apply text-foreground my-1 list-decimal pl-5 text-lg; } - .blog p { + .guides p { @apply text-foreground text-lg; } - .blog kbd { + .guides kbd { @apply bg-muted border-muted-foreground/20 rounded border border-b-2 border-solid px-1.5 py-0.5 text-sm; } - .blog code { + .guides code { @apply bg-muted rounded px-px py-px text-base; } - .blog img { + .guides img { @apply my-8 rounded; } - .blog p:has(+ p), - .blog ul:has(+ p), - .blog ol:has(+ p) { + .guides p:has(+ p), + .guides ul:has(+ p), + .guides ol:has(+ p) { @apply pb-4 md:pb-6; } - .blog p:has(+ h1), - .blog ul:has(+ h1), - .blog ol:has(+ h1), - .blog p:has(+ h2), - .blog ul:has(+ h2), - .blog ol:has(+ h2), - .blog p:has(+ h3), - .blog ul:has(+ h3), - .blog ol:has(+ h3), - .blog p:has(+ h4), - .blog ul:has(+ h4), - .blog ol:has(+ h4), - .blog p:has(+ h5), - .blog ul:has(+ h5), - .blog ol:has(+ h5), - .blog p:has(+ h6), - .blog ul:has(+ h6), - .blog ol:has(+ h6) { + .guides p:has(+ h1), + .guides ul:has(+ h1), + .guides ol:has(+ h1), + .guides p:has(+ h2), + .guides ul:has(+ h2), + .guides ol:has(+ h2), + .guides p:has(+ h3), + .guides ul:has(+ h3), + .guides ol:has(+ h3), + .guides p:has(+ h4), + .guides ul:has(+ h4), + .guides ol:has(+ h4), + .guides p:has(+ h5), + .guides ul:has(+ h5), + .guides ol:has(+ h5), + .guides p:has(+ h6), + .guides ul:has(+ h6), + .guides ol:has(+ h6) { @apply pb-8 md:pb-10; } } diff --git a/tests/e2e/self-hosted/add-feed.spec.ts b/tests/e2e/self-hosted/add-feed.spec.ts new file mode 100644 index 00000000..60f833bb --- /dev/null +++ b/tests/e2e/self-hosted/add-feed.spec.ts @@ -0,0 +1,84 @@ +import { expect, test } from "@playwright/test"; +import { + SELF_HOSTED_APP_PORT, + SELF_HOSTED_RSS_SERVER_PORT, + SELF_HOSTED_TURSO_PORT, +} from "../fixtures/ports"; +import { cleanupUser, seedArticleData } from "../fixtures/seed-db"; +import { signIn } from "../fixtures/auth"; + +test.describe("add feed manually", () => { + test.use({ viewport: { width: 1920, height: 1080 } }); + + let testEmail: string; + + test.afterEach(async () => { + if (testEmail) { + await cleanupUser(SELF_HOSTED_TURSO_PORT, testEmail); + } + }); + + test("add a single feed by URL and verify it appears", async ({ page }) => { + test.setTimeout(120000); + + const { email, password } = await seedArticleData( + SELF_HOSTED_TURSO_PORT, + SELF_HOSTED_APP_PORT, + SELF_HOSTED_RSS_SERVER_PORT, + ); + testEmail = email; + + await signIn({ page, email, password }); + await expect(page.locator("article").first()).toBeVisible({ + timeout: 30000, + }); + + // Open the Add Feed dialog with the "a" keyboard shortcut + await page.keyboard.press("a"); + await page.waitForTimeout(300); + + // Wait for Add Feed dialog + const dialog = page.locator('[role="dialog"]'); + await expect(dialog.getByRole("heading", { name: "Add Feed" })).toBeVisible( + { timeout: 5000 }, + ); + + // Enter the RSS server URL for the "cgp-grey" feed + const feedUrl = `http://127.0.0.1:${SELF_HOSTED_RSS_SERVER_PORT}/feed/cgp-grey`; + await dialog.locator('input[type="url"]').fill(feedUrl); + + // Click the Find button + await dialog.getByRole("button", { name: /find/i }).click(); + + // For a single discovered feed, the dialog auto-selects it (locked state). + // Wait for the selected feed badge to appear. + await expect( + dialog.locator("p").filter({ hasText: "CGP Grey" }), + ).toBeVisible({ timeout: 10000 }); + + // Click Add Feed button + await dialog.getByRole("button", { name: /add .* feed/i }).click(); + + // Verify success toast + await expect(page.getByText("Feed added!")).toBeVisible({ + timeout: 10000, + }); + + // Verify the feed appears in the sidebar + const feedsSection = page.locator('[data-sidebar="group"]').filter({ + has: page.locator('[data-sidebar="group-label"]', { hasText: "Feeds" }), + }); + await expect( + feedsSection.getByRole("button", { name: "CGP Grey" }), + ).toBeVisible({ timeout: 10000 }); + + // Verify the feed appears on /feeds + await page.goto("/feeds"); + await expect( + page.getByRole("tab", { name: /feeds/i, selected: true }), + ).toBeVisible({ timeout: 10000 }); + await expect( + page.locator("main").getByRole("button", { name: "CGP Grey" }), + ).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/tests/e2e/self-hosted/item-actions.spec.ts b/tests/e2e/self-hosted/item-actions.spec.ts new file mode 100644 index 00000000..62c56392 --- /dev/null +++ b/tests/e2e/self-hosted/item-actions.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "@playwright/test"; +import { + SELF_HOSTED_APP_PORT, + SELF_HOSTED_TURSO_PORT, +} from "../fixtures/ports"; +import { cleanupUser, seedArticleData } from "../fixtures/seed-db"; +import { signIn } from "../fixtures/auth"; + +test.describe("feed item actions", () => { + test.use({ viewport: { width: 1920, height: 1080 } }); + + let testEmail: string; + + test.afterEach(async () => { + if (testEmail) { + await cleanupUser(SELF_HOSTED_TURSO_PORT, testEmail); + } + }); + + test("mark as read on read page and verify on home page", async ({ + page, + }) => { + test.setTimeout(120000); + + const { email, password, feedItemId } = await seedArticleData( + SELF_HOSTED_TURSO_PORT, + SELF_HOSTED_APP_PORT, + ); + testEmail = email; + + await signIn({ page, email, password }); + + // Wait for home page to fully load items + await expect(page.locator("article").first()).toBeVisible({ + timeout: 30000, + }); + + // Navigate to the article read page + await page.goto(`/read/${feedItemId}`); + await expect( + page.locator("h1").filter({ hasText: "Test Article" }), + ).toBeVisible({ timeout: 10000 }); + + // ── Mark as Read ─────────────────────────────────────────────── + await page.keyboard.press("e"); + await page.waitForTimeout(500); + + // ── Navigate back home with 'h' shortcut ─────────────────────── + await page.keyboard.press("h"); + await page.waitForTimeout(500); + await expect(page).toHaveURL("/", { timeout: 10000 }); + + // Switch to "read" filter with the "i" shortcut + await page.keyboard.press("i"); + await page.waitForTimeout(500); + + // Article should appear and have reduced opacity + const readArticle = page.locator("article").first(); + await expect(readArticle).toBeVisible({ timeout: 10000 }); + await expect(readArticle).toHaveClass(/opacity-50/, { timeout: 5000 }); + }); +}); diff --git a/tests/unit/rss/fetch-and-insert.test.ts b/tests/unit/rss/fetch-and-insert.test.ts index e19e3e3d..abfa4502 100644 --- a/tests/unit/rss/fetch-and-insert.test.ts +++ b/tests/unit/rss/fetch-and-insert.test.ts @@ -16,6 +16,10 @@ vi.mock("~/server/logger", () => ({ vi.mock("~/lib/semaphore", () => ({ dbSemaphore: { run: (fn: () => T) => fn() }, })); +vi.mock("~/server/rss/feedCache", () => ({ + getCachedFeedResult: vi.fn(() => Promise.resolve(null)), + setCachedFeedResult: vi.fn(() => Promise.resolve()), +})); const FIRESHIP_OLD_XML = readFileSync( resolvePath(__dirname, "../../e2e/fixtures/fireship-old.xml"),