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 (
+
+ );
+}
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 }) {