aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mobile/app/dashboard/(tabs)/(lists)
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2026-02-08 22:45:32 +0000
committerMohamed Bassem <me@mbassem.com>2026-02-09 00:17:31 +0000
commite455e46852900c6d2b3e77b7a77e1b9da41b2ca8 (patch)
tree2d2042bd43d704b6432332c1465619b4b907dc71 /apps/mobile/app/dashboard/(tabs)/(lists)
parent4186c4c64c68892248ce8671d9b8e67fc7f884a0 (diff)
downloadkarakeep-e455e46852900c6d2b3e77b7a77e1b9da41b2ca8.tar.zst
feat(mobile): more native screens
Diffstat (limited to 'apps/mobile/app/dashboard/(tabs)/(lists)')
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx284
2 files changed, 302 insertions, 0 deletions
diff --git a/apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx
new file mode 100644
index 00000000..398ba650
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx
@@ -0,0 +1,18 @@
+import { Stack } from "expo-router/stack";
+
+export default function Layout() {
+ return (
+ <Stack
+ screenOptions={{
+ headerLargeTitle: true,
+ headerTransparent: true,
+ headerBlurEffect: "systemMaterial",
+ headerShadowVisible: false,
+ headerLargeTitleShadowVisible: false,
+ headerLargeStyle: { backgroundColor: "transparent" },
+ }}
+ >
+ <Stack.Screen name="index" options={{ title: "Lists" }} />
+ </Stack>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx
new file mode 100644
index 00000000..4c98ef2c
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx
@@ -0,0 +1,284 @@
+import { useEffect, useMemo, useState } from "react";
+import { FlatList, Pressable, View } from "react-native";
+import * as Haptics from "expo-haptics";
+import { Link, router, Stack } from "expo-router";
+import FullPageError from "@/components/FullPageError";
+import ChevronRight from "@/components/ui/ChevronRight";
+import FullPageSpinner from "@/components/ui/FullPageSpinner";
+import { Text } from "@/components/ui/Text";
+import { useColorScheme } from "@/lib/useColorScheme";
+import { condProps } from "@/lib/utils";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Plus } from "lucide-react-native";
+
+import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists";
+import { useTRPC } from "@karakeep/shared-react/trpc";
+import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils";
+
+function HeaderRight({ openNewListModal }: { openNewListModal: () => void }) {
+ return (
+ <Pressable
+ className="my-auto px-4"
+ onPress={() => {
+ Haptics.selectionAsync();
+ openNewListModal();
+ }}
+ >
+ <Plus color="rgb(0, 122, 255)" />
+ </Pressable>
+ );
+}
+
+interface ListLink {
+ id: string;
+ logo: string;
+ name: string;
+ href: string;
+ level: number;
+ parent?: string;
+ numChildren: number;
+ collapsed: boolean;
+ isSharedSection?: boolean;
+ numBookmarks?: number;
+}
+
+function traverseTree(
+ node: ZBookmarkListTreeNode,
+ links: ListLink[],
+ showChildrenOf: Record<string, boolean>,
+ listStats?: Map<string, number>,
+ parent?: string,
+ level = 0,
+) {
+ links.push({
+ id: node.item.id,
+ logo: node.item.icon,
+ name: node.item.name,
+ href: `/dashboard/lists/${node.item.id}`,
+ level,
+ parent,
+ numChildren: node.children?.length ?? 0,
+ collapsed: !showChildrenOf[node.item.id],
+ numBookmarks: listStats?.get(node.item.id),
+ });
+
+ if (node.children && showChildrenOf[node.item.id]) {
+ node.children.forEach((child) =>
+ traverseTree(
+ child,
+ links,
+ showChildrenOf,
+ listStats,
+ node.item.id,
+ level + 1,
+ ),
+ );
+ }
+}
+
+export default function Lists() {
+ const { colors } = useColorScheme();
+ const [refreshing, setRefreshing] = useState(false);
+ const { data: lists, isPending, error, refetch } = useBookmarkLists();
+ const [showChildrenOf, setShowChildrenOf] = useState<Record<string, boolean>>(
+ {},
+ );
+ const api = useTRPC();
+ const queryClient = useQueryClient();
+ const { data: listStats } = useQuery(api.lists.stats.queryOptions());
+
+ // Check if there are any shared lists
+ const hasSharedLists = useMemo(() => {
+ return lists?.data.some((list) => list.userRole !== "owner") ?? false;
+ }, [lists?.data]);
+
+ // Check if any list has children to determine if we need chevron spacing
+ const hasAnyListsWithChildren = useMemo(() => {
+ const checkForChildren = (node: ZBookmarkListTreeNode): boolean => {
+ if (node.children && node.children.length > 0) return true;
+ return false;
+ };
+ return (
+ Object.values(lists?.root ?? {}).some(checkForChildren) || hasSharedLists
+ );
+ }, [lists?.root, hasSharedLists]);
+
+ useEffect(() => {
+ setRefreshing(isPending);
+ }, [isPending]);
+
+ if (error) {
+ return <FullPageError error={error.message} onRetry={() => refetch()} />;
+ }
+
+ if (!lists) {
+ return <FullPageSpinner />;
+ }
+
+ const onRefresh = () => {
+ queryClient.invalidateQueries(api.lists.list.pathFilter());
+ queryClient.invalidateQueries(api.lists.stats.pathFilter());
+ };
+
+ const links: ListLink[] = [
+ {
+ id: "fav",
+ logo: "⭐️",
+ name: "Favourites",
+ href: "/dashboard/favourites",
+ level: 0,
+ numChildren: 0,
+ collapsed: false,
+ },
+ {
+ id: "arch",
+ logo: "🗄️",
+ name: "Archive",
+ href: "/dashboard/archive",
+ level: 0,
+ numChildren: 0,
+ collapsed: false,
+ },
+ ];
+
+ // Add shared lists section if there are any
+ if (hasSharedLists) {
+ // Count shared lists to determine if section has children
+ const sharedListsCount = Object.values(lists.root).filter(
+ (list) => list.item.userRole !== "owner",
+ ).length;
+
+ links.push({
+ id: "shared-section",
+ logo: "👥",
+ name: "Shared Lists",
+ href: "#",
+ level: 0,
+ numChildren: sharedListsCount,
+ collapsed: !showChildrenOf["shared-section"],
+ isSharedSection: true,
+ });
+
+ // Add shared lists as children if section is expanded
+ if (showChildrenOf["shared-section"]) {
+ Object.values(lists.root).forEach((list) => {
+ if (list.item.userRole !== "owner") {
+ traverseTree(
+ list,
+ links,
+ showChildrenOf,
+ listStats?.stats,
+ "shared-section",
+ 1,
+ );
+ }
+ });
+ }
+ }
+
+ // Add owned lists only
+ Object.values(lists.root).forEach((list) => {
+ if (list.item.userRole === "owner") {
+ traverseTree(list, links, showChildrenOf, listStats?.stats);
+ }
+ });
+
+ return (
+ <>
+ <Stack.Screen
+ options={{
+ headerRight: () => (
+ <HeaderRight
+ openNewListModal={() => router.push("/dashboard/lists/new")}
+ />
+ ),
+ }}
+ />
+ <FlatList
+ className="h-full"
+ contentInsetAdjustmentBehavior="automatic"
+ contentContainerStyle={{
+ gap: 6,
+ paddingBottom: 20,
+ }}
+ renderItem={(l) => (
+ <View
+ className="mx-2 flex flex-row items-center rounded-xl bg-card px-4 py-2"
+ style={{
+ borderCurve: "continuous",
+ ...condProps({
+ condition: l.item.level > 0,
+ props: { marginLeft: l.item.level * 20 },
+ }),
+ }}
+ >
+ {hasAnyListsWithChildren && (
+ <View style={{ width: 32 }}>
+ {l.item.numChildren > 0 && (
+ <Pressable
+ className="pr-2"
+ onPress={() => {
+ setShowChildrenOf((prev) => ({
+ ...prev,
+ [l.item.id]: !prev[l.item.id],
+ }));
+ }}
+ >
+ <ChevronRight
+ color={colors.foreground}
+ style={{
+ transform: [
+ { rotate: l.item.collapsed ? "0deg" : "90deg" },
+ ],
+ }}
+ />
+ </Pressable>
+ )}
+ </View>
+ )}
+
+ {l.item.isSharedSection ? (
+ <Pressable
+ className="flex flex-1 flex-row items-center justify-between"
+ onPress={() => {
+ setShowChildrenOf((prev) => ({
+ ...prev,
+ [l.item.id]: !prev[l.item.id],
+ }));
+ }}
+ >
+ <Text>
+ {l.item.logo} {l.item.name}
+ </Text>
+ </Pressable>
+ ) : (
+ <Link
+ asChild
+ key={l.item.id}
+ href={l.item.href}
+ className="flex-1"
+ >
+ <Pressable className="flex flex-row items-center justify-between">
+ <Text className="shrink">
+ {l.item.logo} {l.item.name}
+ </Text>
+ <View className="flex flex-row items-center">
+ {l.item.numBookmarks !== undefined && (
+ <Text className="mr-2 text-xs text-muted-foreground">
+ {l.item.numBookmarks}
+ </Text>
+ )}
+ <ChevronRight />
+ </View>
+ </Pressable>
+ </Link>
+ )}
+ </View>
+ )}
+ data={links}
+ refreshing={refreshing}
+ onRefresh={onRefresh}
+ />
+ </>
+ );
+}