From af3a732f649604e11561f5bcb19bc491c8d6121c Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Sun, 20 Apr 2025 12:54:30 -0400 Subject: [PATCH 01/35] schema, initial endpoint scaffolding --- src/app/(feed)/feed/SidebarViews.tsx | 130 +++++++++++++++++++++++++++ src/components/app-sidebar.tsx | 2 + src/lib/data/atoms.ts | 8 ++ src/lib/data/views/index.ts | 38 ++++++++ src/server/api/root.ts | 2 + src/server/api/routers/viewRouter.ts | 29 ++++++ src/server/db/constants.ts | 17 ++++ src/server/db/schema.ts | 53 ++++++++++- 8 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 src/app/(feed)/feed/SidebarViews.tsx create mode 100644 src/lib/data/views/index.ts create mode 100644 src/server/api/routers/viewRouter.ts create mode 100644 src/server/db/constants.ts diff --git a/src/app/(feed)/feed/SidebarViews.tsx b/src/app/(feed)/feed/SidebarViews.tsx new file mode 100644 index 0000000..af19eac --- /dev/null +++ b/src/app/(feed)/feed/SidebarViews.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useCallback } from "react"; + +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { CircleSmall } from "lucide-react"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "~/components/ui/sidebar"; +import { + categoryFilterAtom, + dateFilterAtom, + feedFilterAtom, + useFeedItemsMap, + useFeedItemsOrder, + visibilityFilterAtom, +} from "~/lib/data/atoms"; +import { useContentCategories } from "~/lib/data/content-categories"; +import { useFeedCategories } from "~/lib/data/feed-categories"; +import { doesFeedItemPassFilters } from "~/lib/data/feed-items"; + +function useCheckFilteredFeedItemsForView() { + const feedItemsOrder = useFeedItemsOrder(); + const feedItemsMap = useFeedItemsMap(); + const { feedCategories } = useFeedCategories(); + + const dateFilter = useAtomValue(dateFilterAtom); + const visibilityFilter = useAtomValue(visibilityFilterAtom); + + return useCallback( + (category: number) => { + if (!feedItemsOrder || !feedCategories) return []; + return feedItemsOrder.filter( + (item) => + feedItemsMap[item] && + doesFeedItemPassFilters( + feedItemsMap[item], + dateFilter, + visibilityFilter, + category, + feedCategories, + -1, + [], + ), + ); + }, + [ + feedItemsOrder, + feedItemsMap, + dateFilter, + visibilityFilter, + feedCategories, + ], + ); +} + +export function SidebarViews() { + const checkFilteredFeedItemsForCategory = useCheckFilteredFeedItemsForView(); + + const setFeedFilter = useSetAtom(feedFilterAtom); + const setDateFilter = useSetAtom(dateFilterAtom); + const [categoryFilter, setCategoryFilter] = useAtom(categoryFilterAtom); + + const { contentCategories } = useContentCategories(); + + const categoryOptions = contentCategories?.map((category) => ({ + ...category, + hasEntries: !!checkFilteredFeedItemsForCategory(category.id).length, + })); + + const hasAnyItems = !!checkFilteredFeedItemsForCategory(-1).length; + + if (!categoryOptions?.length) return null; + + const updateCategoryFilter = (category: number) => { + setFeedFilter(-1); + setCategoryFilter(category); + }; + + return ( + + Views + + + { + updateCategoryFilter(-1); + setDateFilter(1); + }} + > + {!hasAnyItems && ( + + )} + {hasAnyItems && ( +
+
+
+ )} + All + + + {categoryOptions?.map((option) => { + return ( + + updateCategoryFilter(option.id)} + > + {!option.hasEntries && ( + + )} + {option.hasEntries && ( +
+
+
+ )} + {option.name} + + + ); + })} + + + ); +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 92e4c99..ebe8874 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -34,6 +34,7 @@ import { } from "~/components/ui/sidebar"; import { ButtonWithShortcut } from "./ButtonWithShortcut"; import { SerialLogo } from "./SerialLogo"; +import { SidebarViews } from "~/app/(feed)/feed/SidebarViews"; export function AppLeftSidebar() { const { toggleSidebar, isMobile } = useSidebar(); @@ -65,6 +66,7 @@ export function AppLeftSidebar() { + diff --git a/src/lib/data/atoms.ts b/src/lib/data/atoms.ts index 2c3e471..64b8a15 100644 --- a/src/lib/data/atoms.ts +++ b/src/lib/data/atoms.ts @@ -12,6 +12,7 @@ import { feedCategorySchema, feedItemSchema, feedsSchema, + viewSchema, } from "~/server/db/schema"; function validatedPersistedAtom({ @@ -104,6 +105,13 @@ export const feedCategoriesAtom = validatedPersistedAtom< persistanceKey: "serial-feed-categories", }); +export const hasFetchedViewsAtom = atom(false); +export const viewsAtom = validatedPersistedAtom({ + defaultValue: [], + schema: viewSchema.array(), + persistanceKey: "serial-views", +}); + export const dateFilterAtom = atom(1); const visibilityFilterSchema = z.enum(["unread", "later", "videos", "shorts"]); export type VisibilityFilter = z.infer; diff --git a/src/lib/data/views/index.ts b/src/lib/data/views/index.ts new file mode 100644 index 0000000..2996764 --- /dev/null +++ b/src/lib/data/views/index.ts @@ -0,0 +1,38 @@ +import { useQuery } from "@tanstack/react-query"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useEffect } from "react"; +import { useTRPC } from "~/trpc/react"; +import { hasFetchedViewsAtom, viewsAtom } from "../atoms"; + +export function useViewsQuery() { + const setHasFetchedViews = useSetAtom(hasFetchedViewsAtom); + const setViews = useSetAtom(viewsAtom); + + const query = useQuery( + useTRPC().contentCategories.getAll.queryOptions(undefined, { + staleTime: Infinity, + }), + ); + + useEffect(() => { + if (query.isSuccess) { + setHasFetchedViews(true); + setViews(query.data); + } + }, [query]); + + return query; +} + +export function useViews() { + const [views, setViews] = useAtom(viewsAtom); + const hasFetchedViews = useAtomValue(hasFetchedViewsAtom); + const viewsQuery = useViewsQuery(); + + return { + views, + setViews, + viewsQuery, + hasFetchedViews, + }; +} diff --git a/src/server/api/root.ts b/src/server/api/root.ts index a0e6efd..afbd61e 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -5,6 +5,7 @@ import { feedCategoriesRouter } from "~/server/api/routers/feedCategoriesRouter" import { createTRPCRouter } from "~/server/api/trpc"; import { userConfigRouter } from "./routers/userConfigRouter"; import { userRouter } from "./routers/userRouter"; +import { viewRouter } from "./routers/viewRouter"; export const appRouter = createTRPCRouter({ user: userRouter, @@ -13,6 +14,7 @@ export const appRouter = createTRPCRouter({ contentCategories: contentCategoriesRouter, feedCategories: feedCategoriesRouter, userConfig: userConfigRouter, + views: viewRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/server/api/routers/viewRouter.ts b/src/server/api/routers/viewRouter.ts new file mode 100644 index 0000000..22a2e4b --- /dev/null +++ b/src/server/api/routers/viewRouter.ts @@ -0,0 +1,29 @@ +import { eq, asc } from "drizzle-orm"; +import { z } from "zod"; + +import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; +import { createViewSchema, views } from "~/server/db/schema"; + +export const viewRouter = createTRPCRouter({ + create: protectedProcedure + .input(createViewSchema) + .mutation(async ({ ctx, input }) => { + await ctx.db.insert(views).values({ + userId: ctx.auth!.user.id, + name: input.name, + daysWindow: input.daysWindow, + readStatus: input.readStatus, + orientation: input.orientation, + placement: input.placement, + }); + }), + getAll: protectedProcedure.query(async ({ ctx }) => { + const viewsList = await ctx.db + .select() + .from(views) + .where(eq(views.userId, ctx.auth!.user.id)) + .orderBy(asc(views.placement)); + + return viewsList; + }), +}); diff --git a/src/server/db/constants.ts b/src/server/db/constants.ts new file mode 100644 index 0000000..2b00d70 --- /dev/null +++ b/src/server/db/constants.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const FEED_ITEM_ORIENTATION = { + HORIZONTAL: "horizontal", + VERTICAL: "vertical", +} as const; +export const feedItemOrientationSchema = z.enum([ + FEED_ITEM_ORIENTATION.HORIZONTAL, + FEED_ITEM_ORIENTATION.VERTICAL, +]); + +export const VIEW_READ_STATUS = { + UNREAD: 0, + READ: 1, + ANY: 2, +} as const; +export const viewReadStatusSchema = z.number().gte(0).lte(2); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index efdbd0a..44222e2 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -8,7 +8,14 @@ import { index, primaryKey, } from "drizzle-orm/sqlite-core"; -import { createSelectSchema } from "drizzle-zod"; +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; +import { + FEED_ITEM_ORIENTATION, + feedItemOrientationSchema, + VIEW_READ_STATUS, + viewReadStatusSchema, +} from "./constants"; +import { z } from "zod"; /** * This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same @@ -167,3 +174,47 @@ export const userConfig = sqliteTable("user_config", { darkHSL: text("dark_hsl", { length: 16 }).notNull().default(""), }); export type DatabaseUserConfig = typeof userConfig.$inferSelect; + +export const views = sqliteTable( + "views", + { + id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), + userId: text("user_id").notNull().default(""), + name: text("name", { length: 256 }).notNull().default(""), + daysWindow: integer("days_window", { mode: "number" }).notNull().default(1), + readStatus: integer("read_status", { mode: "number" }) + .notNull() + .default(VIEW_READ_STATUS.UNREAD), + orientation: text("orientation", { length: 16 }) + .notNull() + .default(FEED_ITEM_ORIENTATION.HORIZONTAL), + placement: integer("read_status", { mode: "number" }).notNull().default(-1), + createdAt: integer("created_at", { mode: "timestamp" }) + .$default(() => new Date()) + .notNull(), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .$default(() => new Date()) + .notNull(), + }, + (example) => [index("feed_name_idx").on(example.name)], +); +export const viewSchema = createSelectSchema(views); +export const createViewSchema = createInsertSchema(views).merge( + z.object({ + readStatus: viewReadStatusSchema.optional(), + orientation: feedItemOrientationSchema.optional(), + daysWindow: z.number().lte(30).optional(), + placement: z.number().gte(-1).optional(), + }), +); +export type DatabaseView = typeof views.$inferSelect; + +export const viewCategories = sqliteTable( + "view_categories", + { + viewId: integer("view_id").references(() => views.id), + categoryId: integer("category_id").references(() => contentCategories.id), + }, + (table) => [primaryKey({ columns: [table.viewId, table.categoryId] })], +); +export type DatabaseViewCategory = typeof viewCategories.$inferSelect; From 9fcb69f2fc52930a0d1128f20a79c7ba20906b2c Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Sun, 20 Apr 2025 18:24:21 -0400 Subject: [PATCH 02/35] initial views version working --- src/app/(feed)/feed/AppDialogs.tsx | 2 + src/app/(feed)/feed/SidebarCategories.tsx | 11 +- src/app/(feed)/feed/SidebarFeeds.tsx | 21 +- src/app/(feed)/feed/SidebarViews.tsx | 79 +- src/app/(feed)/feed/ViewFilterChips.tsx | 31 + src/app/(feed)/feed/dialogStore.ts | 2 +- src/app/(feed)/feed/page.tsx | 6 +- .../feed/useCheckFilteredFeedItemsForFeed.tsx | 0 src/app/(feed)/feed/useUpdateViewFilter.tsx | 0 src/components/AddViewDialog.tsx | 165 ++++ src/components/app-sidebar.tsx | 38 +- src/lib/data/InitialClientQueries.tsx | 4 +- src/lib/data/atoms.ts | 14 +- src/lib/data/feed-items/index.ts | 22 + src/lib/data/views/index.ts | 101 ++- src/lib/data/views/mutations.ts | 17 + src/server/api/routers/viewRouter.ts | 56 +- .../db/migrations/0013_brainy_logan.sql | 20 + .../db/migrations/meta/0013_snapshot.json | 836 ++++++++++++++++++ src/server/db/migrations/meta/_journal.json | 7 + src/server/db/schema.ts | 31 +- 21 files changed, 1338 insertions(+), 125 deletions(-) create mode 100644 src/app/(feed)/feed/ViewFilterChips.tsx create mode 100644 src/app/(feed)/feed/useCheckFilteredFeedItemsForFeed.tsx create mode 100644 src/app/(feed)/feed/useUpdateViewFilter.tsx create mode 100644 src/components/AddViewDialog.tsx create mode 100644 src/lib/data/views/mutations.ts create mode 100644 src/server/db/migrations/0013_brainy_logan.sql create mode 100644 src/server/db/migrations/meta/0013_snapshot.json diff --git a/src/app/(feed)/feed/AppDialogs.tsx b/src/app/(feed)/feed/AppDialogs.tsx index c39d78b..5caeb46 100644 --- a/src/app/(feed)/feed/AppDialogs.tsx +++ b/src/app/(feed)/feed/AppDialogs.tsx @@ -1,10 +1,12 @@ import { AddFeedDialog } from "~/components/AddFeedDialog"; +import { AddViewDialog } from "~/components/AddViewDialog"; import { CustomVideoDialog } from "~/components/CustomVideoDialog"; export function AppDialogs() { return ( <> + ); diff --git a/src/app/(feed)/feed/SidebarCategories.tsx b/src/app/(feed)/feed/SidebarCategories.tsx index 5235927..afeb45c 100644 --- a/src/app/(feed)/feed/SidebarCategories.tsx +++ b/src/app/(feed)/feed/SidebarCategories.tsx @@ -17,15 +17,13 @@ import { feedFilterAtom, useFeedItemsMap, useFeedItemsOrder, + viewFilterAtom, visibilityFilterAtom, } from "~/lib/data/atoms"; import { useContentCategories } from "~/lib/data/content-categories"; import { useFeedCategories } from "~/lib/data/feed-categories"; -import { - doesFeedItemPassFilters, - useFilteredFeedItemsOrder, -} from "~/lib/data/feed-items"; -import { useFeeds } from "~/lib/data/feeds"; +import { doesFeedItemPassFilters } from "~/lib/data/feed-items"; +import { useDeselectViewFilter } from "~/lib/data/views"; function useCheckFilteredFeedItemsForCategory() { const feedItemsOrder = useFeedItemsOrder(); @@ -49,6 +47,7 @@ function useCheckFilteredFeedItemsForCategory() { feedCategories, -1, [], + null, ), ); }, @@ -68,6 +67,7 @@ export function SidebarCategories() { const setFeedFilter = useSetAtom(feedFilterAtom); const setDateFilter = useSetAtom(dateFilterAtom); + const deselectViewFilter = useDeselectViewFilter(); const [categoryFilter, setCategoryFilter] = useAtom(categoryFilterAtom); const { contentCategories } = useContentCategories(); @@ -84,6 +84,7 @@ export function SidebarCategories() { const updateCategoryFilter = (category: number) => { setFeedFilter(-1); setCategoryFilter(category); + deselectViewFilter(); }; return ( diff --git a/src/app/(feed)/feed/SidebarFeeds.tsx b/src/app/(feed)/feed/SidebarFeeds.tsx index c6fab83..1df72ec 100644 --- a/src/app/(feed)/feed/SidebarFeeds.tsx +++ b/src/app/(feed)/feed/SidebarFeeds.tsx @@ -1,13 +1,10 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { CircleSmall, PlusIcon } from "lucide-react"; import { useCallback } from "react"; -import { Button } from "~/components/ui/button"; import { SidebarGroup, SidebarGroupLabel, - SidebarHeader, SidebarMenu, - SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, } from "~/components/ui/sidebar"; @@ -17,11 +14,14 @@ import { feedFilterAtom, useFeedItemsMap, useFeedItemsOrder, + viewFilterAtom, visibilityFilterAtom, } from "~/lib/data/atoms"; import { useFeedCategories } from "~/lib/data/feed-categories"; import { doesFeedItemPassFilters } from "~/lib/data/feed-items"; import { useFeeds } from "~/lib/data/feeds"; +import { useDialogStore } from "./dialogStore"; +import { useDeselectViewFilter } from "~/lib/data/views"; function useCheckFilteredFeedItemsForFeed() { const feedItemsOrder = useFeedItemsOrder(); @@ -32,6 +32,7 @@ function useCheckFilteredFeedItemsForFeed() { const dateFilter = useAtomValue(dateFilterAtom); const visibilityFilter = useAtomValue(visibilityFilterAtom); const categoryFilter = useAtomValue(categoryFilterAtom); + const viewFilter = useAtomValue(viewFilterAtom); return useCallback( (feed: number) => { @@ -47,6 +48,7 @@ function useCheckFilteredFeedItemsForFeed() { feedCategories, feed, feeds, + viewFilter, ), ); }, @@ -64,9 +66,11 @@ function useCheckFilteredFeedItemsForFeed() { export function SidebarFeeds() { const { feeds } = useFeeds(); + const launchDialog = useDialogStore((store) => store.launchDialog); const setDateFilter = useSetAtom(dateFilterAtom); const [feedFilter, setFeedFilter] = useAtom(feedFilterAtom); + const deselectViewFilter = useDeselectViewFilter(); const checkFilteredFeedItemsForFeed = useCheckFilteredFeedItemsForFeed(); @@ -86,7 +90,14 @@ export function SidebarFeeds() { return ( - Feeds + + Feeds +
+ launchDialog("add-feed")}> + + +
+
{ setFeedFilter(-1); setDateFilter(1); + deselectViewFilter(); }} > {!hasAnyItems && ( @@ -115,6 +127,7 @@ export function SidebarFeeds() { onClick={() => { setFeedFilter(feed.id); setDateFilter(30); + deselectViewFilter(); }} > {!feed.hasEntries && ( diff --git a/src/app/(feed)/feed/SidebarViews.tsx b/src/app/(feed)/feed/SidebarViews.tsx index af19eac..e7064c0 100644 --- a/src/app/(feed)/feed/SidebarViews.tsx +++ b/src/app/(feed)/feed/SidebarViews.tsx @@ -3,7 +3,7 @@ import { useCallback } from "react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { CircleSmall } from "lucide-react"; +import { CircleSmall, PlusIcon } from "lucide-react"; import { SidebarGroup, SidebarGroupLabel, @@ -12,28 +12,35 @@ import { SidebarMenuItem, } from "~/components/ui/sidebar"; import { - categoryFilterAtom, dateFilterAtom, feedFilterAtom, useFeedItemsMap, useFeedItemsOrder, + viewFilterAtom, + viewFilterIdAtom, visibilityFilterAtom, } from "~/lib/data/atoms"; -import { useContentCategories } from "~/lib/data/content-categories"; import { useFeedCategories } from "~/lib/data/feed-categories"; import { doesFeedItemPassFilters } from "~/lib/data/feed-items"; +import { useUpdateViewFilter, useViews } from "~/lib/data/views"; +import { useDialogStore } from "./dialogStore"; +import { useFeeds } from "~/lib/data/feeds"; function useCheckFilteredFeedItemsForView() { const feedItemsOrder = useFeedItemsOrder(); const feedItemsMap = useFeedItemsMap(); const { feedCategories } = useFeedCategories(); + const { feeds } = useFeeds(); + const { views } = useViews(); const dateFilter = useAtomValue(dateFilterAtom); const visibilityFilter = useAtomValue(visibilityFilterAtom); return useCallback( - (category: number) => { + (viewId: number) => { if (!feedItemsOrder || !feedCategories) return []; + const viewFilter = views.find((view) => view.id === viewId) || null; + return feedItemsOrder.filter( (item) => feedItemsMap[item] && @@ -41,10 +48,11 @@ function useCheckFilteredFeedItemsForView() { feedItemsMap[item], dateFilter, visibilityFilter, - category, + -1, feedCategories, -1, - [], + feeds, + viewFilter, ), ); }, @@ -59,57 +67,36 @@ function useCheckFilteredFeedItemsForView() { } export function SidebarViews() { - const checkFilteredFeedItemsForCategory = useCheckFilteredFeedItemsForView(); + const launchDialog = useDialogStore((store) => store.launchDialog); + const checkFilteredFeedItemsForView = useCheckFilteredFeedItemsForView(); - const setFeedFilter = useSetAtom(feedFilterAtom); - const setDateFilter = useSetAtom(dateFilterAtom); - const [categoryFilter, setCategoryFilter] = useAtom(categoryFilterAtom); + const updateViewFilter = useUpdateViewFilter(); + const [viewFilter] = useAtom(viewFilterIdAtom); - const { contentCategories } = useContentCategories(); + const { views } = useViews(); - const categoryOptions = contentCategories?.map((category) => ({ - ...category, - hasEntries: !!checkFilteredFeedItemsForCategory(category.id).length, + const viewOptions = views?.map((view) => ({ + ...view, + hasEntries: !!checkFilteredFeedItemsForView(view.id).length, })); - const hasAnyItems = !!checkFilteredFeedItemsForCategory(-1).length; - - if (!categoryOptions?.length) return null; - - const updateCategoryFilter = (category: number) => { - setFeedFilter(-1); - setCategoryFilter(category); - }; - return ( - Views - - - { - updateCategoryFilter(-1); - setDateFilter(1); - }} - > - {!hasAnyItems && ( - - )} - {hasAnyItems && ( -
-
-
- )} - All + + Views +
+ launchDialog("add-view")}> + - - {categoryOptions?.map((option) => { +
+
+ + {viewOptions?.map((option) => { return ( updateCategoryFilter(option.id)} + variant={option.id === viewFilter ? "outline" : "default"} + onClick={() => updateViewFilter(option.id)} > {!option.hasEntries && ( diff --git a/src/app/(feed)/feed/ViewFilterChips.tsx b/src/app/(feed)/feed/ViewFilterChips.tsx new file mode 100644 index 0000000..5cbc833 --- /dev/null +++ b/src/app/(feed)/feed/ViewFilterChips.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useAtom } from "jotai"; +import { ToggleGroup, ToggleGroupItem } from "~/components/ui/toggle-group"; +import { viewFilterIdAtom } from "~/lib/data/atoms"; +import { useUpdateViewFilter, useViews } from "~/lib/data/views"; + +export function ViewFilterChips() { + const { views } = useViews(); + const [viewFilter] = useAtom(viewFilterIdAtom); + + const updateViewFilter = useUpdateViewFilter(); + + return ( + { + if (!value) return; + updateViewFilter(parseInt(value)); + }} + size="sm" + > + {views.map((view) => ( + + {view.name} + + ))} + + ); +} diff --git a/src/app/(feed)/feed/dialogStore.ts b/src/app/(feed)/feed/dialogStore.ts index 2a7caeb..6d86742 100644 --- a/src/app/(feed)/feed/dialogStore.ts +++ b/src/app/(feed)/feed/dialogStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; -export type DialogType = "add-feed" | "custom-video"; +export type DialogType = "add-feed" | "add-view" | "custom-video"; type DialogStore = { dialog: null | DialogType; launchDialog: (dialog: DialogType) => void; diff --git a/src/app/(feed)/feed/page.tsx b/src/app/(feed)/feed/page.tsx index e1c91fd..0b83e9b 100644 --- a/src/app/(feed)/feed/page.tsx +++ b/src/app/(feed)/feed/page.tsx @@ -2,6 +2,7 @@ import { ClientDatetime } from "./ClientDatetime"; import { DateFilterChips } from "./DateFilterChips"; import { ItemVisibilityChips } from "./ItemVisibilityChips"; import { TodayItems } from "./TodayItems"; +import { ViewFilterChips } from "./ViewFilterChips"; export default async function Home() { return ( @@ -12,10 +13,7 @@ export default async function Home() {

- -
-
- +
diff --git a/src/app/(feed)/feed/useCheckFilteredFeedItemsForFeed.tsx b/src/app/(feed)/feed/useCheckFilteredFeedItemsForFeed.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/app/(feed)/feed/useUpdateViewFilter.tsx b/src/app/(feed)/feed/useUpdateViewFilter.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/components/AddViewDialog.tsx b/src/components/AddViewDialog.tsx new file mode 100644 index 0000000..3daca69 --- /dev/null +++ b/src/components/AddViewDialog.tsx @@ -0,0 +1,165 @@ +"use client"; +import { DialogTitle } from "@radix-ui/react-dialog"; +import { ImportIcon } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useDialogStore } from "~/app/(feed)/feed/dialogStore"; +import { useContentCategories } from "~/lib/data/content-categories"; +import { useCreateContentCategoryMutation } from "~/lib/data/content-categories/mutations"; +import { useCreateFeedMutation } from "~/lib/data/feeds/mutations"; +import { validateFeedUrl } from "~/server/rss/validateFeedUrl"; +import { useTRPC } from "~/trpc/react"; +import { Button } from "./ui/button"; +import { Combobox } from "./ui/combobox"; +import { Dialog, DialogContent, DialogHeader } from "./ui/dialog"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { Badge } from "./ui/badge"; +import clsx from "clsx"; +import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group"; +import { VIEW_READ_STATUS } from "~/server/db/constants"; +import { useCreateViewMutation } from "~/lib/data/views/mutations"; + +function AddViewToggleItem({ + value, + children, +}: { + value: string; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function AddViewDialog() { + const trpc = useTRPC(); + const [isAddingView, setIsAddingView] = useState(false); + + const { mutateAsync: createView } = useCreateViewMutation(); + + const { contentCategories } = useContentCategories(); + + const [name, setName] = useState(""); + const [daysTimeWindow, setDaysTimeWindow] = useState(1); + const [readStatus, setReadStatus] = useState(VIEW_READ_STATUS.UNREAD); + const [selectedCategories, setSelectedCategories] = useState([]); + + const dialog = useDialogStore((store) => store.dialog); + const onOpenChange = useDialogStore((store) => store.onOpenChange); + + const isDisabled = !name; + + return ( + + + + Add View + +
+
+ + { + setName(e.target.value); + }} + /> +
+
+ + { + if (!value) return; + setDaysTimeWindow(parseInt(value)); + }} + size="sm" + className="w-fit" + > + Today + This Week + This Month + +
+
+ + { + if (!value) return; + setReadStatus(parseInt(value)); + }} + size="sm" + className="w-fit" + > + + Unread + + + Watched + + + Any + + +
+
+ + category.toString())} + onValueChange={(value) => { + if (!value) return; + setSelectedCategories(value.map((id) => parseInt(id))); + }} + size="sm" + className="flex w-fit flex-wrap justify-start gap-1" + > + {contentCategories.map((category) => { + return ( + + {category.name} + + ); + })} + +
+ +
+
+
+ ); +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index ebe8874..b345000 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -1,24 +1,9 @@ "use client"; -import { - IconCamera, - IconChartBar, - IconDashboard, - IconFileAi, - IconFileDescription, - IconFolder, - IconHelp, - IconListDetails, - IconSearch, - IconSettings, - IconUsers, -} from "@tabler/icons-react"; - -import { PlusIcon } from "lucide-react"; import Link from "next/link"; -import { useDialogStore } from "~/app/(feed)/feed/dialogStore"; import { SidebarCategories } from "~/app/(feed)/feed/SidebarCategories"; import { SidebarFeeds } from "~/app/(feed)/feed/SidebarFeeds"; +import { SidebarViews } from "~/app/(feed)/feed/SidebarViews"; import { UserManagementNavItem } from "~/app/(feed)/feed/UserManagementButton"; import { LeftSidebarBottomNav } from "~/components/LeftSidebarBottomNav"; import { LeftSidebarMain } from "~/components/LeftSidebarMain"; @@ -32,9 +17,7 @@ import { SidebarMenuItem, useSidebar, } from "~/components/ui/sidebar"; -import { ButtonWithShortcut } from "./ButtonWithShortcut"; import { SerialLogo } from "./SerialLogo"; -import { SidebarViews } from "~/app/(feed)/feed/SidebarViews"; export function AppLeftSidebar() { const { toggleSidebar, isMobile } = useSidebar(); @@ -78,30 +61,11 @@ export function AppLeftSidebar() { } export function AppRightSidebar() { - const launchDialog = useDialogStore((store) => store.launchDialog); - return ( - - - - - launchDialog("add-feed")} - shortcut="a" - > - - Add feed - - - - - ); } diff --git a/src/lib/data/InitialClientQueries.tsx b/src/lib/data/InitialClientQueries.tsx index 3020a9c..c53770a 100644 --- a/src/lib/data/InitialClientQueries.tsx +++ b/src/lib/data/InitialClientQueries.tsx @@ -3,10 +3,12 @@ import { type PropsWithChildren } from "react"; import { useFeedsQuery } from "./feeds"; import { useFeedItemsQuery } from "./feed-items"; +import { useViewsQuery } from "./views"; export function InitialClientQueries({ children }: PropsWithChildren) { - // useFeedsQuery(); + useFeedsQuery(); useFeedItemsQuery(); + useViewsQuery(); return children; } diff --git a/src/lib/data/atoms.ts b/src/lib/data/atoms.ts index 64b8a15..c7420fb 100644 --- a/src/lib/data/atoms.ts +++ b/src/lib/data/atoms.ts @@ -4,6 +4,8 @@ import { useMemo } from "react"; import superjson from "superjson"; import { z } from "zod"; import { + ApplicationView, + applicationViewSchema, contentCategorySchema, type DatabaseContentCategory, type DatabaseFeed, @@ -12,7 +14,6 @@ import { feedCategorySchema, feedItemSchema, feedsSchema, - viewSchema, } from "~/server/db/schema"; function validatedPersistedAtom({ @@ -106,9 +107,9 @@ export const feedCategoriesAtom = validatedPersistedAtom< }); export const hasFetchedViewsAtom = atom(false); -export const viewsAtom = validatedPersistedAtom({ +export const viewsAtom = validatedPersistedAtom({ defaultValue: [], - schema: viewSchema.array(), + schema: applicationViewSchema.array(), persistanceKey: "serial-views", }); @@ -118,3 +119,10 @@ export type VisibilityFilter = z.infer; export const visibilityFilterAtom = atom("unread"); export const categoryFilterAtom = atom(-1); export const feedFilterAtom = atom(-1); + +export const viewFilterIdAtom = atom(-1); +export const viewFilterAtom = atom((get) => { + const views = get(viewsAtom); + const viewId = get(viewFilterIdAtom); + return views.find((view) => view.id === viewId) || null; +}); diff --git a/src/lib/data/feed-items/index.ts b/src/lib/data/feed-items/index.ts index 5e53dd4..8bb2956 100644 --- a/src/lib/data/feed-items/index.ts +++ b/src/lib/data/feed-items/index.ts @@ -16,10 +16,12 @@ import { feedItemsOrderAtom, feedsAtom, hasFetchedFeedItemsAtom, + viewFilterAtom, type VisibilityFilter, visibilityFilterAtom, } from "../atoms"; import { splitAtom } from "jotai/utils"; +import { ApplicationView } from "../views"; export function doesFeedItemPassFilters( item: DatabaseFeedItem, @@ -29,6 +31,7 @@ export function doesFeedItemPassFilters( feedCategories: DatabaseFeedCategory[], feedFilter: number, feeds: DatabaseFeed[], + viewFilter: ApplicationView | null, ) { const date = new Date(item.postedAt); const now = new Date(); @@ -69,6 +72,23 @@ export function doesFeedItemPassFilters( return false; } + const feedsForView = feedCategories + .filter( + (category) => + category.categoryId !== null && + viewFilter?.categoryIds.includes(category.categoryId), + ) + .map((category) => category.feedId); + + // View filter + if ( + !!viewFilter && + viewFilter.categoryIds.length > 0 && + !feedsForView.includes(item.feedId) + ) { + return false; + } + return true; } @@ -81,6 +101,7 @@ const filteredFeedItemsOrderAtom = atom((get) => { const feedCategories = get(feedCategoriesAtom); const feedFilter = get(feedFilterAtom); const feeds = get(feedsAtom); + const viewFilter = get(viewFilterAtom); return feedItemsOrder.filter( (item) => @@ -93,6 +114,7 @@ const filteredFeedItemsOrderAtom = atom((get) => { feedCategories, feedFilter, feeds, + viewFilter, ), ); }); diff --git a/src/lib/data/views/index.ts b/src/lib/data/views/index.ts index 2996764..2eef3a2 100644 --- a/src/lib/data/views/index.ts +++ b/src/lib/data/views/index.ts @@ -1,27 +1,116 @@ import { useQuery } from "@tanstack/react-query"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { useEffect } from "react"; +import { useCallback, useEffect, useMemo } from "react"; +import { useSession } from "~/lib/auth-client"; +import { FEED_ITEM_ORIENTATION, VIEW_READ_STATUS } from "~/server/db/constants"; +import { ApplicationView, contentCategories } from "~/server/db/schema"; import { useTRPC } from "~/trpc/react"; -import { hasFetchedViewsAtom, viewsAtom } from "../atoms"; +import { + categoryFilterAtom, + dateFilterAtom, + feedFilterAtom, + hasFetchedViewsAtom, + viewFilterIdAtom, + viewsAtom, +} from "../atoms"; +import { useContentCategories } from "../content-categories"; + +export const UNSELECTED_VIEW_ID = -100; +export function useDeselectViewFilter() { + const setViewFilter = useSetAtom(viewFilterIdAtom); + return useCallback(() => { + setViewFilter(UNSELECTED_VIEW_ID); + }, [setViewFilter]); +} + +export function useUpdateViewFilter() { + const views = useAtomValue(viewsAtom); + const [viewFilter, setViewFilter] = useAtom(viewFilterIdAtom); + + const setFeedFilter = useSetAtom(feedFilterAtom); + const setDateFilter = useSetAtom(dateFilterAtom); + const setCategoryFilter = useSetAtom(categoryFilterAtom); + + const updateViewFilter = ( + viewId: number, + updatedViews?: ApplicationView[], + ) => { + const _views = updatedViews ?? views; + const view = _views?.find((view) => view.id === viewId); + + if (!view) return; + + setFeedFilter(-1); + setCategoryFilter(-1); + setDateFilter(view.daysWindow); + setViewFilter(view.id); + }; + + return updateViewFilter; +} export function useViewsQuery() { + const { data } = useSession(); + const { contentCategories } = useContentCategories(); const setHasFetchedViews = useSetAtom(hasFetchedViewsAtom); const setViews = useSetAtom(viewsAtom); + const updateViewFilter = useUpdateViewFilter(); const query = useQuery( - useTRPC().contentCategories.getAll.queryOptions(undefined, { + useTRPC().views.getAll.queryOptions(undefined, { staleTime: Infinity, }), ); + const transformedData = useMemo(() => { + const now = new Date(); + + const customViews: ApplicationView[] = (query.data ?? []).map((view) => ({ + ...view, + })); + + const allCategoryIdsSet = new Set( + contentCategories.map((category) => category.id), + ); + const customViewCategoryIdsSet = new Set( + customViews.flatMap((view) => view.categoryIds), + ); + + const inboxViewCategoryIds = allCategoryIdsSet.difference( + customViewCategoryIdsSet, + ); + + const inboxView: ApplicationView = { + id: -1, + name: "Inbox", + daysWindow: 7, + orientation: FEED_ITEM_ORIENTATION.HORIZONTAL, + readStatus: VIEW_READ_STATUS.UNREAD, + placement: -1, + userId: data?.user.id ?? "", + createdAt: now, + updatedAt: now, + categoryIds: Array.from(inboxViewCategoryIds), + isDefault: true, + }; + + return [...customViews, inboxView]; + }, [query.data]); + useEffect(() => { if (query.isSuccess) { setHasFetchedViews(true); - setViews(query.data); + setViews(transformedData); + if (!!transformedData?.[0]?.id) { + updateViewFilter(transformedData[0].id, transformedData); + } } - }, [query]); + }, [query.isSuccess, transformedData]); - return query; + return { + ...query, + data: transformedData, + }; } export function useViews() { diff --git a/src/lib/data/views/mutations.ts b/src/lib/data/views/mutations.ts new file mode 100644 index 0000000..37d034e --- /dev/null +++ b/src/lib/data/views/mutations.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "~/trpc/react"; + +export function useCreateViewMutation() { + const api = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + api.views.create.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: api.views.getAll.queryKey(), + }); + }, + }), + ); +} diff --git a/src/server/api/routers/viewRouter.ts b/src/server/api/routers/viewRouter.ts index 22a2e4b..b4acd56 100644 --- a/src/server/api/routers/viewRouter.ts +++ b/src/server/api/routers/viewRouter.ts @@ -2,19 +2,42 @@ import { eq, asc } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; -import { createViewSchema, views } from "~/server/db/schema"; +import { + ApplicationView, + createViewSchema, + viewCategories, + views, +} from "~/server/db/schema"; export const viewRouter = createTRPCRouter({ create: protectedProcedure .input(createViewSchema) .mutation(async ({ ctx, input }) => { - await ctx.db.insert(views).values({ - userId: ctx.auth!.user.id, - name: input.name, - daysWindow: input.daysWindow, - readStatus: input.readStatus, - orientation: input.orientation, - placement: input.placement, + ctx.db.transaction(async (tx) => { + const viewsResult = await tx + .insert(views) + .values({ + userId: ctx.auth!.user.id, + name: input.name, + daysWindow: input.daysWindow, + readStatus: input.readStatus, + orientation: input.orientation, + placement: input.placement, + }) + .returning(); + + const view = viewsResult?.[0]; + + if (!input.categoryIds || !view) return; + + return await Promise.all( + input.categoryIds.map(async (categoryId) => { + await tx.insert(viewCategories).values({ + viewId: view.id, + categoryId, + }); + }), + ); }); }), getAll: protectedProcedure.query(async ({ ctx }) => { @@ -24,6 +47,21 @@ export const viewRouter = createTRPCRouter({ .where(eq(views.userId, ctx.auth!.user.id)) .orderBy(asc(views.placement)); - return viewsList; + const viewCategoriesList = await ctx.db.select().from(viewCategories); + + const zippedViews = viewsList.map((view) => { + const applicationView: ApplicationView = { + ...view, + isDefault: false, + categoryIds: viewCategoriesList + .filter((category) => category.viewId === view.id) + .map((category) => category.categoryId) + .filter((id) => id !== null), + }; + + return applicationView; + }); + + return zippedViews; }), }); diff --git a/src/server/db/migrations/0013_brainy_logan.sql b/src/server/db/migrations/0013_brainy_logan.sql new file mode 100644 index 0000000..0fc53e5 --- /dev/null +++ b/src/server/db/migrations/0013_brainy_logan.sql @@ -0,0 +1,20 @@ +CREATE TABLE `serial_view_categories` ( + `view_id` integer, + `category_id` integer, + PRIMARY KEY(`view_id`, `category_id`), + FOREIGN KEY (`view_id`) REFERENCES `serial_views`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`category_id`) REFERENCES `serial_content_categories`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `serial_views` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text DEFAULT '' NOT NULL, + `name` text(256) DEFAULT '' NOT NULL, + `days_window` integer DEFAULT 1 NOT NULL, + `read_status` integer DEFAULT -1 NOT NULL, + `orientation` text(16) DEFAULT 'horizontal' NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `view_name_idx` ON `serial_views` (`name`); \ No newline at end of file diff --git a/src/server/db/migrations/meta/0013_snapshot.json b/src/server/db/migrations/meta/0013_snapshot.json new file mode 100644 index 0000000..9e9a040 --- /dev/null +++ b/src/server/db/migrations/meta/0013_snapshot.json @@ -0,0 +1,836 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1a5f1fcf-f8ba-4db1-9ebc-d1193494ee01", + "prevId": "8e116498-e8c8-4e24-9149-8166ae5050cc", + "tables": { + "serial_account": { + "name": "serial_account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "serial_account_user_id_serial_user_id_fk": { + "name": "serial_account_user_id_serial_user_id_fk", + "tableFrom": "serial_account", + "tableTo": "serial_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serial_content_categories": { + "name": "serial_content_categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "content_categories_name_idx": { + "name": "content_categories_name_idx", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serial_feed_categories": { + "name": "serial_feed_categories", + "columns": { + "feed_id": { + "name": "feed_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "serial_feed_categories_feed_id_serial_feed_id_fk": { + "name": "serial_feed_categories_feed_id_serial_feed_id_fk", + "tableFrom": "serial_feed_categories", + "tableTo": "serial_feed", + "columnsFrom": [ + "feed_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "serial_feed_categories_category_id_serial_content_categories_id_fk": { + "name": "serial_feed_categories_category_id_serial_content_categories_id_fk", + "tableFrom": "serial_feed_categories", + "tableTo": "serial_content_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "serial_feed_categories_feed_id_category_id_pk": { + "columns": [ + "feed_id", + "category_id" + ], + "name": "serial_feed_categories_feed_id_category_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serial_feed_item": { + "name": "serial_feed_item", + "columns": { + "feed_id": { + "name": "feed_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_id": { + "name": "content_id", + "type": "text(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "is_watched": { + "name": "is_watched", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_watch_later": { + "name": "is_watch_later", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "orientation": { + "name": "orientation", + "type": "text(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "posted_at": { + "name": "posted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "feed_item_feed_id_idx": { + "name": "feed_item_feed_id_idx", + "columns": [ + "feed_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "serial_feed_item_feed_id_serial_feed_id_fk": { + "name": "serial_feed_item_feed_id_serial_feed_id_fk", + "tableFrom": "serial_feed_item", + "tableTo": "serial_feed", + "columnsFrom": [ + "feed_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "serial_feed_item_feed_id_content_id_pk": { + "columns": [ + "feed_id", + "content_id" + ], + "name": "serial_feed_item_feed_id_content_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serial_feed": { + "name": "serial_feed", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "platform": { + "name": "platform", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'youtube'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "feed_name_idx": { + "name": "feed_name_idx", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serial_session": { + "name": "serial_session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "serial_session_token_unique": { + "name": "serial_session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "serial_session_user_id_serial_user_id_fk": { + "name": "serial_session_user_id_serial_user_id_fk", + "tableFrom": "serial_session", + "tableTo": "serial_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serial_user": { + "name": "serial_user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "serial_user_email_unique": { + "name": "serial_user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serial_user_config": { + "name": "serial_user_config", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "light_hsl": { + "name": "light_hsl", + "type": "text(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "dark_hsl": { + "name": "dark_hsl", + "type": "text(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serial_verification": { + "name": "serial_verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serial_view_categories": { + "name": "serial_view_categories", + "columns": { + "view_id": { + "name": "view_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "serial_view_categories_view_id_serial_views_id_fk": { + "name": "serial_view_categories_view_id_serial_views_id_fk", + "tableFrom": "serial_view_categories", + "tableTo": "serial_views", + "columnsFrom": [ + "view_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "serial_view_categories_category_id_serial_content_categories_id_fk": { + "name": "serial_view_categories_category_id_serial_content_categories_id_fk", + "tableFrom": "serial_view_categories", + "tableTo": "serial_content_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "serial_view_categories_view_id_category_id_pk": { + "columns": [ + "view_id", + "category_id" + ], + "name": "serial_view_categories_view_id_category_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serial_views": { + "name": "serial_views", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "days_window": { + "name": "days_window", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "read_status": { + "name": "read_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": -1 + }, + "orientation": { + "name": "orientation", + "type": "text(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'horizontal'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "view_name_idx": { + "name": "view_name_idx", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/server/db/migrations/meta/_journal.json b/src/server/db/migrations/meta/_journal.json index c63e115..5ac5910 100644 --- a/src/server/db/migrations/meta/_journal.json +++ b/src/server/db/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1744480782661, "tag": "0012_dazzling_tyger_tiger", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1745184500672, + "tag": "0013_brainy_logan", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 44222e2..cc4d94b 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -196,19 +196,22 @@ export const views = sqliteTable( .$default(() => new Date()) .notNull(), }, - (example) => [index("feed_name_idx").on(example.name)], + (example) => [index("view_name_idx").on(example.name)], ); + export const viewSchema = createSelectSchema(views); -export const createViewSchema = createInsertSchema(views).merge( - z.object({ - readStatus: viewReadStatusSchema.optional(), - orientation: feedItemOrientationSchema.optional(), - daysWindow: z.number().lte(30).optional(), - placement: z.number().gte(-1).optional(), - }), -); export type DatabaseView = typeof views.$inferSelect; +export const applicationViewSchema = createInsertSchema(views) + .merge( + z.object({ + categoryIds: z.array(z.number()), + isDefault: z.boolean(), + }), + ) + .required(); +export type ApplicationView = z.infer; + export const viewCategories = sqliteTable( "view_categories", { @@ -218,3 +221,13 @@ export const viewCategories = sqliteTable( (table) => [primaryKey({ columns: [table.viewId, table.categoryId] })], ); export type DatabaseViewCategory = typeof viewCategories.$inferSelect; + +export const createViewSchema = createInsertSchema(views).merge( + z.object({ + readStatus: viewReadStatusSchema.optional(), + orientation: feedItemOrientationSchema.optional(), + daysWindow: z.number().lte(30).optional(), + placement: z.number().gte(-1).optional(), + categoryIds: z.array(z.number()).optional(), + }), +); From b2e69f79f4c94ef795564b6264065dcdc1703ac4 Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Sun, 20 Apr 2025 18:53:27 -0400 Subject: [PATCH 03/35] lots of progress --- src/app/(feed)/feed/SidebarCategories.tsx | 12 ++--- src/app/(feed)/feed/SidebarFeeds.tsx | 8 ++-- src/app/(feed)/feed/SidebarViews.tsx | 2 +- src/app/(feed)/feed/TodayItems.tsx | 15 ++----- src/app/(feed)/feed/ViewFilterChips.tsx | 27 +++++++++++- src/lib/data/InitialClientQueries.tsx | 22 ++++++++-- src/lib/data/atoms.ts | 4 +- src/lib/data/views/index.ts | 53 ++++++++++++++++++++--- 8 files changed, 108 insertions(+), 35 deletions(-) diff --git a/src/app/(feed)/feed/SidebarCategories.tsx b/src/app/(feed)/feed/SidebarCategories.tsx index afeb45c..3ba7982 100644 --- a/src/app/(feed)/feed/SidebarCategories.tsx +++ b/src/app/(feed)/feed/SidebarCategories.tsx @@ -30,7 +30,6 @@ function useCheckFilteredFeedItemsForCategory() { const feedItemsMap = useFeedItemsMap(); const { feedCategories } = useFeedCategories(); - const dateFilter = useAtomValue(dateFilterAtom); const visibilityFilter = useAtomValue(visibilityFilterAtom); return useCallback( @@ -41,7 +40,7 @@ function useCheckFilteredFeedItemsForCategory() { feedItemsMap[item] && doesFeedItemPassFilters( feedItemsMap[item], - dateFilter, + 30, visibilityFilter, category, feedCategories, @@ -51,13 +50,7 @@ function useCheckFilteredFeedItemsForCategory() { ), ); }, - [ - feedItemsOrder, - feedItemsMap, - dateFilter, - visibilityFilter, - feedCategories, - ], + [feedItemsOrder, feedItemsMap, visibilityFilter, feedCategories], ); } @@ -84,6 +77,7 @@ export function SidebarCategories() { const updateCategoryFilter = (category: number) => { setFeedFilter(-1); setCategoryFilter(category); + setDateFilter(30); deselectViewFilter(); }; diff --git a/src/app/(feed)/feed/SidebarFeeds.tsx b/src/app/(feed)/feed/SidebarFeeds.tsx index 1df72ec..f11ef66 100644 --- a/src/app/(feed)/feed/SidebarFeeds.tsx +++ b/src/app/(feed)/feed/SidebarFeeds.tsx @@ -22,6 +22,7 @@ import { doesFeedItemPassFilters } from "~/lib/data/feed-items"; import { useFeeds } from "~/lib/data/feeds"; import { useDialogStore } from "./dialogStore"; import { useDeselectViewFilter } from "~/lib/data/views"; +import { ButtonWithShortcut } from "~/components/ButtonWithShortcut"; function useCheckFilteredFeedItemsForFeed() { const feedItemsOrder = useFeedItemsOrder(); @@ -93,8 +94,10 @@ export function SidebarFeeds() { Feeds
- launchDialog("add-feed")}> - + launchDialog("add-feed")}> + + +
@@ -105,7 +108,6 @@ export function SidebarFeeds() { onClick={() => { setFeedFilter(-1); setDateFilter(1); - deselectViewFilter(); }} > {!hasAnyItems && ( diff --git a/src/app/(feed)/feed/SidebarViews.tsx b/src/app/(feed)/feed/SidebarViews.tsx index e7064c0..409568f 100644 --- a/src/app/(feed)/feed/SidebarViews.tsx +++ b/src/app/(feed)/feed/SidebarViews.tsx @@ -26,7 +26,7 @@ import { useUpdateViewFilter, useViews } from "~/lib/data/views"; import { useDialogStore } from "./dialogStore"; import { useFeeds } from "~/lib/data/feeds"; -function useCheckFilteredFeedItemsForView() { +export function useCheckFilteredFeedItemsForView() { const feedItemsOrder = useFeedItemsOrder(); const feedItemsMap = useFeedItemsMap(); const { feedCategories } = useFeedCategories(); diff --git a/src/app/(feed)/feed/TodayItems.tsx b/src/app/(feed)/feed/TodayItems.tsx index f4bbef8..a202ac7 100644 --- a/src/app/(feed)/feed/TodayItems.tsx +++ b/src/app/(feed)/feed/TodayItems.tsx @@ -8,7 +8,6 @@ import { ClockIcon, EyeIcon, PlusIcon, - RefreshCwIcon, SproutIcon, } from "lucide-react"; import Link from "next/link"; @@ -16,7 +15,6 @@ import FeedLoading from "~/app/loading"; import { Button } from "~/components/ui/button"; import { useFeedItemGlobalState, - useFeedItemsMap, useFeedItemsOrder, useHasFetchedFeedItems, } from "~/lib/data/atoms"; @@ -25,12 +23,10 @@ import { useFilteredFeedItemsOrder } from "~/lib/data/feed-items"; import { useFeedItemsSetWatchedValueMutation, useFeedItemsSetWatchLaterValueMutation, - useFetchNewFeedItemsMutation, } from "~/lib/data/feed-items/mutations"; import { useFeeds } from "~/lib/data/feeds"; import { useDialogStore } from "./dialogStore"; -import { Suspense } from "react"; -import { useSidebar } from "~/components/ui/sidebar"; +import { useViews } from "~/lib/data/views"; function timeAgo(date: string | Date) { const diff = dayjs().diff(date); @@ -187,7 +183,8 @@ function ItemDisplay({ contentId }: { contentId: string }) { export function TodayItems() { const { feeds, hasFetchedFeeds } = useFeeds(); - const { feedCategories, hasFetchedFeedCategories } = useFeedCategories(); + const { hasFetchedFeedCategories } = useFeedCategories(); + const { views } = useViews(); const hasFetchedFeedItems = useHasFetchedFeedItems(); const feedItemsOrder = useFeedItemsOrder(); @@ -195,11 +192,7 @@ export function TodayItems() { const [parent] = useAutoAnimate(); - if ( - (!hasFetchedFeeds && !feeds.length) || - (!hasFetchedFeedItems && !feedItemsOrder.length) || - (!hasFetchedFeedCategories && !feedCategories.length) - ) { + if (!views.length) { return ; } diff --git a/src/app/(feed)/feed/ViewFilterChips.tsx b/src/app/(feed)/feed/ViewFilterChips.tsx index 5cbc833..f21ad52 100644 --- a/src/app/(feed)/feed/ViewFilterChips.tsx +++ b/src/app/(feed)/feed/ViewFilterChips.tsx @@ -1,9 +1,15 @@ "use client"; +import clsx from "clsx"; import { useAtom } from "jotai"; +import { useMemo } from "react"; import { ToggleGroup, ToggleGroupItem } from "~/components/ui/toggle-group"; import { viewFilterIdAtom } from "~/lib/data/atoms"; -import { useUpdateViewFilter, useViews } from "~/lib/data/views"; +import { + useCheckFilteredFeedItemsForView, + useUpdateViewFilter, + useViews, +} from "~/lib/data/views"; export function ViewFilterChips() { const { views } = useViews(); @@ -11,6 +17,16 @@ export function ViewFilterChips() { const updateViewFilter = useUpdateViewFilter(); + const checkFilteredFeedItemsForView = useCheckFilteredFeedItemsForView(); + + const viewHasEntriesMap = useMemo(() => { + const map = new Map(); + views.forEach((view) => { + map.set(view.id, checkFilteredFeedItemsForView(view.id).length > 0); + }); + return map; + }, [views, checkFilteredFeedItemsForView]); + return ( {views.map((view) => ( - + {view.name} ))} diff --git a/src/lib/data/InitialClientQueries.tsx b/src/lib/data/InitialClientQueries.tsx index c53770a..8c52492 100644 --- a/src/lib/data/InitialClientQueries.tsx +++ b/src/lib/data/InitialClientQueries.tsx @@ -1,14 +1,30 @@ "use client"; -import { type PropsWithChildren } from "react"; +import { useEffect, useRef, type PropsWithChildren } from "react"; import { useFeedsQuery } from "./feeds"; import { useFeedItemsQuery } from "./feed-items"; -import { useViewsQuery } from "./views"; +import { useUpdateViewFilter, useViews, useViewsQuery } from "./views"; +import { useContentCategoriesQuery } from "./content-categories"; +import { useAtom } from "jotai"; +import { hasSetInitialViewAtom } from "./atoms"; export function InitialClientQueries({ children }: PropsWithChildren) { useFeedsQuery(); useFeedItemsQuery(); - useViewsQuery(); + useContentCategoriesQuery(); + + const [hasSetInitialView, setHasSetInitialView] = useAtom( + hasSetInitialViewAtom, + ); + const { views } = useViews(); + const updateViewFilter = useUpdateViewFilter(); + + useEffect(() => { + if (!!views?.length && !hasSetInitialView) { + setHasSetInitialView(true); + updateViewFilter(views[0]!.id); + } + }, [views, hasSetInitialView, setHasSetInitialView, updateViewFilter]); return children; } diff --git a/src/lib/data/atoms.ts b/src/lib/data/atoms.ts index c7420fb..8672530 100644 --- a/src/lib/data/atoms.ts +++ b/src/lib/data/atoms.ts @@ -106,6 +106,7 @@ export const feedCategoriesAtom = validatedPersistedAtom< persistanceKey: "serial-feed-categories", }); +export const hasSetInitialViewAtom = atom(false); export const hasFetchedViewsAtom = atom(false); export const viewsAtom = validatedPersistedAtom({ defaultValue: [], @@ -120,7 +121,8 @@ export const visibilityFilterAtom = atom("unread"); export const categoryFilterAtom = atom(-1); export const feedFilterAtom = atom(-1); -export const viewFilterIdAtom = atom(-1); +export const UNSELECTED_VIEW_ID = -100; +export const viewFilterIdAtom = atom(UNSELECTED_VIEW_ID); export const viewFilterAtom = atom((get) => { const views = get(viewsAtom); const viewId = get(viewFilterIdAtom); diff --git a/src/lib/data/views/index.ts b/src/lib/data/views/index.ts index 2eef3a2..00493d2 100644 --- a/src/lib/data/views/index.ts +++ b/src/lib/data/views/index.ts @@ -10,12 +10,18 @@ import { dateFilterAtom, feedFilterAtom, hasFetchedViewsAtom, + UNSELECTED_VIEW_ID, + useFeedItemsMap, + useFeedItemsOrder, viewFilterIdAtom, viewsAtom, + visibilityFilterAtom, } from "../atoms"; import { useContentCategories } from "../content-categories"; +import { useFeedCategories } from "../feed-categories"; +import { useFeeds } from "../feeds"; +import { doesFeedItemPassFilters } from "../feed-items"; -export const UNSELECTED_VIEW_ID = -100; export function useDeselectViewFilter() { const setViewFilter = useSetAtom(viewFilterIdAtom); return useCallback(() => { @@ -49,10 +55,50 @@ export function useUpdateViewFilter() { return updateViewFilter; } +export function useCheckFilteredFeedItemsForView() { + const feedItemsOrder = useFeedItemsOrder(); + const feedItemsMap = useFeedItemsMap(); + const { feedCategories } = useFeedCategories(); + const { feeds } = useFeeds(); + const { views } = useViews(); + + const dateFilter = useAtomValue(dateFilterAtom); + const visibilityFilter = useAtomValue(visibilityFilterAtom); + + return useCallback( + (viewId: number) => { + if (!feedItemsOrder || !feedCategories) return []; + const viewFilter = views.find((view) => view.id === viewId) || null; + + return feedItemsOrder.filter( + (item) => + feedItemsMap[item] && + doesFeedItemPassFilters( + feedItemsMap[item], + dateFilter, + visibilityFilter, + -1, + feedCategories, + -1, + feeds, + viewFilter, + ), + ); + }, + [ + feedItemsOrder, + feedItemsMap, + dateFilter, + visibilityFilter, + feedCategories, + ], + ); +} + export function useViewsQuery() { const { data } = useSession(); const { contentCategories } = useContentCategories(); - const setHasFetchedViews = useSetAtom(hasFetchedViewsAtom); + const [hasFetchedViews, setHasFetchedViews] = useAtom(hasFetchedViewsAtom); const setViews = useSetAtom(viewsAtom); const updateViewFilter = useUpdateViewFilter(); @@ -101,9 +147,6 @@ export function useViewsQuery() { if (query.isSuccess) { setHasFetchedViews(true); setViews(transformedData); - if (!!transformedData?.[0]?.id) { - updateViewFilter(transformedData[0].id, transformedData); - } } }, [query.isSuccess, transformedData]); From cfe25cdcb94efaa67ec1c42aceb5de0a215d3d33 Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Mon, 21 Apr 2025 12:24:42 -0400 Subject: [PATCH 04/35] lint cleanup --- .eslintrc.cjs | 1 + src/app/(feed)/feed/Header.tsx | 1 - .../(feed)/feed/OpenRightSidebarButton.tsx | 6 +---- .../color-theme/ApplyColorTheme.tsx | 4 ++-- .../color-theme/ColorThemePopoverButton.tsx | 24 +++++++------------ .../releases/ReleaseNotifierClient.tsx | 6 ++--- src/components/ui/responsive-dropdown.tsx | 9 ++++--- src/components/ui/sidebar.tsx | 7 ++---- src/lib/data/InitialClientQueries.tsx | 10 ++++---- src/lib/data/atoms.ts | 2 +- src/lib/data/content-categories/index.ts | 4 ++-- src/lib/data/feed-categories/index.ts | 6 ++--- src/lib/data/feed-items/index.ts | 9 ++++--- src/lib/data/feed-items/mutations.ts | 8 +------ src/lib/hooks/use-search-param-state.ts | 2 +- src/lib/hooks/useFlagState.ts | 2 +- src/lib/markdown/releases.ts | 2 +- src/server/api/routers/viewRouter.ts | 7 +++--- 18 files changed, 43 insertions(+), 67 deletions(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index c3d0e06..11cd2f1 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -32,6 +32,7 @@ const config = { checksVoidReturn: { attributes: false }, }, ], + "@next/next/no-img-element": "off", }, }; diff --git a/src/app/(feed)/feed/Header.tsx b/src/app/(feed)/feed/Header.tsx index 9174cdf..10d212f 100644 --- a/src/app/(feed)/feed/Header.tsx +++ b/src/app/(feed)/feed/Header.tsx @@ -1,4 +1,3 @@ -import { Button } from "~/components/ui/button"; import { TopLeftButton } from "./TopLeftButton"; import { TopRightHeaderContent } from "./TopRightHeaderContent"; diff --git a/src/app/(feed)/feed/OpenRightSidebarButton.tsx b/src/app/(feed)/feed/OpenRightSidebarButton.tsx index e571c7a..aa761f7 100644 --- a/src/app/(feed)/feed/OpenRightSidebarButton.tsx +++ b/src/app/(feed)/feed/OpenRightSidebarButton.tsx @@ -1,10 +1,6 @@ "use client"; -import { - MenuIcon, - PanelRightCloseIcon, - PanelRightOpenIcon, -} from "lucide-react"; +import { PanelRightCloseIcon, PanelRightOpenIcon } from "lucide-react"; import { Button } from "~/components/ui/button"; import { useSidebar } from "~/components/ui/sidebar"; diff --git a/src/components/color-theme/ApplyColorTheme.tsx b/src/components/color-theme/ApplyColorTheme.tsx index 1465886..61151ca 100644 --- a/src/components/color-theme/ApplyColorTheme.tsx +++ b/src/components/color-theme/ApplyColorTheme.tsx @@ -1,6 +1,6 @@ -import { ApplyColorThemeOnMount } from "./ApplyColorThemeOnMount"; import { getServerApi } from "~/server/api/server"; -import { getServerAuth, isServerAuthed } from "~/server/auth"; +import { isServerAuthed } from "~/server/auth"; +import { ApplyColorThemeOnMount } from "./ApplyColorThemeOnMount"; export async function ApplyColorTheme({ children, diff --git a/src/components/color-theme/ColorThemePopoverButton.tsx b/src/components/color-theme/ColorThemePopoverButton.tsx index dceea5f..2430b07 100644 --- a/src/components/color-theme/ColorThemePopoverButton.tsx +++ b/src/components/color-theme/ColorThemePopoverButton.tsx @@ -1,25 +1,19 @@ "use client"; +import { useMutation } from "@tanstack/react-query"; +import clsx from "clsx"; import { LaptopIcon, MoonIcon, PaletteIcon, SunIcon } from "lucide-react"; -import { ResponsiveButton } from "../ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { useTheme } from "next-themes"; -import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group"; -import { useTRPC } from "~/trpc/react"; -import { useMutation } from "@tanstack/react-query"; import { useEffect, useState } from "react"; +import { authClient } from "~/lib/auth-client"; +import { useTRPC } from "~/trpc/react"; +import { ResponsiveButton } from "../ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { ResponsiveDropdown } from "../ui/responsive-dropdown"; import { Slider } from "../ui/slider"; -import clsx from "clsx"; +import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group"; import { EnableCustomVideoPlayerToggle } from "./EnableCustomVideoPlayerToggle"; import { ShowShortcutsToggle } from "./ShowShortcutsToggle"; -import { authClient } from "~/lib/auth-client"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; -import { useSidebar } from "../ui/sidebar"; -import { ResponsiveDropdown } from "../ui/responsive-dropdown"; function getCssVariable(name: string) { const value = window @@ -226,8 +220,6 @@ export function ColorThemeDropdownSidebar({ }: { children: React.ReactNode; }) { - const { isMobile } = useSidebar(); - return ( diff --git a/src/components/releases/ReleaseNotifierClient.tsx b/src/components/releases/ReleaseNotifierClient.tsx index 973d1c1..a311c7e 100644 --- a/src/components/releases/ReleaseNotifierClient.tsx +++ b/src/components/releases/ReleaseNotifierClient.tsx @@ -14,6 +14,8 @@ export function ReleaseNotifierClient({ slug }: { slug: string | undefined }) { const lastViewedSlug = window.localStorage.getItem(RELEASE_SLUG_KEY); if (lastViewedSlug !== slug) { + window.localStorage.setItem(RELEASE_SLUG_KEY, slug); + const toastId = toast( "There have been improvements to Serial since your last visit! Check out the release notes.", { @@ -22,7 +24,6 @@ export function ReleaseNotifierClient({ slug }: { slug: string | undefined }) { + +
+
+ + + ); +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index dbce835..7560e31 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,18 +1,18 @@ -"use client" +"use client"; -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { Cross2Icon } from "@radix-ui/react-icons" +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { Cross2Icon } from "@radix-ui/react-icons"; +import * as React from "react"; -import { cn } from "~/lib/utils" +import { cn } from "~/lib/utils"; -const Dialog = DialogPrimitive.Root +const Dialog = DialogPrimitive.Root; -const DialogTrigger = DialogPrimitive.Trigger +const DialogTrigger = DialogPrimitive.Trigger; -const DialogPortal = DialogPrimitive.Portal +const DialogPortal = DialogPrimitive.Portal; -const DialogClose = DialogPrimitive.Close +const DialogClose = DialogPrimitive.Close; const DialogOverlay = React.forwardRef< React.ElementRef, @@ -21,13 +21,13 @@ const DialogOverlay = React.forwardRef< -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, @@ -38,20 +38,20 @@ const DialogContent = React.forwardRef< {children} - + Close -)) -DialogContent.displayName = DialogPrimitive.Content.displayName +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, @@ -60,12 +60,12 @@ const DialogHeader = ({
-) -DialogHeader.displayName = "DialogHeader" +); +DialogHeader.displayName = "DialogHeader"; const DialogFooter = ({ className, @@ -74,12 +74,12 @@ const DialogFooter = ({
-) -DialogFooter.displayName = "DialogFooter" +); +DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< React.ElementRef, @@ -88,13 +88,13 @@ const DialogTitle = React.forwardRef< -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< React.ElementRef, @@ -102,21 +102,21 @@ const DialogDescription = React.forwardRef< >(({ className, ...props }, ref) => ( -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; export { Dialog, - DialogPortal, - DialogOverlay, - DialogTrigger, DialogClose, DialogContent, - DialogHeader, + DialogDescription, DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, DialogTitle, - DialogDescription, -} + DialogTrigger, +}; diff --git a/src/lib/data/views/mutations.ts b/src/lib/data/views/mutations.ts index 37d034e..8854fff 100644 --- a/src/lib/data/views/mutations.ts +++ b/src/lib/data/views/mutations.ts @@ -15,3 +15,33 @@ export function useCreateViewMutation() { }), ); } + +export function useEditViewMutation() { + const api = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + api.views.edit.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: api.views.getAll.queryKey(), + }); + }, + }), + ); +} + +export function useDeleteViewMutation() { + const api = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + api.views.delete.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: api.views.getAll.queryKey(), + }); + }, + }), + ); +} diff --git a/src/server/api/routers/viewRouter.ts b/src/server/api/routers/viewRouter.ts index 748dbfd..fe18ff3 100644 --- a/src/server/api/routers/viewRouter.ts +++ b/src/server/api/routers/viewRouter.ts @@ -1,9 +1,11 @@ -import { asc, eq } from "drizzle-orm"; +import { and, asc, eq, notInArray } from "drizzle-orm"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { type ApplicationView, createViewSchema, + deleteViewSchema, + updateViewSchema, viewCategories, views, } from "~/server/db/schema"; @@ -39,6 +41,65 @@ export const viewRouter = createTRPCRouter({ ); }); }), + edit: protectedProcedure + .input(updateViewSchema) + .mutation(async ({ ctx, input }) => { + await ctx.db.transaction(async (tx) => { + const viewsResult = await tx + .update(views) + .set({ + name: input.name, + daysWindow: input.daysWindow, + readStatus: input.readStatus, + orientation: input.orientation, + placement: input.placement, + }) + .where( + and(eq(views.userId, ctx.auth!.user.id), eq(views.id, input.id)), + ) + .returning(); + + const view = viewsResult?.[0]; + + if (!input.categoryIds || !view) return; + + await tx + .delete(viewCategories) + .where( + and( + eq(viewCategories.viewId, view.id), + notInArray(viewCategories.categoryId, input.categoryIds), + ), + ); + + return await Promise.all( + input.categoryIds.map(async (categoryId) => { + await tx + .insert(viewCategories) + .values({ + viewId: view.id, + categoryId, + }) + .onConflictDoNothing(); + }), + ); + }); + }), + delete: protectedProcedure + .input(deleteViewSchema) + .mutation(async ({ ctx, input }) => { + await ctx.db.transaction(async (tx) => { + await tx + .delete(viewCategories) + .where(eq(viewCategories.viewId, input.id)); + + return await tx + .delete(views) + .where( + and(eq(views.id, input.id), eq(views.userId, ctx.auth!.user.id)), + ); + }); + }), getAll: protectedProcedure.query(async ({ ctx }) => { const viewsList = await ctx.db .select() diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index cc4d94b..e9108a8 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -8,7 +8,11 @@ import { index, primaryKey, } from "drizzle-orm/sqlite-core"; -import { createInsertSchema, createSelectSchema } from "drizzle-zod"; +import { + createInsertSchema, + createSelectSchema, + createUpdateSchema, +} from "drizzle-zod"; import { FEED_ITEM_ORIENTATION, feedItemOrientationSchema, @@ -231,3 +235,14 @@ export const createViewSchema = createInsertSchema(views).merge( categoryIds: z.array(z.number()).optional(), }), ); + +export const updateViewSchema = createUpdateSchema(views).merge( + z.object({ + id: z.number(), + categoryIds: z.array(z.number()), + }), +); + +export const deleteViewSchema = z.object({ + id: z.number(), +}); From 9daf02a30e3825e4cc457db4fb5234533ee975fd Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Thu, 15 May 2025 23:45:01 -0400 Subject: [PATCH 06/35] better onboarding --- src/app/(feed)/feed/SidebarFeeds.tsx | 10 ++++- src/app/(feed)/feed/TodayItems.tsx | 63 +++++++++++++++++++++++++++- src/components/ui/button.tsx | 2 +- src/lib/data/feed-items/index.ts | 8 ++++ src/lib/data/views/index.ts | 4 +- 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/app/(feed)/feed/SidebarFeeds.tsx b/src/app/(feed)/feed/SidebarFeeds.tsx index f11ef66..9bf61cc 100644 --- a/src/app/(feed)/feed/SidebarFeeds.tsx +++ b/src/app/(feed)/feed/SidebarFeeds.tsx @@ -71,6 +71,8 @@ export function SidebarFeeds() { const setDateFilter = useSetAtom(dateFilterAtom); const [feedFilter, setFeedFilter] = useAtom(feedFilterAtom); + const categoryFilter = useAtomValue(categoryFilterAtom); + const viewFilter = useAtomValue(viewFilterAtom); const deselectViewFilter = useDeselectViewFilter(); const checkFilteredFeedItemsForFeed = useCheckFilteredFeedItemsForFeed(); @@ -107,7 +109,9 @@ export function SidebarFeeds() { variant={feedFilter === -1 ? "outline" : "default"} onClick={() => { setFeedFilter(-1); - setDateFilter(1); + if (!viewFilter && categoryFilter < 0) { + setDateFilter(1); + } }} > {!hasAnyItems && ( @@ -129,7 +133,9 @@ export function SidebarFeeds() { onClick={() => { setFeedFilter(feed.id); setDateFilter(30); - deselectViewFilter(); + if (!feed.hasEntries) { + deselectViewFilter(); + } }} > {!feed.hasEntries && ( diff --git a/src/app/(feed)/feed/TodayItems.tsx b/src/app/(feed)/feed/TodayItems.tsx index a202ac7..28dfe9a 100644 --- a/src/app/(feed)/feed/TodayItems.tsx +++ b/src/app/(feed)/feed/TodayItems.tsx @@ -7,10 +7,10 @@ import { CheckIcon, ClockIcon, EyeIcon, + ImportIcon, PlusIcon, SproutIcon, } from "lucide-react"; -import Link from "next/link"; import FeedLoading from "~/app/loading"; import { Button } from "~/components/ui/button"; import { @@ -27,6 +27,14 @@ import { import { useFeeds } from "~/lib/data/feeds"; import { useDialogStore } from "./dialogStore"; import { useViews } from "~/lib/data/views"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import Link from "next/link"; function timeAgo(date: string | Date) { const diff = dayjs().diff(date); @@ -66,6 +74,59 @@ function TodayItemsEmptyState() { function TodayItemsFeedEmptyState() { const launchDialog = useDialogStore((store) => store.launchDialog); + return ( + <> +
+

Welcome to Serial!

+

There are a couple ways to get started:

+
+
+ + + Add feeds manually + + Add one or more feeds by +
    +
  • YouTube Channel URL
  • +
  • RSS Feed URL
  • +
+
+
+ + + +
+ + + Import feeds from elsewhere + + Serial supports importing from +
    +
  • + Google Takeout (subscriptions.csv) +
  • +
  • + Other RSS readers (.opml) +
  • +
+
+
+ + + +
+
+ + ); + return ( -
-
- ))} - - {/*
-

Categories

- -
-
- {categories - ?.toSorted((a, b) => { - return a.name.localeCompare(b.name); - }) - .map((category) => ( -
-

{category.name}

-
- - -
-
- ))} -
*/} - - ); -} diff --git a/src/components/AddFeedDialog.tsx b/src/components/AddFeedDialog.tsx index e34d963..320b2b8 100644 --- a/src/components/AddFeedDialog.tsx +++ b/src/components/AddFeedDialog.tsx @@ -2,12 +2,16 @@ import { DialogTitle } from "@radix-ui/react-dialog"; import { ImportIcon } from "lucide-react"; import Link from "next/link"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { useDialogStore } from "~/app/(feed)/feed/dialogStore"; import { useContentCategories } from "~/lib/data/content-categories"; import { useCreateContentCategoryMutation } from "~/lib/data/content-categories/mutations"; -import { useCreateFeedMutation } from "~/lib/data/feeds/mutations"; +import { + useCreateFeedMutation, + useDeleteFeedMutation, + useEditFeedMutation, +} from "~/lib/data/feeds/mutations"; import { validateFeedUrl } from "~/server/rss/validateFeedUrl"; import { useTRPC } from "~/trpc/react"; import { Button } from "./ui/button"; @@ -15,6 +19,9 @@ import { Combobox } from "./ui/combobox"; import { Dialog, DialogContent, DialogHeader } from "./ui/dialog"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; +import { useFeeds } from "~/lib/data/feeds"; +import { useFeedCategories } from "~/lib/data/feed-categories"; +import { ViewCategoriesInput } from "./AddViewDialog"; export function AddFeedDialog() { const trpc = useTRPC(); @@ -136,3 +143,110 @@ export function AddFeedDialog() { ); } + +export function EditFeedDialog({ + selectedFeedId, + onClose, +}: { + selectedFeedId: null | number; + onClose: () => void; +}) { + const [isUpdatingFeed, setIsUpdatingFeed] = useState(false); + const [isDeletingFeed, setIsDeletingFeed] = useState(false); + + const { mutateAsync: editFeed } = useEditFeedMutation(); + const { mutateAsync: deleteFeed } = useDeleteFeedMutation(); + + const [name, setName] = useState(""); + const [selectedCategories, setSelectedCategories] = useState([]); + + const isFormDisabled = !name; + + const { feeds } = useFeeds(); + const { feedCategories } = useFeedCategories(); + + useEffect(() => { + if (!feeds || !selectedFeedId) return; + + const feed = feeds.find((v) => v.id === selectedFeedId); + if (!feed) return; + + const _feedCategories = feedCategories + .filter((category) => category.feedId === feed.id) + .map((category) => category.categoryId) + .filter((id) => typeof id === "number"); + + setName(feed.name); + setSelectedCategories(_feedCategories); + }, [feedCategories, selectedFeedId]); + + return ( + + + + + Edit Feed{" "} + + +
+
+ + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/components/AddViewDialog.tsx b/src/components/AddViewDialog.tsx index baf9e99..8c536cd 100644 --- a/src/components/AddViewDialog.tsx +++ b/src/components/AddViewDialog.tsx @@ -117,7 +117,7 @@ function ViewReadStatusInput({ ); } -function ViewCategoriesInput({ +export function ViewCategoriesInput({ selectedCategories, setSelectedCategories, }: { diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index b345000..1320c9e 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -46,7 +46,6 @@ export function AppLeftSidebar() {
- diff --git a/src/lib/data/feeds/mutations.ts b/src/lib/data/feeds/mutations.ts index 3d44fb3..61490c2 100644 --- a/src/lib/data/feeds/mutations.ts +++ b/src/lib/data/feeds/mutations.ts @@ -100,3 +100,18 @@ export function useDeleteFeedMutation() { }), ); } + +export function useEditFeedMutation() { + const api = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + api.feeds.update.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: api.feedCategories.getAll.queryKey(), + }); + }, + }), + ); +} diff --git a/src/lib/data/views/mutations.ts b/src/lib/data/views/mutations.ts index 8854fff..f10f700 100644 --- a/src/lib/data/views/mutations.ts +++ b/src/lib/data/views/mutations.ts @@ -21,7 +21,7 @@ export function useEditViewMutation() { const queryClient = useQueryClient(); return useMutation( - api.views.edit.mutationOptions({ + api.views.update.mutationOptions({ onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: api.views.getAll.queryKey(), diff --git a/src/server/api/routers/feedRouter.ts b/src/server/api/routers/feedRouter.ts index d930f72..dc645ed 100644 --- a/src/server/api/routers/feedRouter.ts +++ b/src/server/api/routers/feedRouter.ts @@ -1,5 +1,5 @@ import type { inferRouterOutputs } from "@trpc/server"; -import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, notInArray, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; @@ -215,21 +215,38 @@ export const feedRouter = createTRPCRouter({ }); return feedsList; - - // const feedIds = feedsList.map((feed) => feed.id); - - // const feedCategoriesList = await ctx.db - // .select() - // .from(feedCategories) - // .where(inArray(feedCategories.feedId, feedIds)); - - // return feedsList.map((feed) => ({ - // ...feed, - // categories: feedCategoriesList.filter( - // (feedCategory) => feedCategory.feedId === feed.id, - // ), - // })); }), + update: protectedProcedure + .input( + z.object({ + feedId: z.number(), + categoryIds: z.number().array(), + }), + ) + .mutation(async ({ ctx, input }) => { + await ctx.db.transaction(async (tx) => { + await tx + .delete(feedCategories) + .where( + and( + eq(feedCategories.feedId, input.feedId), + notInArray(feedCategories.categoryId, input.categoryIds), + ), + ); + + return await Promise.all( + input.categoryIds.map(async (categoryId) => { + await tx + .insert(feedCategories) + .values({ + feedId: input.feedId, + categoryId, + }) + .onConflictDoNothing(); + }), + ); + }); + }), }); export type FeedRouter = inferRouterOutputs; diff --git a/src/server/api/routers/viewRouter.ts b/src/server/api/routers/viewRouter.ts index fe18ff3..935a6cc 100644 --- a/src/server/api/routers/viewRouter.ts +++ b/src/server/api/routers/viewRouter.ts @@ -41,7 +41,7 @@ export const viewRouter = createTRPCRouter({ ); }); }), - edit: protectedProcedure + update: protectedProcedure .input(updateViewSchema) .mutation(async ({ ctx, input }) => { await ctx.db.transaction(async (tx) => { From 7cd34d062b64585e2f474d79824837b08dda75a8 Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Fri, 16 May 2025 18:15:13 -0400 Subject: [PATCH 09/35] better feed organization, add searching --- src/app/(feed)/feed/SidebarFeeds.tsx | 156 ++++++++++++++++++++++++--- 1 file changed, 144 insertions(+), 12 deletions(-) diff --git a/src/app/(feed)/feed/SidebarFeeds.tsx b/src/app/(feed)/feed/SidebarFeeds.tsx index 6371d21..ae55544 100644 --- a/src/app/(feed)/feed/SidebarFeeds.tsx +++ b/src/app/(feed)/feed/SidebarFeeds.tsx @@ -1,6 +1,6 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { CircleSmall, Edit2Icon, PlusIcon } from "lucide-react"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { SidebarGroup, SidebarGroupLabel, @@ -24,6 +24,7 @@ import { useDialogStore } from "./dialogStore"; import { useDeselectViewFilter } from "~/lib/data/views"; import { ButtonWithShortcut } from "~/components/ButtonWithShortcut"; import { EditFeedDialog } from "~/components/AddFeedDialog"; +import { Input } from "~/components/ui/input"; function useCheckFilteredFeedItemsForFeed() { const feedItemsOrder = useFeedItemsOrder(); @@ -66,7 +67,33 @@ function useCheckFilteredFeedItemsForFeed() { ); } +function useDebouncedState(defaultValue: string, delay: number) { + const [searchQuery, setSearchQuery] = useState(defaultValue); + const timeoutRef = useRef(null); + + const setDebouncedQuery = useCallback( + (newValue: string, forceUpdate: boolean = false) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + if (forceUpdate) { + setSearchQuery(newValue); + } else { + timeoutRef.current = setTimeout(() => { + setSearchQuery(newValue); + }, delay); + } + }, + [], + ); + + return [searchQuery, setDebouncedQuery] as const; +} + export function SidebarFeeds() { + const [searchQuery, setSearchQuery] = useDebouncedState("", 300); + const [selectedFeedForEditing, setSelectedFeedForEditing] = useState< null | number >(null); @@ -82,16 +109,74 @@ export function SidebarFeeds() { const checkFilteredFeedItemsForFeed = useCheckFilteredFeedItemsForFeed(); - const feedOptions = feeds - ?.toSorted((a, b) => a.name.localeCompare(b.name)) - ?.map((category) => ({ - ...category, - hasEntries: !!checkFilteredFeedItemsForFeed(category.id).length, - })) - ?.toSorted((a, b) => { - if (a.hasEntries && !b.hasEntries) return -1; - if (!a.hasEntries && b.hasEntries) return 1; - return 0; + // const feedOptions = feeds + // ?.map((category) => ({ + // ...category, + // hasEntries: !!checkFilteredFeedItemsForFeed(category.id).length, + // })) + // ?.toSorted((a, b) => { + // if (!!searchQuery) { + // const lowercaseQuery = searchQuery.toLowerCase(); + // const isLowercaseANameMatch = a.name + // .toLowerCase() + // .includes(lowercaseQuery); + // const isLowercaseBNameMatch = b.name + // .toLowerCase() + // .includes(lowercaseQuery); + + // if (isLowercaseANameMatch && !isLowercaseBNameMatch) return -1; + // if (!isLowercaseANameMatch && isLowercaseBNameMatch) return 1; + // } else { + // if (a.hasEntries && !b.hasEntries) return -1; + // if (!a.hasEntries && b.hasEntries) return 1; + // } + + // return a.name.localeCompare(b.name); + // }); + // + const feedOptions = feeds?.map((category) => ({ + ...category, + hasEntries: !!checkFilteredFeedItemsForFeed(category.id).length, + })); + + const preferredFeedOptions = feedOptions + ?.filter((feedOption) => { + if (!!searchQuery) { + const lowercaseQuery = searchQuery.toLowerCase(); + const lowercaseName = feedOption.name.toLowerCase(); + + if (lowercaseName.includes(lowercaseQuery)) { + return true; + } + } else { + if (feedOption.hasEntries) return true; + } + + if (feedOption.id === feedFilter) { + return true; + } + + return false; + }) + .toSorted((a, b) => { + if (a.id === feedFilter) { + return -1; + } + if (b.id === feedFilter) { + return 1; + } + + return a.name.localeCompare(b.name); + }); + + const otherFeedOptions = feedOptions + ?.filter((feedOption) => { + return !preferredFeedOptions.some( + (option) => option.id === feedOption.id, + ); + }) + .toSorted((a, b) => { + return a.name.localeCompare(b.name); }); const hasAnyItems = !!checkFilteredFeedItemsForFeed(-1).length; @@ -114,6 +199,17 @@ export function SidebarFeeds() { + + { + setSearchQuery(e.target.value, true); + }} + onChange={(e) => { + setSearchQuery(e.target.value); + }} + /> + - {feedOptions.map((feed, i) => { + {preferredFeedOptions.map((feed, i) => { + return ( + + { + setFeedFilter(feed.id); + setDateFilter(30); + if (!feed.hasEntries) { + deselectViewFilter(); + } + }} + > + {!feed.hasEntries && ( + + )} + {feed.hasEntries && ( +
+
+
+ )} +
{feed.name}
+ +
+ setSelectedFeedForEditing(feed.id)} + > + + +
+ + ); + })} + {!!preferredFeedOptions.length && !!otherFeedOptions.length && ( +
+ )} + {otherFeedOptions.map((feed, i) => { return ( Date: Fri, 16 May 2025 18:20:44 -0400 Subject: [PATCH 10/35] don't set date filter on feed select --- src/app/(feed)/feed/SidebarFeeds.tsx | 39 +++++----------------------- 1 file changed, 6 insertions(+), 33 deletions(-) diff --git a/src/app/(feed)/feed/SidebarFeeds.tsx b/src/app/(feed)/feed/SidebarFeeds.tsx index ae55544..c891340 100644 --- a/src/app/(feed)/feed/SidebarFeeds.tsx +++ b/src/app/(feed)/feed/SidebarFeeds.tsx @@ -1,6 +1,9 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { CircleSmall, Edit2Icon, PlusIcon } from "lucide-react"; import { useCallback, useRef, useState } from "react"; +import { EditFeedDialog } from "~/components/AddFeedDialog"; +import { ButtonWithShortcut } from "~/components/ButtonWithShortcut"; +import { Input } from "~/components/ui/input"; import { SidebarGroup, SidebarGroupLabel, @@ -20,11 +23,8 @@ import { import { useFeedCategories } from "~/lib/data/feed-categories"; import { doesFeedItemPassFilters } from "~/lib/data/feed-items"; import { useFeeds } from "~/lib/data/feeds"; -import { useDialogStore } from "./dialogStore"; import { useDeselectViewFilter } from "~/lib/data/views"; -import { ButtonWithShortcut } from "~/components/ButtonWithShortcut"; -import { EditFeedDialog } from "~/components/AddFeedDialog"; -import { Input } from "~/components/ui/input"; +import { useDialogStore } from "./dialogStore"; function useCheckFilteredFeedItemsForFeed() { const feedItemsOrder = useFeedItemsOrder(); @@ -109,31 +109,6 @@ export function SidebarFeeds() { const checkFilteredFeedItemsForFeed = useCheckFilteredFeedItemsForFeed(); - // const feedOptions = feeds - // ?.map((category) => ({ - // ...category, - // hasEntries: !!checkFilteredFeedItemsForFeed(category.id).length, - // })) - // ?.toSorted((a, b) => { - // if (!!searchQuery) { - // const lowercaseQuery = searchQuery.toLowerCase(); - // const isLowercaseANameMatch = a.name - // .toLowerCase() - // .includes(lowercaseQuery); - // const isLowercaseBNameMatch = b.name - // .toLowerCase() - // .includes(lowercaseQuery); - - // if (isLowercaseANameMatch && !isLowercaseBNameMatch) return -1; - // if (!isLowercaseANameMatch && isLowercaseBNameMatch) return 1; - // } else { - // if (a.hasEntries && !b.hasEntries) return -1; - // if (!a.hasEntries && b.hasEntries) return 1; - // } - - // return a.name.localeCompare(b.name); - // }); - // const feedOptions = feeds?.map((category) => ({ ...category, hasEntries: !!checkFilteredFeedItemsForFeed(category.id).length, @@ -231,14 +206,13 @@ export function SidebarFeeds() { All - {preferredFeedOptions.map((feed, i) => { + {preferredFeedOptions.map((feed) => { return ( { setFeedFilter(feed.id); - setDateFilter(30); if (!feed.hasEntries) { deselectViewFilter(); } @@ -267,14 +241,13 @@ export function SidebarFeeds() { {!!preferredFeedOptions.length && !!otherFeedOptions.length && (
)} - {otherFeedOptions.map((feed, i) => { + {otherFeedOptions.map((feed) => { return ( { setFeedFilter(feed.id); - setDateFilter(30); if (!feed.hasEntries) { deselectViewFilter(); } From 4dd5f21b8250f4a6ac4f0d6da860382bafe4040b Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Fri, 16 May 2025 18:23:42 -0400 Subject: [PATCH 11/35] better view highlighting --- src/lib/data/views/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/data/views/index.ts b/src/lib/data/views/index.ts index bbdfd78..12f59c0 100644 --- a/src/lib/data/views/index.ts +++ b/src/lib/data/views/index.ts @@ -77,7 +77,7 @@ export function useCheckFilteredFeedItemsForView() { feedItemsMap[item] && doesFeedItemPassFilters( feedItemsMap[item], - dateFilter, + viewFilter?.daysWindow ?? 1, visibilityFilter, -1, feedCategories, From f9f21fcba2a48227cfcb16f943c3f731cdea540d Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Fri, 16 May 2025 18:27:05 -0400 Subject: [PATCH 12/35] wait on read status for now, just use Unread --- src/components/AddViewDialog.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/AddViewDialog.tsx b/src/components/AddViewDialog.tsx index 8c536cd..1d98588 100644 --- a/src/components/AddViewDialog.tsx +++ b/src/components/AddViewDialog.tsx @@ -190,10 +190,11 @@ export function AddViewDialog() { daysWindow={daysTimeWindow} setDaysWindow={setDaysTimeWindow} /> - + /> */} - + /> */} Date: Fri, 16 May 2025 19:22:46 -0400 Subject: [PATCH 13/35] allow view sorting --- package.json | 4 + pnpm-lock.yaml | 72 ++++++++++ src/app/(feed)/feed/SidebarViews.tsx | 204 ++++++++++++++++++++++----- src/lib/data/views/index.ts | 6 +- src/lib/data/views/mutations.ts | 27 ++++ src/lib/data/views/utils.ts | 13 ++ src/server/api/routers/viewRouter.ts | 31 +++- 7 files changed, 317 insertions(+), 40 deletions(-) create mode 100644 src/lib/data/views/utils.ts diff --git a/package.json b/package.json index 4c95120..5896689 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "start": "next start" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@formkit/auto-animate": "^0.8.2", "@libsql/client": "0.14.0", "@libsql/linux-x64-musl": "0.5.0-pre.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c5ec77..25e7631 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,18 @@ importers: .: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.1.0) '@formkit/auto-animate': specifier: ^0.8.2 version: 0.8.2 @@ -816,6 +828,34 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -7583,6 +7623,38 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@dnd-kit/accessibility@3.1.1(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tslib: 2.8.1 + + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.4.1': diff --git a/src/app/(feed)/feed/SidebarViews.tsx b/src/app/(feed)/feed/SidebarViews.tsx index 59bee29..5657954 100644 --- a/src/app/(feed)/feed/SidebarViews.tsx +++ b/src/app/(feed)/feed/SidebarViews.tsx @@ -1,7 +1,14 @@ "use client"; -import { useCallback, useState } from "react"; +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useState, +} from "react"; +import { DragHandleDots2Icon } from "@radix-ui/react-icons"; import { useAtom, useAtomValue } from "jotai"; import { CircleSmall, Edit2Icon, PlusIcon } from "lucide-react"; import { EditViewDialog } from "~/components/AddViewDialog"; @@ -25,6 +32,34 @@ import { useFeeds } from "~/lib/data/feeds"; import { useUpdateViewFilter, useViews } from "~/lib/data/views"; import { useDialogStore } from "./dialogStore"; +import { + closestCenter, + DndContext, + DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + restrictToParentElement, + restrictToVerticalAxis, +} from "@dnd-kit/modifiers"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +import { ApplicationView } from "~/server/db/schema"; +import { + calculateViewsPlacement, + useUpdateViewsPlacementMutation, +} from "~/lib/data/views/mutations"; + export function useCheckFilteredFeedItemsForView() { const feedItemsOrder = useFeedItemsOrder(); const feedItemsMap = useFeedItemsMap(); @@ -65,23 +100,126 @@ export function useCheckFilteredFeedItemsForView() { ); } +type ViewOption = ApplicationView & { hasEntries: boolean }; + +function ViewSidebarItem({ + view, + setSelectedViewForEditing, +}: { + view: ViewOption; + setSelectedViewForEditing: Dispatch>; +}) { + const updateViewFilter = useUpdateViewFilter(); + const [viewFilter] = useAtom(viewFilterIdAtom); + + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id: view.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ + updateViewFilter(view.id)} + > + {!view.hasEntries && } + {view.hasEntries && ( +
+
+
+ )} + {view.name} + + {!view.isDefault && ( +
+ setSelectedViewForEditing(view.id)} + > + + +
+ )} + {!view.isDefault && ( +
+ +
+ )} + {view.isDefault && ( +
+ +
+ )} + +
+ ); +} + export function SidebarViews() { const [selectedViewForEditing, setSelectedViewForEditing] = useState< null | number >(null); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + const launchDialog = useDialogStore((store) => store.launchDialog); const checkFilteredFeedItemsForView = useCheckFilteredFeedItemsForView(); - const updateViewFilter = useUpdateViewFilter(); - const [viewFilter] = useAtom(viewFilterIdAtom); + const { views, setViews } = useViews(); - const { views } = useViews(); + const [viewOptions, setViewOptions] = useState([]); + + const { mutateAsync: updateViewsPlacement } = + useUpdateViewsPlacementMutation(); + + useEffect(() => { + setViewOptions( + views?.map((view) => ({ + ...view, + hasEntries: !!checkFilteredFeedItemsForView(view.id).length, + })), + ); + }, [views]); + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + + if (!!over && active.id !== over?.id) { + setViewOptions((options) => { + const oldIndex = options.findIndex((view) => view.id === active.id); + const newIndex = options.findIndex((view) => view.id === over.id); + + const updatedOptions = arrayMove(options, oldIndex, newIndex); + + const updatedViews = calculateViewsPlacement( + updatedOptions.map((option) => { + const { hasEntries, ...restOfOption } = option; + return restOfOption; + }), + ); + + setViews(updatedViews); + updateViewsPlacement({ views: updatedViews }); - const viewOptions = views?.map((view) => ({ - ...view, - hasEntries: !!checkFilteredFeedItemsForView(view.id).length, - })); + return updatedOptions; + }); + } + } return ( <> @@ -99,35 +237,27 @@ export function SidebarViews() {
- {viewOptions?.map((option) => { - return ( - - updateViewFilter(option.id)} - > - {!option.hasEntries && ( - - )} - {option.hasEntries && ( -
-
-
- )} - {option.name} - - {!option.isDefault && ( -
- setSelectedViewForEditing(option.id)} - > - - -
- )} - - ); - })} + + + {viewOptions?.map((option) => { + return ( + + ); + })} + + diff --git a/src/lib/data/views/index.ts b/src/lib/data/views/index.ts index 12f59c0..3a64216 100644 --- a/src/lib/data/views/index.ts +++ b/src/lib/data/views/index.ts @@ -21,8 +21,10 @@ import { useContentCategories } from "../content-categories"; import { useFeedCategories } from "../feed-categories"; import { useFeeds } from "../feeds"; import { doesFeedItemPassFilters } from "../feed-items"; +import { sortViewsByPlacement } from "./utils"; export const INBOX_VIEW_ID = -1; +export const INBOX_VIEW_PLACEMENT = -1; export function useDeselectViewFilter() { const setViewFilter = useSetAtom(viewFilterIdAtom); @@ -134,7 +136,7 @@ export function useViewsQuery() { daysWindow: 7, orientation: FEED_ITEM_ORIENTATION.HORIZONTAL, readStatus: VIEW_READ_STATUS.UNREAD, - placement: -1, + placement: INBOX_VIEW_PLACEMENT, userId: data?.user.id ?? "", createdAt: now, updatedAt: now, @@ -142,7 +144,7 @@ export function useViewsQuery() { isDefault: true, }; - return [...customViews, inboxView]; + return sortViewsByPlacement([...customViews, inboxView]); }, [query.data]); useEffect(() => { diff --git a/src/lib/data/views/mutations.ts b/src/lib/data/views/mutations.ts index f10f700..3f646c0 100644 --- a/src/lib/data/views/mutations.ts +++ b/src/lib/data/views/mutations.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { ApplicationView } from "~/server/db/schema"; import { useTRPC } from "~/trpc/react"; +import { INBOX_VIEW_ID, INBOX_VIEW_PLACEMENT } from "."; export function useCreateViewMutation() { const api = useTRPC(); @@ -45,3 +47,28 @@ export function useDeleteViewMutation() { }), ); } + +export function calculateViewsPlacement(views: ApplicationView[]) { + const inboxIndex = views.findIndex((view) => view.id === INBOX_VIEW_ID); + if (inboxIndex === -1) return views; + + return views.map((view, viewIndex) => ({ + ...view, + placement: inboxIndex - viewIndex + INBOX_VIEW_PLACEMENT, + })); +} + +export function useUpdateViewsPlacementMutation() { + const api = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + api.views.updatePlacement.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: api.views.getAll.queryKey(), + }); + }, + }), + ); +} diff --git a/src/lib/data/views/utils.ts b/src/lib/data/views/utils.ts new file mode 100644 index 0000000..9f66052 --- /dev/null +++ b/src/lib/data/views/utils.ts @@ -0,0 +1,13 @@ +import { type DatabaseView } from "~/server/db/schema"; + +export function sortViewsByPlacement(views: DatabaseView[]) { + return views.toSorted((a, b) => { + if (a.placement < b.placement) { + return 1; + } + if (a.placement > b.placement) { + return -1; + } + return 0; + }); +} diff --git a/src/server/api/routers/viewRouter.ts b/src/server/api/routers/viewRouter.ts index 935a6cc..60d36eb 100644 --- a/src/server/api/routers/viewRouter.ts +++ b/src/server/api/routers/viewRouter.ts @@ -1,4 +1,6 @@ import { and, asc, eq, notInArray } from "drizzle-orm"; +import { z } from "zod"; +import { sortViewsByPlacement } from "~/lib/data/views/utils"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { @@ -85,6 +87,33 @@ export const viewRouter = createTRPCRouter({ ); }); }), + updatePlacement: protectedProcedure + .input( + z.object({ + views: z.array( + z.object({ + id: z.number(), + placement: z.number(), + }), + ), + }), + ) + .mutation(async ({ ctx, input }) => { + await ctx.db.transaction(async (tx) => { + return await Promise.all( + input.views.map(async (view) => { + return await tx + .update(views) + .set({ + placement: view.placement, + }) + .where( + and(eq(views.id, view.id), eq(views.userId, ctx.auth!.user.id)), + ); + }), + ); + }); + }), delete: protectedProcedure .input(deleteViewSchema) .mutation(async ({ ctx, input }) => { @@ -122,6 +151,6 @@ export const viewRouter = createTRPCRouter({ return applicationView; }); - return zippedViews; + return sortViewsByPlacement(zippedViews); }), }); From 857e8740fb0ebe925f055d8878cec1a0ad96baf7 Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Fri, 16 May 2025 19:28:13 -0400 Subject: [PATCH 14/35] fix lint errors --- src/app/(feed)/feed/SidebarFeeds.tsx | 2 +- src/app/(feed)/feed/SidebarViews.tsx | 2 +- src/lib/data/views/utils.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/(feed)/feed/SidebarFeeds.tsx b/src/app/(feed)/feed/SidebarFeeds.tsx index c891340..03c70ef 100644 --- a/src/app/(feed)/feed/SidebarFeeds.tsx +++ b/src/app/(feed)/feed/SidebarFeeds.tsx @@ -72,7 +72,7 @@ function useDebouncedState(defaultValue: string, delay: number) { const timeoutRef = useRef(null); const setDebouncedQuery = useCallback( - (newValue: string, forceUpdate: boolean = false) => { + (newValue: string, forceUpdate = false) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } diff --git a/src/app/(feed)/feed/SidebarViews.tsx b/src/app/(feed)/feed/SidebarViews.tsx index 5657954..06da424 100644 --- a/src/app/(feed)/feed/SidebarViews.tsx +++ b/src/app/(feed)/feed/SidebarViews.tsx @@ -214,7 +214,7 @@ export function SidebarViews() { ); setViews(updatedViews); - updateViewsPlacement({ views: updatedViews }); + void updateViewsPlacement({ views: updatedViews }); return updatedOptions; }); diff --git a/src/lib/data/views/utils.ts b/src/lib/data/views/utils.ts index 9f66052..00b23e4 100644 --- a/src/lib/data/views/utils.ts +++ b/src/lib/data/views/utils.ts @@ -1,6 +1,6 @@ import { type DatabaseView } from "~/server/db/schema"; -export function sortViewsByPlacement(views: DatabaseView[]) { +export function sortViewsByPlacement(views: T[]) { return views.toSorted((a, b) => { if (a.placement < b.placement) { return 1; From 38f9f50fd4e36c3973847cbfb30706caeadf24c6 Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Fri, 16 May 2025 19:41:58 -0400 Subject: [PATCH 15/35] fix lint issues --- src/app/(feed)/feed/SidebarFeeds.tsx | 3 +- src/app/(feed)/feed/SidebarViews.tsx | 13 +- src/app/(feed)/feed/TodayItems.tsx | 20 ++- src/app/(feed)/feed/UserManagementButton.tsx | 11 +- .../(feed)/feed/edit/FeedCategoryCombobox.tsx | 114 --------------- .../(feed)/feed/edit/FeedCategoryEditor.tsx | 136 ------------------ .../import/opml/OPMLSubscriptionImport.tsx | 2 +- .../YouTubeSubscriptionImportCarousel.tsx | 1 + src/app/(feed)/layout.tsx | 17 ++- src/app/(markdown)/layout.tsx | 7 - src/app/(markdown)/releases/page.tsx | 6 +- src/app/auth/AuthHeader.tsx | 6 +- src/app/auth/reset/AuthResetPageComponent.tsx | 14 +- src/app/auth/sign-in.tsx | 4 +- src/app/auth/sign-up.tsx | 9 -- src/components/AddFeedDialog.tsx | 10 +- src/components/AddViewDialog.tsx | 66 ++++----- src/components/ButtonWithShortcut.tsx | 3 +- src/components/ColorModeToggle.tsx | 19 +-- .../useYouTubeVideoShortcuts.tsx | 18 ++- src/components/ImportFeedButton.tsx | 17 --- src/components/KeyboardProvider.tsx | 11 +- src/components/LeftSidebarBottomNav.tsx | 19 +-- src/components/LeftSidebarMain.tsx | 46 ------ src/components/ResponsiveVideo.tsx | 2 +- src/components/app-sidebar.tsx | 1 - src/components/ui/responsive-dropdown.tsx | 2 +- src/lib/data/atoms.ts | 7 +- src/lib/data/feed-items/index.ts | 10 +- src/lib/data/views/index.ts | 17 ++- src/lib/data/views/mutations.ts | 2 +- 31 files changed, 131 insertions(+), 482 deletions(-) delete mode 100644 src/app/(feed)/feed/edit/FeedCategoryCombobox.tsx delete mode 100644 src/app/(feed)/feed/edit/FeedCategoryEditor.tsx delete mode 100644 src/components/ImportFeedButton.tsx delete mode 100644 src/components/LeftSidebarMain.tsx diff --git a/src/app/(feed)/feed/SidebarFeeds.tsx b/src/app/(feed)/feed/SidebarFeeds.tsx index 03c70ef..61f71a7 100644 --- a/src/app/(feed)/feed/SidebarFeeds.tsx +++ b/src/app/(feed)/feed/SidebarFeeds.tsx @@ -63,6 +63,7 @@ function useCheckFilteredFeedItemsForFeed() { categoryFilter, feedCategories, feeds, + viewFilter, ], ); } @@ -85,7 +86,7 @@ function useDebouncedState(defaultValue: string, delay: number) { }, delay); } }, - [], + [delay], ); return [searchQuery, setDebouncedQuery] as const; diff --git a/src/app/(feed)/feed/SidebarViews.tsx b/src/app/(feed)/feed/SidebarViews.tsx index 06da424..f879a1f 100644 --- a/src/app/(feed)/feed/SidebarViews.tsx +++ b/src/app/(feed)/feed/SidebarViews.tsx @@ -1,8 +1,8 @@ "use client"; import { - Dispatch, - SetStateAction, + type Dispatch, + type SetStateAction, useCallback, useEffect, useState, @@ -35,7 +35,7 @@ import { useDialogStore } from "./dialogStore"; import { closestCenter, DndContext, - DragEndEvent, + type DragEndEvent, KeyboardSensor, PointerSensor, useSensor, @@ -54,11 +54,11 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { ApplicationView } from "~/server/db/schema"; import { calculateViewsPlacement, useUpdateViewsPlacementMutation, } from "~/lib/data/views/mutations"; +import type { ApplicationView } from "~/server/db/schema"; export function useCheckFilteredFeedItemsForView() { const feedItemsOrder = useFeedItemsOrder(); @@ -96,6 +96,8 @@ export function useCheckFilteredFeedItemsForView() { dateFilter, visibilityFilter, feedCategories, + feeds, + views, ], ); } @@ -194,7 +196,7 @@ export function SidebarViews() { hasEntries: !!checkFilteredFeedItemsForView(view.id).length, })), ); - }, [views]); + }, [views, checkFilteredFeedItemsForView]); function handleDragEnd(event: DragEndEvent) { const { active, over } = event; @@ -208,6 +210,7 @@ export function SidebarViews() { const updatedViews = calculateViewsPlacement( updatedOptions.map((option) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { hasEntries, ...restOfOption } = option; return restOfOption; }), diff --git a/src/app/(feed)/feed/TodayItems.tsx b/src/app/(feed)/feed/TodayItems.tsx index 28dfe9a..4172877 100644 --- a/src/app/(feed)/feed/TodayItems.tsx +++ b/src/app/(feed)/feed/TodayItems.tsx @@ -11,11 +11,18 @@ import { PlusIcon, SproutIcon, } from "lucide-react"; +import Link from "next/link"; import FeedLoading from "~/app/loading"; import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; import { useFeedItemGlobalState, - useFeedItemsOrder, useHasFetchedFeedItems, } from "~/lib/data/atoms"; import { useFeedCategories } from "~/lib/data/feed-categories"; @@ -25,16 +32,8 @@ import { useFeedItemsSetWatchLaterValueMutation, } from "~/lib/data/feed-items/mutations"; import { useFeeds } from "~/lib/data/feeds"; -import { useDialogStore } from "./dialogStore"; import { useViews } from "~/lib/data/views"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card"; -import Link from "next/link"; +import { useDialogStore } from "./dialogStore"; function timeAgo(date: string | Date) { const diff = dayjs().diff(date); @@ -247,7 +246,6 @@ export function TodayItems() { const { hasFetchedFeedCategories } = useFeedCategories(); const { views } = useViews(); const hasFetchedFeedItems = useHasFetchedFeedItems(); - const feedItemsOrder = useFeedItemsOrder(); const filteredFeedItemsOrder = useFilteredFeedItemsOrder(); diff --git a/src/app/(feed)/feed/UserManagementButton.tsx b/src/app/(feed)/feed/UserManagementButton.tsx index 39c2509..c6ee20c 100644 --- a/src/app/(feed)/feed/UserManagementButton.tsx +++ b/src/app/(feed)/feed/UserManagementButton.tsx @@ -1,16 +1,9 @@ "use client"; -import { Loader2Icon, ExpandIcon, EllipsisVerticalIcon } from "lucide-react"; +import { EllipsisVerticalIcon, Loader2Icon } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { Button } from "~/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, -} from "~/components/ui/dropdown-menu"; import { ResponsiveDropdown, ResponsiveDropdownLabel, @@ -20,7 +13,6 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, - useSidebar, } from "~/components/ui/sidebar"; import { authClient, signOut } from "~/lib/auth-client"; import { AUTH_SIGNED_OUT_URL } from "~/server/auth/constants"; @@ -33,7 +25,6 @@ export function UserManagementNavItem() { const router = useRouter(); const [isSigningOut, setIsSigningOut] = useState(false); - const { isMobile } = useSidebar(); return ( diff --git a/src/app/(feed)/feed/edit/FeedCategoryCombobox.tsx b/src/app/(feed)/feed/edit/FeedCategoryCombobox.tsx deleted file mode 100644 index 8d23d60..0000000 --- a/src/app/(feed)/feed/edit/FeedCategoryCombobox.tsx +++ /dev/null @@ -1,114 +0,0 @@ -"use client"; - -import { ListFilterPlusIcon } from "lucide-react"; -import * as React from "react"; - -import { useState } from "react"; -import { Button } from "~/components/ui/button"; -import { - Command, - CommandGroup, - CommandInput, - CommandItem, -} from "~/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "~/components/ui/popover"; - -type FeedCategoryComboboxProps = { - options: - | { - value: T; - label: string; - }[] - | undefined; - onSelect: (value: T) => void; - onAddOption?: (value: T) => void; - disabled: boolean; -}; -export function FeedCategoryCombobox({ - options, - onSelect, - onAddOption, - disabled, -}: FeedCategoryComboboxProps) { - const [open, setOpen] = useState(false); - const [localValue, setLocalValue] = useState(""); - const triggerRef = React.useRef(null); - - const computedWidth = 200; - - if (!options) return null; - - const doesLocalValueMatchOption = options.some( - (option) => option.label === localValue, - ); - - return ( - - - - - - - { - setLocalValue(updatedValue); - }} - /> - - {options - .sort((a, b) => { - if (a.label < b.label) return -1; - if (a.label > b.label) return 1; - return 0; - }) - .map((option) => ( - { - onSelect(currentValue as T); - setOpen(false); - }} - className="data-disabled:pointer-events-auto data-disabled:opacity-100" - > - {option.label} - - ))} - {/* {!!localValue && !doesLocalValueMatchOption && ( - { - onSelect(currentValue as T); - setOpen(false); - }} - className="data-disabled:pointer-events-auto data-disabled:opacity-100" - > - + Add "{localValue}" - - )} */} - - - - - ); -} diff --git a/src/app/(feed)/feed/edit/FeedCategoryEditor.tsx b/src/app/(feed)/feed/edit/FeedCategoryEditor.tsx deleted file mode 100644 index f0a553b..0000000 --- a/src/app/(feed)/feed/edit/FeedCategoryEditor.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { PopoverTrigger } from "@radix-ui/react-popover"; -import { useState } from "react"; -import { Badge } from "~/components/ui/badge"; -import { Button } from "~/components/ui/button"; -import { Popover, PopoverContent } from "~/components/ui/popover"; -import { - type DatabaseContentCategory, - type DatabaseFeed, - type DatabaseFeedCategory, -} from "~/server/db/schema"; -import { FeedCategoryCombobox } from "./FeedCategoryCombobox"; -import { - useAssignFeedCategoryMutation, - useRemoveFeedCategoryMutation, -} from "~/lib/data/feed-categories/mutations"; - -export function FeedCategoryEditor({ - feed, - feedCategories, - contentCategories, -}: { - feed: DatabaseFeed | undefined; - contentCategories: DatabaseContentCategory[] | undefined; - feedCategories: DatabaseFeedCategory[] | undefined; -}) { - const [deletePopoverCategory, setDeletePopoverCategory] = useState(-1); - - const { mutate: assignFeedCategory, isPending: isAssignFeedCategoryPending } = - useAssignFeedCategoryMutation(); - const { - mutateAsync: removeFeedCategory, - isPending: isRemoveFeedCategoryPending, - } = useRemoveFeedCategoryMutation(); - - const appliedContentCategories = feedCategories - ?.filter((feedCategory) => feedCategory.feedId === feed?.id) - .map((feedCategory) => - contentCategories?.find( - (contentCategory) => contentCategory.id === feedCategory.categoryId, - ), - ); - const appliedContentCategoryIds = appliedContentCategories?.map( - (category) => category?.id, - ); - - const dropdownOptions = contentCategories - ?.filter( - (contentCategory) => - !appliedContentCategoryIds?.includes(contentCategory.id), - ) - .map((contentCategory) => ({ - value: contentCategory.name, - label: contentCategory.name, - })); - - if (!feed) return null; - - return ( -
-
- {appliedContentCategories?.map((category) => ( - { - if (open) { - setDeletePopoverCategory(category?.id ?? -1); - } else { - setDeletePopoverCategory(-1); - } - }} - > - - - {category?.name} - - - -

- Would you like to remove the{" "} - {category?.name} category - from the feed {feed.name}? -

-
- - -
-
-
- ))} -
- { - const categoryId = contentCategories?.find( - (category) => category.name === categoryName, - )?.id; - - if (typeof categoryId === "number" && categoryId >= 0) { - assignFeedCategory({ - feedId: feed.id, - categoryId, - }); - } - }} - options={dropdownOptions} - disabled={isAssignFeedCategoryPending} - /> -
- ); -} diff --git a/src/app/(feed)/feed/import/opml/OPMLSubscriptionImport.tsx b/src/app/(feed)/feed/import/opml/OPMLSubscriptionImport.tsx index 99119c1..6ea15a7 100644 --- a/src/app/(feed)/feed/import/opml/OPMLSubscriptionImport.tsx +++ b/src/app/(feed)/feed/import/opml/OPMLSubscriptionImport.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { Button } from "~/components/ui/button"; -import { useFeeds, useFeedsQuery } from "~/lib/data/feeds"; +import { useFeeds } from "~/lib/data/feeds"; import { ImportDropzone } from "../ImportDropzone"; import { type SubscriptionImportMethodProps } from "../types"; import { parseOPMLSubscriptionInput } from "./parseOPMLSubscriptionInput"; diff --git a/src/app/(feed)/feed/import/youtube/YouTubeSubscriptionImportCarousel.tsx b/src/app/(feed)/feed/import/youtube/YouTubeSubscriptionImportCarousel.tsx index a64c315..2d5916d 100644 --- a/src/app/(feed)/feed/import/youtube/YouTubeSubscriptionImportCarousel.tsx +++ b/src/app/(feed)/feed/import/youtube/YouTubeSubscriptionImportCarousel.tsx @@ -84,6 +84,7 @@ export function YouTubeSubscriptionImportCarousel() { {`YouTube
diff --git a/src/app/(feed)/layout.tsx b/src/app/(feed)/layout.tsx index dba3a3d..af5a3ee 100644 --- a/src/app/(feed)/layout.tsx +++ b/src/app/(feed)/layout.tsx @@ -1,19 +1,18 @@ import "~/styles/globals.css"; import { type Metadata, type Viewport } from "next"; -import { KeyboardProvider } from "~/components/KeyboardProvider"; -import { ScrollArea } from "~/components/ui/scroll-area"; -import { AppDialogs } from "./feed/AppDialogs"; -import { Header } from "./feed/Header"; -import { ApplyColorTheme } from "~/components/color-theme/ApplyColorTheme"; +import { redirect } from "next/navigation"; import { Suspense } from "react"; +import { AppLeftSidebar, AppRightSidebar } from "~/components/app-sidebar"; +import { ApplyColorTheme } from "~/components/color-theme/ApplyColorTheme"; +import { KeyboardProvider } from "~/components/KeyboardProvider"; import { ReleaseNotifier } from "~/components/releases/ReleaseNotifier"; +import { SidebarInset, SidebarProvider } from "~/components/ui/sidebar"; import { InitialClientQueries } from "~/lib/data/InitialClientQueries"; -import FeedLoading from "../loading"; import { isServerAuthed } from "~/server/auth"; -import { redirect } from "next/navigation"; -import { SidebarInset, SidebarProvider } from "~/components/ui/sidebar"; -import { AppLeftSidebar, AppRightSidebar } from "~/components/app-sidebar"; +import FeedLoading from "../loading"; +import { AppDialogs } from "./feed/AppDialogs"; +import { Header } from "./feed/Header"; const title = "Serial"; const description = "Your personal content newsletter"; diff --git a/src/app/(markdown)/layout.tsx b/src/app/(markdown)/layout.tsx index 5732b1b..71ec167 100644 --- a/src/app/(markdown)/layout.tsx +++ b/src/app/(markdown)/layout.tsx @@ -1,14 +1,7 @@ import "~/styles/globals.css"; -import { Inter } from "next/font/google"; - import { type Metadata, type Viewport } from "next"; -const inter = Inter({ - subsets: ["latin"], - variable: "--font-sans", -}); - const title = "Serial"; const description = "Your personal content newsletter"; diff --git a/src/app/(markdown)/releases/page.tsx b/src/app/(markdown)/releases/page.tsx index f15d160..cc176c5 100644 --- a/src/app/(markdown)/releases/page.tsx +++ b/src/app/(markdown)/releases/page.tsx @@ -1,11 +1,7 @@ import Link from "next/link"; import { getReleasePages } from "~/lib/markdown/releases"; -export default async function Page({ - params, -}: { - params: Promise<{ slug: string }>; -}) { +export default async function Page() { const releases = await getReleasePages(); return ( diff --git a/src/app/auth/AuthHeader.tsx b/src/app/auth/AuthHeader.tsx index 6210116..2d81952 100644 --- a/src/app/auth/AuthHeader.tsx +++ b/src/app/auth/AuthHeader.tsx @@ -5,7 +5,11 @@ export function AuthHeader({ children }: PropsWithChildren) { return (
- + Serial icon
{children}
diff --git a/src/app/auth/reset/AuthResetPageComponent.tsx b/src/app/auth/reset/AuthResetPageComponent.tsx index 0287198..e3bd4e7 100644 --- a/src/app/auth/reset/AuthResetPageComponent.tsx +++ b/src/app/auth/reset/AuthResetPageComponent.tsx @@ -1,24 +1,24 @@ "use client"; -import { InfoIcon, Loader2, UserIcon } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { InfoIcon, Loader2 } from "lucide-react"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { type PropsWithChildren, useState } from "react"; +import { toast } from "sonner"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardFooter } from "~/components/ui/card"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; -import { authClient, resetPassword } from "~/lib/auth-client"; +import { authClient } from "~/lib/auth-client"; import { AUTH_PAGE_URL, AUTH_RESET_PASSWORD_URL, AUTH_SIGNED_OUT_URL, } from "~/server/auth/constants"; -import { AuthHeader } from "../AuthHeader"; -import { useSearchParams } from "next/navigation"; -import { toast } from "sonner"; -import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; -import { useQuery } from "@tanstack/react-query"; import { useTRPC } from "~/trpc/react"; +import { AuthHeader } from "../AuthHeader"; function AlertPane({ title, diff --git a/src/app/auth/sign-in.tsx b/src/app/auth/sign-in.tsx index ad077f1..6b467d1 100644 --- a/src/app/auth/sign-in.tsx +++ b/src/app/auth/sign-in.tsx @@ -97,10 +97,10 @@ export default function SignIn({ password, }, { - onRequest: (ctx) => { + onRequest: () => { setLoading(true); }, - onResponse: (ctx) => { + onResponse: () => { setLoading(false); }, onSuccess: async () => { diff --git a/src/app/auth/sign-up.tsx b/src/app/auth/sign-up.tsx index 1e11100..22b1ade 100644 --- a/src/app/auth/sign-up.tsx +++ b/src/app/auth/sign-up.tsx @@ -108,12 +108,3 @@ export default function SignUp() { ); } - -async function convertImageToBase64(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(file); - }); -} diff --git a/src/components/AddFeedDialog.tsx b/src/components/AddFeedDialog.tsx index 320b2b8..768c2c7 100644 --- a/src/components/AddFeedDialog.tsx +++ b/src/components/AddFeedDialog.tsx @@ -7,24 +7,22 @@ import { toast } from "sonner"; import { useDialogStore } from "~/app/(feed)/feed/dialogStore"; import { useContentCategories } from "~/lib/data/content-categories"; import { useCreateContentCategoryMutation } from "~/lib/data/content-categories/mutations"; +import { useFeedCategories } from "~/lib/data/feed-categories"; +import { useFeeds } from "~/lib/data/feeds"; import { useCreateFeedMutation, useDeleteFeedMutation, useEditFeedMutation, } from "~/lib/data/feeds/mutations"; import { validateFeedUrl } from "~/server/rss/validateFeedUrl"; -import { useTRPC } from "~/trpc/react"; +import { ViewCategoriesInput } from "./AddViewDialog"; import { Button } from "./ui/button"; import { Combobox } from "./ui/combobox"; import { Dialog, DialogContent, DialogHeader } from "./ui/dialog"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; -import { useFeeds } from "~/lib/data/feeds"; -import { useFeedCategories } from "~/lib/data/feed-categories"; -import { ViewCategoriesInput } from "./AddViewDialog"; export function AddFeedDialog() { - const trpc = useTRPC(); const [feedUrl, setFeedUrl] = useState(""); const [isAddingFeed, setIsAddingFeed] = useState(false); @@ -178,7 +176,7 @@ export function EditFeedDialog({ setName(feed.name); setSelectedCategories(_feedCategories); - }, [feedCategories, selectedFeedId]); + }, [feedCategories, selectedFeedId, feeds]); return ( diff --git a/src/components/AddViewDialog.tsx b/src/components/AddViewDialog.tsx index 1d98588..ba8fcfa 100644 --- a/src/components/AddViewDialog.tsx +++ b/src/components/AddViewDialog.tsx @@ -83,39 +83,39 @@ function ViewTimeInput({ ); } -function ViewReadStatusInput({ - readStatus, - setReadStatus, -}: { - readStatus: number; - setReadStatus: (status: number) => void; -}) { - return ( -
- - { - if (!value) return; - setReadStatus(parseInt(value)); - }} - size="sm" - className="w-fit" - > - - Unread - - - Watched - - - Any - - -
- ); -} +// function ViewReadStatusInput({ +// readStatus, +// setReadStatus, +// }: { +// readStatus: number; +// setReadStatus: (status: number) => void; +// }) { +// return ( +//
+// +// { +// if (!value) return; +// setReadStatus(parseInt(value)); +// }} +// size="sm" +// className="w-fit" +// > +// +// Unread +// +// +// Watched +// +// +// Any +// +// +//
+// ); +// } export function ViewCategoriesInput({ selectedCategories, diff --git a/src/components/ButtonWithShortcut.tsx b/src/components/ButtonWithShortcut.tsx index 92b2b6d..de5ee3d 100644 --- a/src/components/ButtonWithShortcut.tsx +++ b/src/components/ButtonWithShortcut.tsx @@ -1,8 +1,7 @@ "use client"; import { useFlagState } from "~/lib/hooks/useFlagState"; -import { Button, type ButtonProps, ResponsiveButton } from "./ui/button"; -import React from "react"; +import { type ButtonProps, ResponsiveButton } from "./ui/button"; export const ButtonWithShortcut = ({ shortcut, diff --git a/src/components/ColorModeToggle.tsx b/src/components/ColorModeToggle.tsx index a71ccbb..70f4520 100644 --- a/src/components/ColorModeToggle.tsx +++ b/src/components/ColorModeToggle.tsx @@ -1,6 +1,6 @@ "use client"; -import { MoonIcon, SunIcon, LaptopIcon } from "@radix-ui/react-icons"; +import { LaptopIcon, MoonIcon, SunIcon } from "@radix-ui/react-icons"; import clsx from "clsx"; import { WandIcon } from "lucide-react"; import { useTheme } from "next-themes"; @@ -21,26 +21,16 @@ const iconClasses = (isSelected: boolean) => export function ColorModeToggle() { const { theme, setTheme } = useTheme(); - const [isArcBrowser, setIsArcBrowser] = useState(false); const [showIcons, setShowIcons] = useState(false); - function checkForArcBrowser() { - const variable = getComputedStyle(document.body).getPropertyValue( - "--arc-palette-background", - ); - if (variable !== "") { - setIsArcBrowser(true); - } - } - // dirty hack to avoid hydration errors I don't want to fix useEffect(() => { setShowIcons(true); }, []); return ( - + - - ); -} diff --git a/src/components/KeyboardProvider.tsx b/src/components/KeyboardProvider.tsx index 464bc0a..3800cba 100644 --- a/src/components/KeyboardProvider.tsx +++ b/src/components/KeyboardProvider.tsx @@ -1,24 +1,22 @@ "use client"; +import { useParams, usePathname, useRouter } from "next/navigation"; import { createContext, - Ref, useCallback, useContext, useEffect, - useRef, useState, } from "react"; -import { useParams, usePathname, useRouter } from "next/navigation"; import { useDialogStore } from "~/app/(feed)/feed/dialogStore"; +import { useFeedItemsMap } from "~/lib/data/atoms"; import { useFilteredFeedItemsOrder } from "~/lib/data/feed-items"; import { useFeedItemsSetWatchedValueMutation, useFeedItemsSetWatchLaterValueMutation, } from "~/lib/data/feed-items/mutations"; -import { useFeedItemsMap } from "~/lib/data/atoms"; -import { useSidebar } from "./ui/sidebar"; import { doesAnyFormElementHaveFocus } from "~/lib/doesAnyFormElementHaveFocus"; +import { useSidebar } from "./ui/sidebar"; export const MIN_ZOOM = 0; export const MAX_ZOOM = 6; @@ -210,6 +208,9 @@ export function KeyboardProvider({ children }: KeyboardProviderProps) { toggleSidebar, zoomIn, zoomOut, + filteredFeedItemsOrder, + setWatchLaterValue, + setWatchedValue, ]); const toggleView = useCallback(() => { diff --git a/src/components/LeftSidebarBottomNav.tsx b/src/components/LeftSidebarBottomNav.tsx index aeffd81..9f460ef 100644 --- a/src/components/LeftSidebarBottomNav.tsx +++ b/src/components/LeftSidebarBottomNav.tsx @@ -1,24 +1,19 @@ "use client"; -import * as React from "react"; - -import { - SidebarGroup, - SidebarGroupContent, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from "~/components/ui/sidebar"; import { - CircleHelpIcon, - HelpingHandIcon, LifeBuoyIcon, LightbulbIcon, - MailIcon, NotebookIcon, PaletteIcon, } from "lucide-react"; import Link from "next/link"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "~/components/ui/sidebar"; import { ColorThemeDropdownSidebar } from "./color-theme/ColorThemePopoverButton"; export function LeftSidebarBottomNav() { diff --git a/src/components/LeftSidebarMain.tsx b/src/components/LeftSidebarMain.tsx deleted file mode 100644 index 741f830..0000000 --- a/src/components/LeftSidebarMain.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import { HomeIcon, ListIcon } from "lucide-react"; -import Link from "next/link"; - -import { - SidebarGroup, - SidebarGroupContent, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "~/components/ui/sidebar"; - -export function LeftSidebarMain() { - const { toggleSidebar, isMobile } = useSidebar(); - - const onNavigate = () => { - if (isMobile) { - toggleSidebar("left"); - } - }; - - return ( - - - - - - - - Home - - - - - - Feeds - - - - - - - ); -} diff --git a/src/components/ResponsiveVideo.tsx b/src/components/ResponsiveVideo.tsx index 8f8eec8..800c2f5 100644 --- a/src/components/ResponsiveVideo.tsx +++ b/src/components/ResponsiveVideo.tsx @@ -41,7 +41,7 @@ export default function ResponsiveVideo(props: IResponsiveVideoProps) { allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen className="border-none" - onMouseMove={(e) => { + onMouseMove={() => { containerRef.current?.focus(); }} /> diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 1320c9e..fa9a217 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -6,7 +6,6 @@ import { SidebarFeeds } from "~/app/(feed)/feed/SidebarFeeds"; import { SidebarViews } from "~/app/(feed)/feed/SidebarViews"; import { UserManagementNavItem } from "~/app/(feed)/feed/UserManagementButton"; import { LeftSidebarBottomNav } from "~/components/LeftSidebarBottomNav"; -import { LeftSidebarMain } from "~/components/LeftSidebarMain"; import { Sidebar, SidebarContent, diff --git a/src/components/ui/responsive-dropdown.tsx b/src/components/ui/responsive-dropdown.tsx index 3f9422b..7335429 100644 --- a/src/components/ui/responsive-dropdown.tsx +++ b/src/components/ui/responsive-dropdown.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { +import type { DropdownMenuContentProps, DropdownMenuItemProps, } from "@radix-ui/react-dropdown-menu"; diff --git a/src/lib/data/atoms.ts b/src/lib/data/atoms.ts index 0578ed4..3d69a7c 100644 --- a/src/lib/data/atoms.ts +++ b/src/lib/data/atoms.ts @@ -115,7 +115,12 @@ export const viewsAtom = validatedPersistedAtom({ }); export const dateFilterAtom = atom(1); -const visibilityFilterSchema = z.enum(["unread", "later", "videos", "shorts"]); +export const visibilityFilterSchema = z.enum([ + "unread", + "later", + "videos", + "shorts", +]); export type VisibilityFilter = z.infer; export const visibilityFilterAtom = atom("unread"); export const categoryFilterAtom = atom(-1); diff --git a/src/lib/data/feed-items/index.ts b/src/lib/data/feed-items/index.ts index ac6880a..57378dc 100644 --- a/src/lib/data/feed-items/index.ts +++ b/src/lib/data/feed-items/index.ts @@ -1,11 +1,11 @@ import { useQuery } from "@tanstack/react-query"; import { atom, useAtomValue, useSetAtom } from "jotai"; import { useEffect } from "react"; -import { +import type { ApplicationView, - type DatabaseFeed, - type DatabaseFeedCategory, - type DatabaseFeedItem, + DatabaseFeed, + DatabaseFeedCategory, + DatabaseFeedItem, } from "~/server/db/schema"; import { useTRPC } from "~/trpc/react"; import { @@ -150,7 +150,7 @@ export function useFeedItemsQuery() { ), ); } - }, [query, setFeedItemsOrder]); + }, [query, setFeedItemsOrder, setFeedItemsMap, setHasFetchedFeedItems]); return query; } diff --git a/src/lib/data/views/index.ts b/src/lib/data/views/index.ts index 3a64216..e1116c1 100644 --- a/src/lib/data/views/index.ts +++ b/src/lib/data/views/index.ts @@ -3,7 +3,7 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useMemo } from "react"; import { useSession } from "~/lib/auth-client"; import { FEED_ITEM_ORIENTATION, VIEW_READ_STATUS } from "~/server/db/constants"; -import { ApplicationView, contentCategories } from "~/server/db/schema"; +import { type ApplicationView } from "~/server/db/schema"; import { useTRPC } from "~/trpc/react"; import { categoryFilterAtom, @@ -19,8 +19,8 @@ import { } from "../atoms"; import { useContentCategories } from "../content-categories"; import { useFeedCategories } from "../feed-categories"; -import { useFeeds } from "../feeds"; import { doesFeedItemPassFilters } from "../feed-items"; +import { useFeeds } from "../feeds"; import { sortViewsByPlacement } from "./utils"; export const INBOX_VIEW_ID = -1; @@ -35,7 +35,7 @@ export function useDeselectViewFilter() { export function useUpdateViewFilter() { const views = useAtomValue(viewsAtom); - const [viewFilter, setViewFilter] = useAtom(viewFilterIdAtom); + const [, setViewFilter] = useAtom(viewFilterIdAtom); const setFeedFilter = useSetAtom(feedFilterAtom); const setDateFilter = useSetAtom(dateFilterAtom); @@ -66,7 +66,6 @@ export function useCheckFilteredFeedItemsForView() { const { feeds } = useFeeds(); const { views } = useViews(); - const dateFilter = useAtomValue(dateFilterAtom); const visibilityFilter = useAtomValue(visibilityFilterAtom); return useCallback( @@ -92,9 +91,10 @@ export function useCheckFilteredFeedItemsForView() { [ feedItemsOrder, feedItemsMap, - dateFilter, visibilityFilter, feedCategories, + feeds, + views, ], ); } @@ -102,9 +102,8 @@ export function useCheckFilteredFeedItemsForView() { export function useViewsQuery() { const { data } = useSession(); const { contentCategories } = useContentCategories(); - const [hasFetchedViews, setHasFetchedViews] = useAtom(hasFetchedViewsAtom); + const [, setHasFetchedViews] = useAtom(hasFetchedViewsAtom); const setViews = useSetAtom(viewsAtom); - const updateViewFilter = useUpdateViewFilter(); const query = useQuery( useTRPC().views.getAll.queryOptions(undefined, { @@ -145,14 +144,14 @@ export function useViewsQuery() { }; return sortViewsByPlacement([...customViews, inboxView]); - }, [query.data]); + }, [query.data, contentCategories, data?.user.id]); useEffect(() => { if (query.isSuccess) { setHasFetchedViews(true); setViews(transformedData); } - }, [query.isSuccess, transformedData]); + }, [query.isSuccess, transformedData, setHasFetchedViews, setViews]); return { ...query, diff --git a/src/lib/data/views/mutations.ts b/src/lib/data/views/mutations.ts index 3f646c0..cca7b05 100644 --- a/src/lib/data/views/mutations.ts +++ b/src/lib/data/views/mutations.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { ApplicationView } from "~/server/db/schema"; +import type { ApplicationView } from "~/server/db/schema"; import { useTRPC } from "~/trpc/react"; import { INBOX_VIEW_ID, INBOX_VIEW_PLACEMENT } from "."; From e20aa206dae3af98a4bbb6b7decf1211f902c394 Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Fri, 16 May 2025 19:46:28 -0400 Subject: [PATCH 16/35] fix error --- src/app/(feed)/feed/SidebarViews.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/(feed)/feed/SidebarViews.tsx b/src/app/(feed)/feed/SidebarViews.tsx index f879a1f..541b132 100644 --- a/src/app/(feed)/feed/SidebarViews.tsx +++ b/src/app/(feed)/feed/SidebarViews.tsx @@ -216,7 +216,6 @@ export function SidebarViews() { }), ); - setViews(updatedViews); void updateViewsPlacement({ views: updatedViews }); return updatedOptions; From 5855ab6509931a6bfc313345cf9057cb8f0fb7e2 Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Fri, 16 May 2025 22:34:43 -0400 Subject: [PATCH 17/35] add open in YouTube button --- src/app/(feed)/feed/Header.tsx | 2 +- src/app/(feed)/feed/TopRightHeaderContent.tsx | 28 +++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/app/(feed)/feed/Header.tsx b/src/app/(feed)/feed/Header.tsx index 10d212f..2657104 100644 --- a/src/app/(feed)/feed/Header.tsx +++ b/src/app/(feed)/feed/Header.tsx @@ -3,7 +3,7 @@ import { TopRightHeaderContent } from "./TopRightHeaderContent"; export function Header() { return ( -
+
diff --git a/src/app/(feed)/feed/TopRightHeaderContent.tsx b/src/app/(feed)/feed/TopRightHeaderContent.tsx index 8202766..f270ecb 100644 --- a/src/app/(feed)/feed/TopRightHeaderContent.tsx +++ b/src/app/(feed)/feed/TopRightHeaderContent.tsx @@ -1,6 +1,6 @@ "use client"; -import { MinusIcon, PlusIcon } from "lucide-react"; +import { ExternalLinkIcon, MinusIcon, PlusIcon } from "lucide-react"; import { usePathname } from "next/navigation"; import { ButtonWithShortcut } from "~/components/ButtonWithShortcut"; import { CustomVideoButton } from "~/components/CustomVideoButton"; @@ -8,6 +8,23 @@ import { MAX_ZOOM, MIN_ZOOM, useKeyboard } from "~/components/KeyboardProvider"; import { OpenRightSidebarButton } from "./OpenRightSidebarButton"; import { RefetchItemsButton } from "./RefetchItemsButton"; import { useSidebar } from "~/components/ui/sidebar"; +import Link from "next/link"; + +function OpenInYouTubeButton() { + const pathname = usePathname(); + + const videoId = pathname.split("/feed/watch/")[1]?.split("?"); + const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; + + return ( + + + YouTube + + + + ); +} export function TopRightHeaderContent() { const pathname = usePathname(); @@ -16,7 +33,13 @@ export function TopRightHeaderContent() { const { zoom, zoomIn, zoomOut } = useKeyboard(); if (pathname.includes("/feed/watch/")) { - if (isMobile) return null; + if (isMobile) { + return ( +
+ +
+ ); + } return (
@@ -38,6 +61,7 @@ export function TopRightHeaderContent() { > +
); } From eb9c1f637a2899842a1f03defdc449c1c59dfc38 Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Fri, 16 May 2025 22:36:05 -0400 Subject: [PATCH 18/35] remove shortcut button --- src/app/(feed)/feed/TopRightHeaderContent.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/(feed)/feed/TopRightHeaderContent.tsx b/src/app/(feed)/feed/TopRightHeaderContent.tsx index f270ecb..7f05227 100644 --- a/src/app/(feed)/feed/TopRightHeaderContent.tsx +++ b/src/app/(feed)/feed/TopRightHeaderContent.tsx @@ -9,6 +9,7 @@ import { OpenRightSidebarButton } from "./OpenRightSidebarButton"; import { RefetchItemsButton } from "./RefetchItemsButton"; import { useSidebar } from "~/components/ui/sidebar"; import Link from "next/link"; +import { Button } from "~/components/ui/button"; function OpenInYouTubeButton() { const pathname = usePathname(); @@ -18,10 +19,10 @@ function OpenInYouTubeButton() { return ( - + ); } From def25f4448f292b816f32846688389f9bec694af Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Fri, 16 May 2025 22:38:01 -0400 Subject: [PATCH 19/35] clearer no items state --- src/app/(feed)/feed/ViewFilterChips.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/(feed)/feed/ViewFilterChips.tsx b/src/app/(feed)/feed/ViewFilterChips.tsx index f21ad52..e1b85df 100644 --- a/src/app/(feed)/feed/ViewFilterChips.tsx +++ b/src/app/(feed)/feed/ViewFilterChips.tsx @@ -40,8 +40,7 @@ export function ViewFilterChips() { {views.map((view) => ( Date: Fri, 16 May 2025 23:02:43 -0400 Subject: [PATCH 20/35] complete category lifecycle from sidebar --- src/app/(feed)/feed/AppDialogs.tsx | 2 + src/app/(feed)/feed/SidebarCategories.tsx | 125 +++++++----- src/app/(feed)/feed/SidebarViews.tsx | 2 +- src/app/(feed)/feed/TopRightHeaderContent.tsx | 2 +- src/app/(feed)/feed/dialogStore.ts | 6 +- src/components/AddContentCategoryDialog.tsx | 188 ++++++++++++++++++ src/lib/data/content-categories/mutations.ts | 36 ++++ .../api/routers/contentCategoriesRouter.ts | 49 ++++- src/server/db/schema.ts | 8 +- 9 files changed, 362 insertions(+), 56 deletions(-) create mode 100644 src/components/AddContentCategoryDialog.tsx diff --git a/src/app/(feed)/feed/AppDialogs.tsx b/src/app/(feed)/feed/AppDialogs.tsx index 5caeb46..f66b70f 100644 --- a/src/app/(feed)/feed/AppDialogs.tsx +++ b/src/app/(feed)/feed/AppDialogs.tsx @@ -1,3 +1,4 @@ +import { AddContentCategoryDialog } from "~/components/AddContentCategoryDialog"; import { AddFeedDialog } from "~/components/AddFeedDialog"; import { AddViewDialog } from "~/components/AddViewDialog"; import { CustomVideoDialog } from "~/components/CustomVideoDialog"; @@ -7,6 +8,7 @@ export function AppDialogs() { <> + ); diff --git a/src/app/(feed)/feed/SidebarCategories.tsx b/src/app/(feed)/feed/SidebarCategories.tsx index 3ae99eb..d25e9d1 100644 --- a/src/app/(feed)/feed/SidebarCategories.tsx +++ b/src/app/(feed)/feed/SidebarCategories.tsx @@ -1,9 +1,10 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { CircleSmall } from "lucide-react"; +import { CircleSmall, Edit2Icon, PlusIcon } from "lucide-react"; +import { EditContentCategoryDialog } from "~/components/AddContentCategoryDialog"; import { SidebarGroup, SidebarGroupLabel, @@ -23,6 +24,7 @@ import { useContentCategories } from "~/lib/data/content-categories"; import { useFeedCategories } from "~/lib/data/feed-categories"; import { doesFeedItemPassFilters } from "~/lib/data/feed-items"; import { useDeselectViewFilter } from "~/lib/data/views"; +import { useDialogStore } from "./dialogStore"; function useCheckFilteredFeedItemsForCategory() { const feedItemsOrder = useFeedItemsOrder(); @@ -54,6 +56,11 @@ function useCheckFilteredFeedItemsForCategory() { } export function SidebarCategories() { + const [ + selectedContentCategoryForEditing, + setSelectedContentCategoryForEditing, + ] = useState(null); + const checkFilteredFeedItemsForCategory = useCheckFilteredFeedItemsForCategory(); @@ -62,6 +69,8 @@ export function SidebarCategories() { const deselectViewFilter = useDeselectViewFilter(); const [categoryFilter, setCategoryFilter] = useAtom(categoryFilterAtom); + const launchDialog = useDialogStore((store) => store.launchDialog); + const { contentCategories } = useContentCategories(); const categoryOptions = contentCategories?.map((category) => ({ @@ -81,49 +90,73 @@ export function SidebarCategories() { }; return ( - - Categories - - - { - updateCategoryFilter(-1); - setDateFilter(1); - }} - > - {!hasAnyItems && ( - - )} - {hasAnyItems && ( -
-
-
- )} - All - - - {categoryOptions?.map((option) => { - return ( - - updateCategoryFilter(option.id)} - > - {!option.hasEntries && ( - - )} - {option.hasEntries && ( -
-
-
- )} - {option.name} - - - ); - })} - - + <> + setSelectedContentCategoryForEditing(null)} + /> + + + Categories +
+ launchDialog("add-content-category")} + > + + +
+
+ + + { + updateCategoryFilter(-1); + setDateFilter(1); + }} + > + {!hasAnyItems && ( + + )} + {hasAnyItems && ( +
+
+
+ )} + All + + + {categoryOptions?.map((option) => { + return ( + + updateCategoryFilter(option.id)} + > + {!option.hasEntries && ( + + )} + {option.hasEntries && ( +
+
+
+ )} + {option.name} + +
+ + setSelectedContentCategoryForEditing(option.id) + } + > + + +
+ + ); + })} + + + ); } diff --git a/src/app/(feed)/feed/SidebarViews.tsx b/src/app/(feed)/feed/SidebarViews.tsx index 541b132..c619beb 100644 --- a/src/app/(feed)/feed/SidebarViews.tsx +++ b/src/app/(feed)/feed/SidebarViews.tsx @@ -182,7 +182,7 @@ export function SidebarViews() { const launchDialog = useDialogStore((store) => store.launchDialog); const checkFilteredFeedItemsForView = useCheckFilteredFeedItemsForView(); - const { views, setViews } = useViews(); + const { views } = useViews(); const [viewOptions, setViewOptions] = useState([]); diff --git a/src/app/(feed)/feed/TopRightHeaderContent.tsx b/src/app/(feed)/feed/TopRightHeaderContent.tsx index 7f05227..9008be9 100644 --- a/src/app/(feed)/feed/TopRightHeaderContent.tsx +++ b/src/app/(feed)/feed/TopRightHeaderContent.tsx @@ -14,7 +14,7 @@ import { Button } from "~/components/ui/button"; function OpenInYouTubeButton() { const pathname = usePathname(); - const videoId = pathname.split("/feed/watch/")[1]?.split("?"); + const videoId = pathname.split("/feed/watch/")[1]?.split("?")[1]; const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; return ( diff --git a/src/app/(feed)/feed/dialogStore.ts b/src/app/(feed)/feed/dialogStore.ts index 6d86742..ec8d87e 100644 --- a/src/app/(feed)/feed/dialogStore.ts +++ b/src/app/(feed)/feed/dialogStore.ts @@ -1,6 +1,10 @@ import { create } from "zustand"; -export type DialogType = "add-feed" | "add-view" | "custom-video"; +export type DialogType = + | "add-feed" + | "add-view" + | "add-content-category" + | "custom-video"; type DialogStore = { dialog: null | DialogType; launchDialog: (dialog: DialogType) => void; diff --git a/src/components/AddContentCategoryDialog.tsx b/src/components/AddContentCategoryDialog.tsx new file mode 100644 index 0000000..7599ee5 --- /dev/null +++ b/src/components/AddContentCategoryDialog.tsx @@ -0,0 +1,188 @@ +"use client"; +import { DialogTitle } from "@radix-ui/react-dialog"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { useDialogStore } from "~/app/(feed)/feed/dialogStore"; +import { useContentCategories } from "~/lib/data/content-categories"; +import { + useCreateContentCategoryMutation, + useDeleteContentCategoryMutation, + useUpdateContentCategoryMutation, +} from "~/lib/data/content-categories/mutations"; +import { Button } from "./ui/button"; +import { Dialog, DialogContent, DialogHeader } from "./ui/dialog"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; + +function CategoryNameInput({ + name, + setName, +}: { + name: string; + setName: (name: string) => void; +}) { + return ( +
+ + { + setName(e.target.value); + }} + /> +
+ ); +} + +export function AddContentCategoryDialog() { + const [isAddingContentCategory, setIsAddingContentCategory] = useState(false); + + const { mutateAsync: createContentCategory } = + useCreateContentCategoryMutation(); + + const [name, setName] = useState(""); + + const dialog = useDialogStore((store) => store.dialog); + const onOpenChangeDialog = useDialogStore((store) => store.onOpenChange); + + const isDisabled = !name; + + const onOpenChange = (value: boolean) => { + onOpenChangeDialog(value); + + if (!value) { + setName(""); + } + }; + + return ( + + + + Add Category + +
+ + +
+
+
+ ); +} + +export function EditContentCategoryDialog({ + selectedContentCategoryId, + onClose, +}: { + selectedContentCategoryId: null | number; + onClose: () => void; +}) { + const [isUpdatingContentCategory, setIsUpdatingContentCategory] = + useState(false); + const [isDeletingContentCategory, setIsDeletingContentCategory] = + useState(false); + + const { mutateAsync: updateContentCategory } = + useUpdateContentCategoryMutation(); + const { mutateAsync: deleteContentCategory } = + useDeleteContentCategoryMutation(); + + const [name, setName] = useState(""); + + const isFormDisabled = !name; + + const { contentCategories } = useContentCategories(); + useEffect(() => { + if (!contentCategories || !selectedContentCategoryId) return; + + const category = contentCategories.find( + (v) => v.id === selectedContentCategoryId, + ); + if (!category) return; + + setName(category.name); + }, [contentCategories, selectedContentCategoryId]); + + return ( + + + + + Edit Category{" "} + + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/lib/data/content-categories/mutations.ts b/src/lib/data/content-categories/mutations.ts index 1df7f9f..a178d28 100644 --- a/src/lib/data/content-categories/mutations.ts +++ b/src/lib/data/content-categories/mutations.ts @@ -15,3 +15,39 @@ export function useCreateContentCategoryMutation() { }), ); } + +export function useUpdateContentCategoryMutation() { + const api = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + api.contentCategories.update.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: api.contentCategories.getAll.queryKey(), + }); + }, + }), + ); +} + +export function useDeleteContentCategoryMutation() { + const api = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + api.contentCategories.delete.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: api.contentCategories.getAll.queryKey(), + }); + await queryClient.invalidateQueries({ + queryKey: api.views.getAll.queryKey(), + }); + await queryClient.invalidateQueries({ + queryKey: api.feedCategories.getAll.queryKey(), + }); + }, + }), + ); +} diff --git a/src/server/api/routers/contentCategoriesRouter.ts b/src/server/api/routers/contentCategoriesRouter.ts index c45fec1..affafa6 100644 --- a/src/server/api/routers/contentCategoriesRouter.ts +++ b/src/server/api/routers/contentCategoriesRouter.ts @@ -1,12 +1,18 @@ -import { eq, asc } from "drizzle-orm"; +import { and, asc, eq } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; -import { contentCategories } from "~/server/db/schema"; +import { + contentCategories, + feedCategories, + viewCategories, +} from "~/server/db/schema"; + +const categoryNameSchema = z.string().min(2); export const contentCategoriesRouter = createTRPCRouter({ create: protectedProcedure - .input(z.object({ name: z.string().min(2) })) + .input(z.object({ name: categoryNameSchema })) .mutation(async ({ ctx, input }) => { await ctx.db.insert(contentCategories).values({ userId: ctx.auth!.user.id, @@ -22,4 +28,41 @@ export const contentCategoriesRouter = createTRPCRouter({ return contentCategoriesList; }), + update: protectedProcedure + .input(z.object({ id: z.number(), name: categoryNameSchema })) + .mutation(async ({ ctx, input }) => { + await ctx.db + .update(contentCategories) + .set({ + name: input.name, + }) + .where( + and( + eq(contentCategories.userId, ctx.auth!.user.id), + eq(contentCategories.id, input.id), + ), + ); + }), + delete: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + await ctx.db.transaction(async (tx) => { + await tx + .delete(feedCategories) + .where(eq(feedCategories.categoryId, input.id)); + + await tx + .delete(viewCategories) + .where(eq(viewCategories.categoryId, input.id)); + + await tx + .delete(contentCategories) + .where( + and( + eq(contentCategories.id, input.id), + eq(contentCategories.userId, ctx.auth!.user.id), + ), + ); + }); + }), }); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index e9108a8..1f82595 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -2,24 +2,24 @@ // https://orm.drizzle.team/docs/sql-schema-declaration import { - integer, - text, - sqliteTableCreator, index, + integer, primaryKey, + sqliteTableCreator, + text, } from "drizzle-orm/sqlite-core"; import { createInsertSchema, createSelectSchema, createUpdateSchema, } from "drizzle-zod"; +import { z } from "zod"; import { FEED_ITEM_ORIENTATION, feedItemOrientationSchema, VIEW_READ_STATUS, viewReadStatusSchema, } from "./constants"; -import { z } from "zod"; /** * This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same From 55e24fdd7b80a0bf0872852f86793c23025ade42 Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Fri, 16 May 2025 23:12:43 -0400 Subject: [PATCH 21/35] tweaks --- src/app/(feed)/feed/SidebarCategories.tsx | 2 -- src/app/(feed)/feed/SidebarViews.tsx | 14 +++++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/app/(feed)/feed/SidebarCategories.tsx b/src/app/(feed)/feed/SidebarCategories.tsx index d25e9d1..e80d462 100644 --- a/src/app/(feed)/feed/SidebarCategories.tsx +++ b/src/app/(feed)/feed/SidebarCategories.tsx @@ -80,8 +80,6 @@ export function SidebarCategories() { const hasAnyItems = !!checkFilteredFeedItemsForCategory(-1).length; - if (!categoryOptions?.length) return null; - const updateCategoryFilter = (category: number) => { setFeedFilter(-1); setCategoryFilter(category); diff --git a/src/app/(feed)/feed/SidebarViews.tsx b/src/app/(feed)/feed/SidebarViews.tsx index c619beb..7800e75 100644 --- a/src/app/(feed)/feed/SidebarViews.tsx +++ b/src/app/(feed)/feed/SidebarViews.tsx @@ -190,13 +190,17 @@ export function SidebarViews() { useUpdateViewsPlacementMutation(); useEffect(() => { - setViewOptions( - views?.map((view) => ({ + if (viewOptions.length === views.length) { + return; + } + + setViewOptions(() => { + return views?.map((view) => ({ ...view, hasEntries: !!checkFilteredFeedItemsForView(view.id).length, - })), - ); - }, [views, checkFilteredFeedItemsForView]); + })); + }); + }, [views, viewOptions, checkFilteredFeedItemsForView]); function handleDragEnd(event: DragEndEvent) { const { active, over } = event; From defd977339fe3757bd2af31d69d33992e4d84fc9 Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Sat, 17 May 2025 00:15:54 -0400 Subject: [PATCH 22/35] add feed categorization in dialog, more snazzy linking --- src/app/(feed)/feed/ViewFilterChips.tsx | 36 +++++ src/components/AddContentCategoryButton.tsx | 20 +++ src/components/AddContentCategoryDialog.tsx | 124 +++++++++++++++++- src/components/AddViewDialog.tsx | 11 ++ src/lib/data/content-categories/mutations.ts | 6 + .../api/routers/contentCategoriesRouter.ts | 118 ++++++++++++++--- 6 files changed, 298 insertions(+), 17 deletions(-) create mode 100644 src/components/AddContentCategoryButton.tsx diff --git a/src/app/(feed)/feed/ViewFilterChips.tsx b/src/app/(feed)/feed/ViewFilterChips.tsx index e1b85df..54d7265 100644 --- a/src/app/(feed)/feed/ViewFilterChips.tsx +++ b/src/app/(feed)/feed/ViewFilterChips.tsx @@ -2,7 +2,10 @@ import clsx from "clsx"; import { useAtom } from "jotai"; +import { PlusIcon } from "lucide-react"; import { useMemo } from "react"; +import { Button } from "~/components/ui/button"; +import { toggleVariants } from "~/components/ui/toggle"; import { ToggleGroup, ToggleGroupItem } from "~/components/ui/toggle-group"; import { viewFilterIdAtom } from "~/lib/data/atoms"; import { @@ -10,9 +13,12 @@ import { useUpdateViewFilter, useViews, } from "~/lib/data/views"; +import { useDialogStore } from "./dialogStore"; +import { useContentCategories } from "~/lib/data/content-categories"; export function ViewFilterChips() { const { views } = useViews(); + const { contentCategories } = useContentCategories(); const [viewFilter] = useAtom(viewFilterIdAtom); const updateViewFilter = useUpdateViewFilter(); @@ -27,6 +33,36 @@ export function ViewFilterChips() { return map; }, [views, checkFilteredFeedItemsForView]); + const launchDialog = useDialogStore((store) => store.launchDialog); + + if (contentCategories.length === 0) { + return ( + + ); + } + + if (views.length === 1) { + return ( + + ); + } + return ( store.launchDialog); + + return ( + + ); +} diff --git a/src/components/AddContentCategoryDialog.tsx b/src/components/AddContentCategoryDialog.tsx index 7599ee5..378582a 100644 --- a/src/components/AddContentCategoryDialog.tsx +++ b/src/components/AddContentCategoryDialog.tsx @@ -1,18 +1,25 @@ "use client"; import { DialogTitle } from "@radix-ui/react-dialog"; -import { useEffect, useState } from "react"; +import { Dispatch, SetStateAction, useEffect, useState } from "react"; import { toast } from "sonner"; import { useDialogStore } from "~/app/(feed)/feed/dialogStore"; +import { useFeedItemsMap, useFeedItemsOrder } from "~/lib/data/atoms"; import { useContentCategories } from "~/lib/data/content-categories"; import { useCreateContentCategoryMutation, useDeleteContentCategoryMutation, useUpdateContentCategoryMutation, } from "~/lib/data/content-categories/mutations"; +import { useFeedCategories } from "~/lib/data/feed-categories"; +import { useFeeds } from "~/lib/data/feeds"; +import type { FeedCategorization } from "~/server/api/routers/contentCategoriesRouter"; +import { DatabaseFeed } from "~/server/db/schema"; import { Button } from "./ui/button"; +import { Checkbox } from "./ui/checkbox"; import { Dialog, DialogContent, DialogHeader } from "./ui/dialog"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; +import { ScrollArea } from "./ui/scroll-area"; function CategoryNameInput({ name, @@ -37,6 +44,101 @@ function CategoryNameInput({ ); } +function useMostRecentlyAppearingFeeds() { + const { feeds } = useFeeds(); + const order = useFeedItemsOrder(); + const items = useFeedItemsMap(); + + const feedIdsInOrder = order.map((id) => items[id]?.feedId).filter(Boolean); + const orderSet = new Set(feedIdsInOrder); + + let foundFeeds: DatabaseFeed[] = []; + orderSet.forEach((entry) => { + const foundFeed = feeds.find((feed) => feed.id === entry); + if (foundFeed) { + foundFeeds.push(foundFeed); + } + }); + + return foundFeeds; +} + +function CategoryFeedsInput({ + updatedFeedIdCategorizations, + setUpdatedFeedIdCategorizations, + categoryId, +}: { + updatedFeedIdCategorizations: FeedCategorization[]; + setUpdatedFeedIdCategorizations: Dispatch< + SetStateAction + >; + categoryId: number | null; +}) { + const { feedCategories } = useFeedCategories(); + const mostRecentlyAppearingFeeds = useMostRecentlyAppearingFeeds(); + + if (mostRecentlyAppearingFeeds.length === 0) { + return null; + } + + return ( +
+ + +
    + {mostRecentlyAppearingFeeds.map((feed) => { + const updatedIsSelected = updatedFeedIdCategorizations.find( + (categorization) => categorization.feedId === feed.id, + )?.selected; + const fallbackIsSelected = !!feedCategories.find( + (category) => + category.categoryId === categoryId && + category.feedId === feed.id, + ); + const isSelected = updatedIsSelected ?? fallbackIsSelected; + + return ( +
  • + { + setUpdatedFeedIdCategorizations((categorizations) => { + const categorizationIndex = categorizations.findIndex( + (categorization) => categorization.feedId === feed.id, + ); + + const updatedCategorization: FeedCategorization = { + feedId: feed.id, + selected: Boolean(value), + }; + + if (categorizationIndex >= 0) { + categorizations[categorizationIndex] = + updatedCategorization; + return [...categorizations]; + } + + return [...categorizations, updatedCategorization]; + }); + }} + /> + +
  • + ); + })} +
+
+
+ ); +} + export function AddContentCategoryDialog() { const [isAddingContentCategory, setIsAddingContentCategory] = useState(false); @@ -44,6 +146,8 @@ export function AddContentCategoryDialog() { useCreateContentCategoryMutation(); const [name, setName] = useState(""); + const [updatedFeedIdCategorizations, setUpdatedFeedIdCategorizations] = + useState([]); const dialog = useDialogStore((store) => store.dialog); const onOpenChangeDialog = useDialogStore((store) => store.onOpenChange); @@ -55,6 +159,7 @@ export function AddContentCategoryDialog() { if (!value) { setName(""); + setUpdatedFeedIdCategorizations([]); } }; @@ -69,6 +174,11 @@ export function AddContentCategoryDialog() {
+
+ ); + } + return (
diff --git a/src/lib/data/content-categories/mutations.ts b/src/lib/data/content-categories/mutations.ts index a178d28..7e42604 100644 --- a/src/lib/data/content-categories/mutations.ts +++ b/src/lib/data/content-categories/mutations.ts @@ -11,6 +11,9 @@ export function useCreateContentCategoryMutation() { await queryClient.invalidateQueries({ queryKey: api.contentCategories.getAll.queryKey(), }); + await queryClient.invalidateQueries({ + queryKey: api.feedCategories.getAll.queryKey(), + }); }, }), ); @@ -26,6 +29,9 @@ export function useUpdateContentCategoryMutation() { await queryClient.invalidateQueries({ queryKey: api.contentCategories.getAll.queryKey(), }); + await queryClient.invalidateQueries({ + queryKey: api.feedCategories.getAll.queryKey(), + }); }, }), ); diff --git a/src/server/api/routers/contentCategoriesRouter.ts b/src/server/api/routers/contentCategoriesRouter.ts index affafa6..5dd2e99 100644 --- a/src/server/api/routers/contentCategoriesRouter.ts +++ b/src/server/api/routers/contentCategoriesRouter.ts @@ -9,14 +9,51 @@ import { } from "~/server/db/schema"; const categoryNameSchema = z.string().min(2); +const feedCategorizationSchema = z.object({ + feedId: z.number(), + selected: z.boolean(), +}); +export type FeedCategorization = Required< + z.infer +>; + +const feedCategorizationsSchema = z.array(feedCategorizationSchema).optional(); export const contentCategoriesRouter = createTRPCRouter({ create: protectedProcedure - .input(z.object({ name: categoryNameSchema })) + .input( + z.object({ + name: categoryNameSchema, + feedCategorizations: feedCategorizationsSchema, + }), + ) .mutation(async ({ ctx, input }) => { - await ctx.db.insert(contentCategories).values({ - userId: ctx.auth!.user.id, - name: input.name, + await ctx.db.transaction(async (tx) => { + const categories = await ctx.db + .insert(contentCategories) + .values({ + userId: ctx.auth!.user.id, + name: input.name, + }) + .returning(); + const category = categories[0]; + + if (!input.feedCategorizations || !category) return; + + const feedIdsToCategorize = input.feedCategorizations + .filter((categorization) => categorization.selected) + .map((categorization) => categorization.feedId); + + if (!!feedIdsToCategorize.length) { + await Promise.all( + feedIdsToCategorize.map(async (feedId) => { + return await ctx.db.insert(feedCategories).values({ + categoryId: category.id, + feedId, + }); + }), + ); + } }); }), getAll: protectedProcedure.query(async ({ ctx }) => { @@ -29,19 +66,68 @@ export const contentCategoriesRouter = createTRPCRouter({ return contentCategoriesList; }), update: protectedProcedure - .input(z.object({ id: z.number(), name: categoryNameSchema })) + .input( + z.object({ + id: z.number(), + name: categoryNameSchema, + feedCategorizations: feedCategorizationsSchema, + }), + ) .mutation(async ({ ctx, input }) => { - await ctx.db - .update(contentCategories) - .set({ - name: input.name, - }) - .where( - and( - eq(contentCategories.userId, ctx.auth!.user.id), - eq(contentCategories.id, input.id), - ), - ); + await ctx.db.transaction(async (tx) => { + const categories = await ctx.db + .update(contentCategories) + .set({ + name: input.name, + }) + .where( + and( + eq(contentCategories.userId, ctx.auth!.user.id), + eq(contentCategories.id, input.id), + ), + ) + .returning(); + const category = categories[0]; + + if (!input.feedCategorizations || !category) return; + + const feedIdsToCategorize = input.feedCategorizations + .filter((categorization) => categorization.selected) + .map((categorization) => categorization.feedId); + + const feedIdsToDecategorize = input.feedCategorizations + .filter((categorization) => !categorization.selected) + .map((categorization) => categorization.feedId); + + if (!!feedIdsToCategorize.length) { + await Promise.all( + feedIdsToCategorize.map(async (feedId) => { + return await ctx.db + .insert(feedCategories) + .values({ + categoryId: category.id, + feedId, + }) + .onConflictDoNothing(); + }), + ); + } + + if (!!feedIdsToDecategorize.length) { + await Promise.all( + feedIdsToDecategorize.map(async (feedId) => { + return await ctx.db + .delete(feedCategories) + .where( + and( + eq(feedCategories.feedId, feedId), + eq(feedCategories.categoryId, input.id), + ), + ); + }), + ); + } + }); }), delete: protectedProcedure .input(z.object({ id: z.number() })) From 0a9695b236c55ef5971d4505e173bb89d64efc35 Mon Sep 17 00:00:00 2001 From: hfellerhoff Date: Sat, 17 May 2025 00:32:58 -0400 Subject: [PATCH 23/35] use fancy promise notifications ooo --- src/components/AddContentCategoryDialog.tsx | 37 ++++++-- src/components/AddFeedDialog.tsx | 99 +++++++++------------ src/components/AddViewDialog.tsx | 38 ++++++-- src/server/api/routers/feedRouter.ts | 16 ++-- 4 files changed, 110 insertions(+), 80 deletions(-) diff --git a/src/components/AddContentCategoryDialog.tsx b/src/components/AddContentCategoryDialog.tsx index 378582a..52ebdf5 100644 --- a/src/components/AddContentCategoryDialog.tsx +++ b/src/components/AddContentCategoryDialog.tsx @@ -185,12 +185,19 @@ export function AddContentCategoryDialog() { setIsAddingContentCategory(true); try { - await createContentCategory({ + const addCategoryPromise = createContentCategory({ name, feedCategorizations: updatedFeedIdCategorizations, }); - toast.success("Category added!"); - + toast.promise(addCategoryPromise, { + loading: "Creating category...", + success: () => { + return "Category created!"; + }, + error: () => { + return "Something went wrong creating your category."; + }, + }); onOpenChange(false); } catch {} @@ -268,10 +275,18 @@ export function EditContentCategoryDialog({ setIsDeletingContentCategory(true); try { - await deleteContentCategory({ + const deleteCategoryPromise = deleteContentCategory({ id: selectedContentCategoryId, }); - toast.success("Category deleted!"); + toast.promise(deleteCategoryPromise, { + loading: "Deleting category...", + success: () => { + return "Category deleted!"; + }, + error: () => { + return "Something went wrong deleting your category."; + }, + }); onClose(); } catch {} @@ -287,12 +302,20 @@ export function EditContentCategoryDialog({ setIsUpdatingContentCategory(true); try { - await updateContentCategory({ + const updateCategoryPromise = updateContentCategory({ name, id: selectedContentCategoryId, feedCategorizations: updatedFeedIdCategorizations, }); - toast.success("Category updated!"); + toast.promise(updateCategoryPromise, { + loading: "Updating category...", + success: () => { + return "Category updated!"; + }, + error: () => { + return "Something went wrong updating your category."; + }, + }); onClose(); } catch {} diff --git a/src/components/AddFeedDialog.tsx b/src/components/AddFeedDialog.tsx index 768c2c7..3bf65b2 100644 --- a/src/components/AddFeedDialog.tsx +++ b/src/components/AddFeedDialog.tsx @@ -28,24 +28,19 @@ export function AddFeedDialog() { const { mutateAsync: createFeed } = useCreateFeedMutation(); - const { - contentCategories, - contentCategoriesQuery: { - refetch: refetchCategories, - isLoading: isLoadingCategories, - }, - } = useContentCategories(); - const [categoryName, setCategoryName] = useState(null); - - const addCategory = useCreateContentCategoryMutation(); - - const categoryOptions = contentCategories?.map((category) => ({ - value: category.name, - label: category.name, - })); + const [selectedCategories, setSelectedCategories] = useState([]); const dialog = useDialogStore((store) => store.dialog); - const onOpenChange = useDialogStore((store) => store.onOpenChange); + const onDialogOpenChange = useDialogStore((store) => store.onOpenChange); + + const onOpenChange = (open: boolean = false) => { + onDialogOpenChange(open); + + if (!open) { + setFeedUrl(""); + setSelectedCategories([]); + } + }; return ( @@ -53,7 +48,7 @@ export function AddFeedDialog() { Add Feed -
+
- {addCategory.isPending || isLoadingCategories ? ( - - ) : ( - { - await addCategory.mutateAsync({ name: newOption }); - const categoriesResponse = await refetchCategories(); - const newCategory = categoriesResponse.data?.find( - (category) => category.name === newOption, - ); - - if (newCategory) { - setCategoryName(newCategory.name); - } - }} - value={categoryName} - placeholder="Select a category" - width="full" - /> - )} +