diff options
| author | Evan Simkowitz <esimkowitz@users.noreply.github.com> | 2025-12-14 16:39:25 -0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-15 00:39:25 +0000 |
| commit | 7f4202afd73105b850498b55ad66922b3505f0e3 (patch) | |
| tree | a45f9f1b2599f4c9925e36dc51563b06ba6854ac | |
| parent | 6db14ac492cd5d9e26d0d986513771f14faa7fd0 (diff) | |
| download | karakeep-7f4202afd73105b850498b55ad66922b3505f0e3.tar.zst | |
feat: Add unified reader settings with local overrides (#2230)
* Add initial impl
* fix some format inconsistencies, add indicator in user settings when local is out of sync
* Fix sliders in user settings, unify constants and formatting
* address CodeRabbit suggestions
* add mobile implementation
* address coderabbit nitpicks
* fix responsiveness of the reader settings popover
* Move more of the web UI strings to i18n
* update translations for more coverage
* remove duplicate logic/definitions
* fix android font family
* add shared reading setting hook between web and mobile
* unify reader settings context for both web and mobile
* remove unused export
* address coderabbit suggestions
* fix tests
30 files changed, 4866 insertions, 199 deletions
@@ -63,3 +63,6 @@ data .vscode auth_failures.log .claude/settings.local.json + +# Local directory for AI agent contexts +.aicontext/ diff --git a/apps/mobile/app/dashboard/(tabs)/settings.tsx b/apps/mobile/app/dashboard/(tabs)/settings.tsx index 76216e00..7c1e00d6 100644 --- a/apps/mobile/app/dashboard/(tabs)/settings.tsx +++ b/apps/mobile/app/dashboard/(tabs)/settings.tsx @@ -88,6 +88,18 @@ export default function Dashboard() { </Link> </View> <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2"> + <Link + asChild + href="/dashboard/settings/reader-settings" + className="flex-1" + > + <Pressable className="flex flex-row justify-between"> + <Text>Reader Text Settings</Text> + <ChevronRight /> + </Pressable> + </Link> + </View> + <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2"> <Text>Show note preview in bookmark</Text> <Switch value={settings.showNotes} diff --git a/apps/mobile/app/dashboard/_layout.tsx b/apps/mobile/app/dashboard/_layout.tsx index eb1cbe4b..260071f0 100644 --- a/apps/mobile/app/dashboard/_layout.tsx +++ b/apps/mobile/app/dashboard/_layout.tsx @@ -144,6 +144,14 @@ export default function Dashboard() { headerBackTitle: "Back", }} /> + <Stack.Screen + name="settings/reader-settings" + options={{ + title: "Reader Settings", + headerTitle: "Reader Settings", + headerBackTitle: "Back", + }} + /> </StyledStack> ); } diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx index 7bf0f118..8fd04115 100644 --- a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx +++ b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { KeyboardAvoidingView } from "react-native"; +import { KeyboardAvoidingView, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Stack, useLocalSearchParams } from "expo-router"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import BookmarkAssetView from "@/components/bookmarks/BookmarkAssetView"; import BookmarkLinkTypeSelector, { BookmarkLinkType, @@ -13,12 +13,14 @@ import FullPageError from "@/components/FullPageError"; import FullPageSpinner from "@/components/ui/FullPageSpinner"; import useAppSettings from "@/lib/settings"; import { api } from "@/lib/trpc"; +import { Settings } from "lucide-react-native"; import { useColorScheme } from "nativewind"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; export default function BookmarkView() { const insets = useSafeAreaInsets(); + const router = useRouter(); const { slug } = useLocalSearchParams(); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; @@ -87,11 +89,22 @@ export default function BookmarkView() { headerTintColor: isDark ? "#fff" : "#000", headerRight: () => bookmark.content.type === BookmarkTypes.LINK ? ( - <BookmarkLinkTypeSelector - type={bookmarkLinkType} - onChange={(type) => setBookmarkLinkType(type)} - bookmark={bookmark} - /> + <View className="flex-row items-center gap-3"> + {bookmarkLinkType === "reader" && ( + <Pressable + onPress={() => + router.push("/dashboard/settings/reader-settings") + } + > + <Settings size={20} color="gray" /> + </Pressable> + )} + <BookmarkLinkTypeSelector + type={bookmarkLinkType} + onChange={(type) => setBookmarkLinkType(type)} + bookmark={bookmark} + /> + </View> ) : undefined, }} /> diff --git a/apps/mobile/app/dashboard/settings/reader-settings.tsx b/apps/mobile/app/dashboard/settings/reader-settings.tsx new file mode 100644 index 00000000..6c522557 --- /dev/null +++ b/apps/mobile/app/dashboard/settings/reader-settings.tsx @@ -0,0 +1,264 @@ +import { useEffect, useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { Slider } from "react-native-awesome-slider"; +import { runOnJS, useSharedValue } from "react-native-reanimated"; +import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; +import { Divider } from "@/components/ui/Divider"; +import { Text } from "@/components/ui/Text"; +import { MOBILE_FONT_FAMILIES, useReaderSettings } from "@/lib/readerSettings"; +import { useColorScheme } from "@/lib/useColorScheme"; +import { Check, RotateCcw } from "lucide-react-native"; + +import { + formatFontFamily, + formatFontSize, + formatLineHeight, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; +import { ZReaderFontFamily } from "@karakeep/shared/types/users"; + +export default function ReaderSettingsPage() { + const { isDarkColorScheme: isDark } = useColorScheme(); + + const { + settings, + localOverrides, + hasLocalOverrides, + hasServerDefaults, + updateLocal, + clearAllLocal, + saveAsDefault, + clearAllDefaults, + } = useReaderSettings(); + + const { + fontSize: effectiveFontSize, + lineHeight: effectiveLineHeight, + fontFamily: effectiveFontFamily, + } = settings; + + // Shared values for sliders + const fontSizeProgress = useSharedValue<number>(effectiveFontSize); + const fontSizeMin = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.fontSize.min, + ); + const fontSizeMax = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.fontSize.max, + ); + + const lineHeightProgress = useSharedValue<number>(effectiveLineHeight); + const lineHeightMin = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.lineHeight.min, + ); + const lineHeightMax = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.lineHeight.max, + ); + + // Display values for showing rounded values while dragging + const [displayFontSize, setDisplayFontSize] = useState(effectiveFontSize); + const [displayLineHeight, setDisplayLineHeight] = + useState(effectiveLineHeight); + + // Sync slider progress and display values with effective settings + useEffect(() => { + fontSizeProgress.value = effectiveFontSize; + setDisplayFontSize(effectiveFontSize); + }, [effectiveFontSize]); + + useEffect(() => { + lineHeightProgress.value = effectiveLineHeight; + setDisplayLineHeight(effectiveLineHeight); + }, [effectiveLineHeight]); + + const handleFontFamilyChange = (fontFamily: ZReaderFontFamily) => { + updateLocal({ fontFamily }); + }; + + const handleFontSizeChange = (value: number) => { + updateLocal({ fontSize: Math.round(value) }); + }; + + const handleLineHeightChange = (value: number) => { + updateLocal({ lineHeight: Math.round(value * 10) / 10 }); + }; + + const handleSaveAsDefault = () => { + saveAsDefault(); + // Note: clearAllLocal is called automatically in the shared hook's onSuccess + }; + + const handleClearLocalOverrides = () => { + clearAllLocal(); + }; + + const handleClearServerDefaults = () => { + clearAllDefaults(); + }; + + const fontFamilyOptions: ZReaderFontFamily[] = ["serif", "sans", "mono"]; + + return ( + <CustomSafeAreaView> + <ScrollView + className="w-full" + contentContainerClassName="items-center gap-4 px-4 py-2" + > + {/* Font Family Selection */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Font Family + {localOverrides.fontFamily !== undefined && ( + <Text className="text-blue-500"> (local)</Text> + )} + </Text> + <View className="w-full rounded-lg bg-card px-4 py-2"> + {fontFamilyOptions.map((fontFamily, index) => { + const isChecked = effectiveFontFamily === fontFamily; + return ( + <View key={fontFamily}> + <Pressable + onPress={() => handleFontFamilyChange(fontFamily)} + className="flex flex-row items-center justify-between py-2" + > + <Text + style={{ + fontFamily: MOBILE_FONT_FAMILIES[fontFamily], + }} + > + {formatFontFamily(fontFamily)} + </Text> + {isChecked && <Check color="rgb(0, 122, 255)" />} + </Pressable> + {index < fontFamilyOptions.length - 1 && ( + <Divider orientation="horizontal" className="h-0.5" /> + )} + </View> + ); + })} + </View> + </View> + + {/* Font Size */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Font Size ({formatFontSize(displayFontSize)}) + {localOverrides.fontSize !== undefined && ( + <Text className="text-blue-500"> (local)</Text> + )} + </Text> + <View className="flex w-full flex-row items-center gap-3 rounded-lg bg-card px-4 py-3"> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.fontSize.min} + </Text> + <View className="flex-1"> + <Slider + progress={fontSizeProgress} + minimumValue={fontSizeMin} + maximumValue={fontSizeMax} + renderBubble={() => null} + onValueChange={(value) => { + "worklet"; + runOnJS(setDisplayFontSize)(Math.round(value)); + }} + onSlidingComplete={(value) => + handleFontSizeChange(Math.round(value)) + } + /> + </View> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.fontSize.max} + </Text> + </View> + </View> + + {/* Line Height */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Line Height ({formatLineHeight(displayLineHeight)}) + {localOverrides.lineHeight !== undefined && ( + <Text className="text-blue-500"> (local)</Text> + )} + </Text> + <View className="flex w-full flex-row items-center gap-3 rounded-lg bg-card px-4 py-3"> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.lineHeight.min} + </Text> + <View className="flex-1"> + <Slider + progress={lineHeightProgress} + minimumValue={lineHeightMin} + maximumValue={lineHeightMax} + renderBubble={() => null} + onValueChange={(value) => { + "worklet"; + runOnJS(setDisplayLineHeight)(Math.round(value * 10) / 10); + }} + onSlidingComplete={handleLineHeightChange} + /> + </View> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.lineHeight.max} + </Text> + </View> + </View> + + {/* Preview */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Preview + </Text> + <View className="w-full rounded-lg bg-card px-4 py-3"> + <Text + style={{ + fontFamily: MOBILE_FONT_FAMILIES[effectiveFontFamily], + fontSize: effectiveFontSize, + lineHeight: effectiveFontSize * effectiveLineHeight, + }} + className="text-foreground" + > + The quick brown fox jumps over the lazy dog. Pack my box with five + dozen liquor jugs. How vexingly quick daft zebras jump! + </Text> + </View> + </View> + + <Divider orientation="horizontal" className="my-2 w-full" /> + + {/* Save as Default */} + <Pressable + onPress={handleSaveAsDefault} + disabled={!hasLocalOverrides} + className="w-full rounded-lg bg-card px-4 py-3" + > + <Text + className={`text-center ${hasLocalOverrides ? "text-blue-500" : "text-muted-foreground"}`} + > + Save as Default (All Devices) + </Text> + </Pressable> + + {/* Clear Local */} + {hasLocalOverrides && ( + <Pressable + onPress={handleClearLocalOverrides} + className="flex w-full flex-row items-center justify-center gap-2 rounded-lg bg-card px-4 py-3" + > + <RotateCcw size={16} color={isDark ? "#9ca3af" : "#6b7280"} /> + <Text className="text-muted-foreground">Clear Local Overrides</Text> + </Pressable> + )} + + {/* Clear Server */} + {hasServerDefaults && ( + <Pressable + onPress={handleClearServerDefaults} + className="flex w-full flex-row items-center justify-center gap-2 rounded-lg bg-card px-4 py-3" + > + <RotateCcw size={16} color={isDark ? "#9ca3af" : "#6b7280"} /> + <Text className="text-muted-foreground">Clear Server Defaults</Text> + </Pressable> + )} + </ScrollView> + </CustomSafeAreaView> + ); +} diff --git a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx index 730bcd08..e0b592d6 100644 --- a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx +++ b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx @@ -5,6 +5,7 @@ import WebView from "react-native-webview"; import { WebViewSourceUri } from "react-native-webview/lib/WebViewTypes"; import { Text } from "@/components/ui/Text"; import { useAssetUrl } from "@/lib/hooks"; +import { useReaderSettings, WEBVIEW_FONT_FAMILIES } from "@/lib/readerSettings"; import { api } from "@/lib/trpc"; import { useColorScheme } from "@/lib/useColorScheme"; @@ -38,6 +39,7 @@ export function BookmarkLinkReaderPreview({ bookmark: ZBookmark; }) { const { isDarkColorScheme: isDark } = useColorScheme(); + const { settings: readerSettings } = useReaderSettings(); const { data: bookmarkWithContent, @@ -61,6 +63,10 @@ export function BookmarkLinkReaderPreview({ throw new Error("Wrong content type rendered"); } + const fontFamily = WEBVIEW_FONT_FAMILIES[readerSettings.fontFamily]; + const fontSize = readerSettings.fontSize; + const lineHeight = readerSettings.lineHeight; + return ( <View className="flex-1 bg-background"> <WebView @@ -73,8 +79,9 @@ export function BookmarkLinkReaderPreview({ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; - line-height: 1.6; + font-family: ${fontFamily}; + font-size: ${fontSize}px; + line-height: ${lineHeight}; color: ${isDark ? "#e5e7eb" : "#374151"}; margin: 0; padding: 16px; @@ -85,17 +92,29 @@ export function BookmarkLinkReaderPreview({ img { max-width: 100%; height: auto; border-radius: 8px; } a { color: #3b82f6; text-decoration: none; } a:hover { text-decoration: underline; } - blockquote { - border-left: 4px solid ${isDark ? "#374151" : "#e5e7eb"}; - margin: 1em 0; - padding-left: 1em; - color: ${isDark ? "#9ca3af" : "#6b7280"}; + blockquote { + border-left: 4px solid ${isDark ? "#374151" : "#e5e7eb"}; + margin: 1em 0; + padding-left: 1em; + color: ${isDark ? "#9ca3af" : "#6b7280"}; + } + pre, code { + font-family: ui-monospace, Menlo, Monaco, 'Courier New', monospace; + background: ${isDark ? "#1f2937" : "#f3f4f6"}; + } + pre { + padding: 1em; + border-radius: 6px; + overflow-x: auto; + } + code { + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 0.9em; } - pre { - background: ${isDark ? "#1f2937" : "#f3f4f6"}; - padding: 1em; - border-radius: 6px; - overflow-x: auto; + pre code { + padding: 0; + background: none; } </style> </head> diff --git a/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx b/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx index 58cbcc8d..c7fd4be3 100644 --- a/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx +++ b/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx @@ -43,7 +43,7 @@ export default function BookmarkLinkTypeSelector({ }: BookmarkLinkTypeSelectorProps) { const availableTypes = getAvailableViewTypes(bookmark); - const allActions = [ + const viewActions = [ { id: "reader" as const, title: "Reader View", @@ -66,7 +66,7 @@ export default function BookmarkLinkTypeSelector({ }, ]; - const availableActions = allActions.filter((action) => + const availableViewActions = viewActions.filter((action) => availableTypes.includes(action.id), ); @@ -76,7 +76,7 @@ export default function BookmarkLinkTypeSelector({ Haptics.selectionAsync(); onChange(nativeEvent.event as BookmarkLinkType); }} - actions={availableActions} + actions={availableViewActions} shouldOpenOnLongPress={false} > <ChevronDown onPress={() => Haptics.selectionAsync()} color="gray" /> diff --git a/apps/mobile/lib/providers.tsx b/apps/mobile/lib/providers.tsx index 938b8aeb..36ed7e71 100644 --- a/apps/mobile/lib/providers.tsx +++ b/apps/mobile/lib/providers.tsx @@ -4,6 +4,7 @@ import { ToastProvider } from "@/components/ui/Toast"; import { TRPCProvider } from "@karakeep/shared-react/providers/trpc-provider"; +import { ReaderSettingsProvider } from "./readerSettings"; import useAppSettings from "./settings"; export function Providers({ children }: { children: React.ReactNode }) { @@ -20,7 +21,9 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( <TRPCProvider settings={settings}> - <ToastProvider>{children}</ToastProvider> + <ReaderSettingsProvider> + <ToastProvider>{children}</ToastProvider> + </ReaderSettingsProvider> </TRPCProvider> ); } diff --git a/apps/mobile/lib/readerSettings.tsx b/apps/mobile/lib/readerSettings.tsx new file mode 100644 index 00000000..9a3fc835 --- /dev/null +++ b/apps/mobile/lib/readerSettings.tsx @@ -0,0 +1,93 @@ +import { ReactNode, useCallback } from "react"; +import { Platform } from "react-native"; + +import { + ReaderSettingsProvider as BaseReaderSettingsProvider, + useReaderSettingsContext, +} from "@karakeep/shared-react/hooks/reader-settings"; +import { ReaderSettingsPartial } from "@karakeep/shared/types/readers"; +import { ZReaderFontFamily } from "@karakeep/shared/types/users"; + +import { useSettings } from "./settings"; + +// Mobile-specific font families for native Text components +// On Android, use generic font family names: "serif", "sans-serif", "monospace" +// On iOS, use specific font names like "Georgia" and "Courier" +// Note: undefined means use the system default font +export const MOBILE_FONT_FAMILIES: Record< + ZReaderFontFamily, + string | undefined +> = Platform.select({ + android: { + serif: "serif", + sans: undefined, + mono: "monospace", + }, + default: { + serif: "Georgia", + sans: undefined, + mono: "Courier", + }, +})!; + +// Font families for WebView HTML content (CSS font stacks) +export const WEBVIEW_FONT_FAMILIES: Record<ZReaderFontFamily, string> = { + serif: "Georgia, 'Times New Roman', serif", + sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif", + mono: "ui-monospace, Menlo, Monaco, 'Courier New', monospace", +} as const; + +/** + * Mobile-specific provider for reader settings. + * Wraps the shared provider with mobile storage callbacks. + */ +export function ReaderSettingsProvider({ children }: { children: ReactNode }) { + // Read from zustand store directly to keep callback stable (empty deps). + const getLocalOverrides = useCallback((): ReaderSettingsPartial => { + const currentSettings = useSettings.getState().settings.settings; + return { + fontSize: currentSettings.readerFontSize, + lineHeight: currentSettings.readerLineHeight, + fontFamily: currentSettings.readerFontFamily, + }; + }, []); + + const saveLocalOverrides = useCallback((overrides: ReaderSettingsPartial) => { + const currentSettings = useSettings.getState().settings.settings; + // Remove reader settings keys first, then add back only defined ones + const { + readerFontSize: _fs, + readerLineHeight: _lh, + readerFontFamily: _ff, + ...rest + } = currentSettings; + + const newSettings = { ...rest }; + if (overrides.fontSize !== undefined) { + (newSettings as typeof currentSettings).readerFontSize = + overrides.fontSize; + } + if (overrides.lineHeight !== undefined) { + (newSettings as typeof currentSettings).readerLineHeight = + overrides.lineHeight; + } + if (overrides.fontFamily !== undefined) { + (newSettings as typeof currentSettings).readerFontFamily = + overrides.fontFamily; + } + + useSettings.getState().setSettings(newSettings); + }, []); + + return ( + <BaseReaderSettingsProvider + getLocalOverrides={getLocalOverrides} + saveLocalOverrides={saveLocalOverrides} + > + {children} + </BaseReaderSettingsProvider> + ); +} + +// Re-export the context hook as useReaderSettings for mobile consumers +export { useReaderSettingsContext as useReaderSettings }; diff --git a/apps/mobile/lib/settings.ts b/apps/mobile/lib/settings.ts index 40a33976..745c778d 100644 --- a/apps/mobile/lib/settings.ts +++ b/apps/mobile/lib/settings.ts @@ -2,6 +2,8 @@ import * as SecureStore from "expo-secure-store"; import { z } from "zod"; import { create } from "zustand"; +import { zReaderFontFamilySchema } from "@karakeep/shared/types/users"; + const SETTING_NAME = "settings"; const zSettingsSchema = z.object({ @@ -16,6 +18,10 @@ const zSettingsSchema = z.object({ .default("reader"), showNotes: z.boolean().optional().default(false), customHeaders: z.record(z.string(), z.string()).optional().default({}), + // Reader settings (local device overrides) + readerFontSize: z.number().int().min(12).max(24).optional(), + readerLineHeight: z.number().min(1.2).max(2.5).optional(), + readerFontFamily: zReaderFontFamilySchema.optional(), }); export type Settings = z.infer<typeof zSettingsSchema>; @@ -73,3 +79,5 @@ export default function useAppSettings() { return { ...settings, setSettings, load }; } + +export { useSettings }; diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index 911d542c..be65e66a 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -4,6 +4,7 @@ import MobileSidebar from "@/components/shared/sidebar/MobileSidebar"; import Sidebar from "@/components/shared/sidebar/Sidebar"; import SidebarLayout from "@/components/shared/sidebar/SidebarLayout"; import { Separator } from "@/components/ui/separator"; +import { ReaderSettingsProvider } from "@/lib/readerSettings"; import { UserSettingsContextProvider } from "@/lib/userSettings"; import { api } from "@/server/api/client"; import { getServerAuthSession } from "@/server/auth"; @@ -98,23 +99,25 @@ export default async function Dashboard({ return ( <UserSettingsContextProvider userSettings={userSettings.data}> - <SidebarLayout - sidebar={ - <Sidebar - items={items} - extraSections={ - <> - <Separator /> - <AllLists initialData={lists.data} /> - </> - } - /> - } - mobileSidebar={<MobileSidebar items={mobileSidebar} />} - modal={modal} - > - {children} - </SidebarLayout> + <ReaderSettingsProvider> + <SidebarLayout + sidebar={ + <Sidebar + items={items} + extraSections={ + <> + <Separator /> + <AllLists initialData={lists.data} /> + </> + } + /> + } + mobileSidebar={<MobileSidebar items={mobileSidebar} />} + modal={modal} + > + {children} + </SidebarLayout> + </ReaderSettingsProvider> </UserSettingsContextProvider> ); } diff --git a/apps/web/app/reader/[bookmarkId]/page.tsx b/apps/web/app/reader/[bookmarkId]/page.tsx index e32811a9..133bb601 100644 --- a/apps/web/app/reader/[bookmarkId]/page.tsx +++ b/apps/web/app/reader/[bookmarkId]/page.tsx @@ -3,36 +3,18 @@ import { Suspense, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import HighlightCard from "@/components/dashboard/highlights/HighlightCard"; +import ReaderSettingsPopover from "@/components/dashboard/preview/ReaderSettingsPopover"; import ReaderView from "@/components/dashboard/preview/ReaderView"; import { Button } from "@/components/ui/button"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; -import { Slider } from "@/components/ui/slider"; -import { - HighlighterIcon as Highlight, - Minus, - Plus, - Printer, - Settings, - Type, - X, -} from "lucide-react"; +import { useReaderSettings } from "@/lib/readerSettings"; +import { HighlighterIcon as Highlight, Printer, X } from "lucide-react"; import { useSession } from "next-auth/react"; import { api } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; +import { READER_FONT_FAMILIES } from "@karakeep/shared/types/readers"; import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils"; export default function ReaderViewPage() { @@ -47,19 +29,10 @@ export default function ReaderViewPage() { const { data: session } = useSession(); const router = useRouter(); - const [fontSize, setFontSize] = useState([18]); - const [lineHeight, setLineHeight] = useState([1.6]); - const [fontFamily, setFontFamily] = useState("serif"); + const { settings } = useReaderSettings(); const [showHighlights, setShowHighlights] = useState(false); - const [showSettings, setShowSettings] = useState(false); const isOwner = session?.user?.id === bookmark?.userId; - const fontFamilies = { - serif: "ui-serif, Georgia, Cambria, serif", - sans: "ui-sans-serif, system-ui, sans-serif", - mono: "ui-monospace, Menlo, Monaco, monospace", - }; - const onClose = () => { if (window.history.length > 1) { router.back(); @@ -89,94 +62,7 @@ export default function ReaderViewPage() { <Printer className="h-4 w-4" /> </Button> - <Popover open={showSettings} onOpenChange={setShowSettings}> - <PopoverTrigger asChild> - <Button variant="ghost" size="icon"> - <Settings className="h-4 w-4" /> - </Button> - </PopoverTrigger> - <PopoverContent side="bottom" align="end" className="w-80"> - <div className="space-y-4"> - <div className="flex items-center gap-2 pb-2"> - <Type className="h-4 w-4" /> - <h3 className="font-semibold">Reading Settings</h3> - </div> - - <div className="space-y-4"> - <div className="space-y-2"> - <label className="text-sm font-medium">Font Family</label> - <Select value={fontFamily} onValueChange={setFontFamily}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="serif">Serif</SelectItem> - <SelectItem value="sans">Sans Serif</SelectItem> - <SelectItem value="mono">Monospace</SelectItem> - </SelectContent> - </Select> - </div> - - <div className="space-y-2"> - <div className="flex items-center justify-between"> - <label className="text-sm font-medium">Font Size</label> - <span className="text-sm text-muted-foreground"> - {fontSize[0]}px - </span> - </div> - <div className="flex items-center gap-2"> - <Button - variant="outline" - size="icon" - className="h-7 w-7 bg-transparent" - onClick={() => - setFontSize([Math.max(12, fontSize[0] - 1)]) - } - > - <Minus className="h-3 w-3" /> - </Button> - <Slider - value={fontSize} - onValueChange={setFontSize} - max={24} - min={12} - step={1} - className="flex-1" - /> - <Button - variant="outline" - size="icon" - className="h-7 w-7 bg-transparent" - onClick={() => - setFontSize([Math.min(24, fontSize[0] + 1)]) - } - > - <Plus className="h-3 w-3" /> - </Button> - </div> - </div> - - <div className="space-y-2"> - <div className="flex items-center justify-between"> - <label className="text-sm font-medium"> - Line Height - </label> - <span className="text-sm text-muted-foreground"> - {lineHeight[0]} - </span> - </div> - <Slider - value={lineHeight} - onValueChange={setLineHeight} - max={2.5} - min={1.2} - step={0.1} - /> - </div> - </div> - </div> - </PopoverContent> - </Popover> + <ReaderSettingsPopover variant="ghost" /> <Button variant={showHighlights ? "default" : "ghost"} @@ -216,10 +102,9 @@ export default function ReaderViewPage() { <h1 className="font-bold leading-tight" style={{ - fontFamily: - fontFamilies[fontFamily as keyof typeof fontFamilies], - fontSize: `${fontSize[0] * 1.8}px`, - lineHeight: lineHeight[0] * 0.9, + fontFamily: READER_FONT_FAMILIES[settings.fontFamily], + fontSize: `${settings.fontSize * 1.8}px`, + lineHeight: settings.lineHeight * 0.9, }} > {getBookmarkTitle(bookmark)} @@ -239,10 +124,9 @@ export default function ReaderViewPage() { <ReaderView className="prose prose-neutral max-w-none break-words dark:prose-invert [&_code]:break-all [&_img]:h-auto [&_img]:max-w-full [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto" style={{ - fontFamily: - fontFamilies[fontFamily as keyof typeof fontFamilies], - fontSize: `${fontSize[0]}px`, - lineHeight: lineHeight[0], + fontFamily: READER_FONT_FAMILIES[settings.fontFamily], + fontSize: `${settings.fontSize}px`, + lineHeight: settings.lineHeight, }} bookmarkId={bookmarkId} readOnly={!isOwner} diff --git a/apps/web/app/reader/layout.tsx b/apps/web/app/reader/layout.tsx new file mode 100644 index 00000000..b0c27c84 --- /dev/null +++ b/apps/web/app/reader/layout.tsx @@ -0,0 +1,39 @@ +import { redirect } from "next/navigation"; +import { ReaderSettingsProvider } from "@/lib/readerSettings"; +import { UserSettingsContextProvider } from "@/lib/userSettings"; +import { api } from "@/server/api/client"; +import { getServerAuthSession } from "@/server/auth"; +import { TRPCError } from "@trpc/server"; + +import { tryCatch } from "@karakeep/shared/tryCatch"; + +export default async function ReaderLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + + const userSettings = await tryCatch(api.users.settings()); + + if (userSettings.error) { + if (userSettings.error instanceof TRPCError) { + if ( + userSettings.error.code === "NOT_FOUND" || + userSettings.error.code === "UNAUTHORIZED" + ) { + redirect("/logout"); + } + } + throw userSettings.error; + } + + return ( + <UserSettingsContextProvider userSettings={userSettings.data}> + <ReaderSettingsProvider>{children}</ReaderSettingsProvider> + </UserSettingsContextProvider> + ); +} diff --git a/apps/web/app/settings/info/page.tsx b/apps/web/app/settings/info/page.tsx index 1807b538..b42c1a28 100644 --- a/apps/web/app/settings/info/page.tsx +++ b/apps/web/app/settings/info/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { ChangePassword } from "@/components/settings/ChangePassword"; import { DeleteAccount } from "@/components/settings/DeleteAccount"; +import ReaderSettings from "@/components/settings/ReaderSettings"; import UserDetails from "@/components/settings/UserDetails"; import UserOptions from "@/components/settings/UserOptions"; import { useTranslation } from "@/lib/i18n/server"; @@ -19,6 +20,7 @@ export default async function InfoPage() { <UserDetails /> <ChangePassword /> <UserOptions /> + <ReaderSettings /> <DeleteAccount /> </div> ); diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index 1c7d25ac..0124becf 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -1,6 +1,7 @@ import MobileSidebar from "@/components/shared/sidebar/MobileSidebar"; import Sidebar from "@/components/shared/sidebar/Sidebar"; import SidebarLayout from "@/components/shared/sidebar/SidebarLayout"; +import { ReaderSettingsProvider } from "@/lib/readerSettings"; import { UserSettingsContextProvider } from "@/lib/userSettings"; import { api } from "@/server/api/client"; import { TFunction } from "i18next"; @@ -114,12 +115,14 @@ export default async function SettingsLayout({ const userSettings = await api.users.settings(); return ( <UserSettingsContextProvider userSettings={userSettings}> - <SidebarLayout - sidebar={<Sidebar items={settingsSidebarItems} />} - mobileSidebar={<MobileSidebar items={settingsSidebarItems} />} - > - {children} - </SidebarLayout> + <ReaderSettingsProvider> + <SidebarLayout + sidebar={<Sidebar items={settingsSidebarItems} />} + mobileSidebar={<MobileSidebar items={settingsSidebarItems} />} + > + {children} + </SidebarLayout> + </ReaderSettingsProvider> </UserSettingsContextProvider> ); } diff --git a/apps/web/components/dashboard/preview/LinkContentSection.tsx b/apps/web/components/dashboard/preview/LinkContentSection.tsx index 64b62df6..75b6df14 100644 --- a/apps/web/components/dashboard/preview/LinkContentSection.tsx +++ b/apps/web/components/dashboard/preview/LinkContentSection.tsx @@ -17,6 +17,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; import { AlertTriangle, Archive, @@ -34,8 +35,10 @@ import { ZBookmark, ZBookmarkedLink, } from "@karakeep/shared/types/bookmarks"; +import { READER_FONT_FAMILIES } from "@karakeep/shared/types/readers"; import { contentRendererRegistry } from "./content-renderers"; +import ReaderSettingsPopover from "./ReaderSettingsPopover"; import ReaderView from "./ReaderView"; function CustomRendererErrorFallback({ error }: { error: Error }) { @@ -106,6 +109,7 @@ export default function LinkContentSection({ bookmark: ZBookmark; }) { const { t } = useTranslation(); + const { settings } = useReaderSettings(); const availableRenderers = contentRendererRegistry.getRenderers(bookmark); const defaultSection = availableRenderers.length > 0 ? availableRenderers[0].id : "cached"; @@ -135,6 +139,11 @@ export default function LinkContentSection({ <ScrollArea className="h-full"> <ReaderView className="prose mx-auto dark:prose-invert" + style={{ + fontFamily: READER_FONT_FAMILIES[settings.fontFamily], + fontSize: `${settings.fontSize}px`, + lineHeight: settings.lineHeight, + }} bookmarkId={bookmark.id} readOnly={!isOwner} /> @@ -213,17 +222,20 @@ export default function LinkContentSection({ </SelectContent> </Select> {section === "cached" && ( - <Tooltip> - <TooltipTrigger> - <Link - href={`/reader/${bookmark.id}`} - className={buttonVariants({ variant: "outline" })} - > - <ExpandIcon className="h-4 w-4" /> - </Link> - </TooltipTrigger> - <TooltipContent side="bottom">FullScreen</TooltipContent> - </Tooltip> + <> + <ReaderSettingsPopover /> + <Tooltip> + <TooltipTrigger> + <Link + href={`/reader/${bookmark.id}`} + className={buttonVariants({ variant: "outline" })} + > + <ExpandIcon className="h-4 w-4" /> + </Link> + </TooltipTrigger> + <TooltipContent side="bottom">FullScreen</TooltipContent> + </Tooltip> + </> )} </div> {content} diff --git a/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx b/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx new file mode 100644 index 00000000..f37b8263 --- /dev/null +++ b/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx @@ -0,0 +1,457 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Slider } from "@/components/ui/slider"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; +import { + Globe, + Laptop, + Minus, + Plus, + RotateCcw, + Settings, + Type, + X, +} from "lucide-react"; + +import { + formatFontSize, + formatLineHeight, + READER_DEFAULTS, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; + +interface ReaderSettingsPopoverProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; + variant?: "outline" | "ghost"; +} + +export default function ReaderSettingsPopover({ + open, + onOpenChange, + variant = "outline", +}: ReaderSettingsPopoverProps) { + const { t } = useTranslation(); + const { + settings, + serverSettings, + localOverrides, + sessionOverrides, + hasSessionChanges, + hasLocalOverrides, + isSaving, + updateSession, + clearSession, + saveToDevice, + clearLocalOverride, + saveToServer, + } = useReaderSettings(); + + // Helper to get the effective server value (server setting or default) + const getServerValue = <K extends keyof typeof serverSettings>(key: K) => { + return serverSettings[key] ?? READER_DEFAULTS[key]; + }; + + // Helper to check if a setting has a local override + const hasLocalOverride = (key: keyof typeof localOverrides) => { + return localOverrides[key] !== undefined; + }; + + // Build tooltip message for the settings button + const getSettingsTooltip = () => { + if (hasSessionChanges && hasLocalOverrides) { + return t("settings.info.reader_settings.tooltip_preview_and_local"); + } + if (hasSessionChanges) { + return t("settings.info.reader_settings.tooltip_preview"); + } + if (hasLocalOverrides) { + return t("settings.info.reader_settings.tooltip_local"); + } + return t("settings.info.reader_settings.tooltip_default"); + }; + + return ( + <Popover open={open} onOpenChange={onOpenChange}> + <Tooltip> + <TooltipTrigger asChild> + <PopoverTrigger asChild> + <Button variant={variant} size="icon" className="relative"> + <Settings className="h-4 w-4" /> + {(hasSessionChanges || hasLocalOverrides) && ( + <span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary" /> + )} + </Button> + </PopoverTrigger> + </TooltipTrigger> + <TooltipContent side="bottom"> + <p>{getSettingsTooltip()}</p> + </TooltipContent> + </Tooltip> + <PopoverContent + side="bottom" + align="center" + collisionPadding={32} + className="flex w-80 flex-col overflow-hidden p-0" + style={{ + maxHeight: "var(--radix-popover-content-available-height)", + }} + > + <div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4"> + <div className="flex items-center justify-between pb-2"> + <div className="flex items-center gap-2"> + <Type className="h-4 w-4" /> + <h3 className="font-semibold"> + {t("settings.info.reader_settings.title")} + </h3> + </div> + {hasSessionChanges && ( + <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"> + {t("settings.info.reader_settings.preview")} + </span> + )} + </div> + + <div className="space-y-4"> + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_family")} + </label> + <div className="flex items-center gap-1"> + {sessionOverrides.fontFamily !== undefined && ( + <span className="text-xs text-muted-foreground"> + {t("settings.info.reader_settings.preview_inline")} + </span> + )} + {hasLocalOverride("fontFamily") && + sessionOverrides.fontFamily === undefined && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-5 w-5 text-muted-foreground hover:text-foreground" + onClick={() => clearLocalOverride("fontFamily")} + > + <X className="h-3 w-3" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {t( + "settings.info.reader_settings.clear_override_hint", + { + value: t( + `settings.info.reader_settings.${getServerValue("fontFamily")}` as const, + ), + }, + )} + </p> + </TooltipContent> + </Tooltip> + )} + </div> + </div> + <Select + value={settings.fontFamily} + onValueChange={(value) => + updateSession({ + fontFamily: value as "serif" | "sans" | "mono", + }) + } + > + <SelectTrigger + className={ + hasLocalOverride("fontFamily") && + sessionOverrides.fontFamily === undefined + ? "border-primary/50" + : "" + } + > + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="serif"> + {t("settings.info.reader_settings.serif")} + </SelectItem> + <SelectItem value="sans"> + {t("settings.info.reader_settings.sans")} + </SelectItem> + <SelectItem value="mono"> + {t("settings.info.reader_settings.mono")} + </SelectItem> + </SelectContent> + </Select> + </div> + + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_size")} + </label> + <div className="flex items-center gap-1"> + <span className="text-sm text-muted-foreground"> + {formatFontSize(settings.fontSize)} + {sessionOverrides.fontSize !== undefined && ( + <span className="ml-1 text-xs"> + {t("settings.info.reader_settings.preview_inline")} + </span> + )} + </span> + {hasLocalOverride("fontSize") && + sessionOverrides.fontSize === undefined && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-5 w-5 text-muted-foreground hover:text-foreground" + onClick={() => clearLocalOverride("fontSize")} + > + <X className="h-3 w-3" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {t( + "settings.info.reader_settings.clear_override_hint", + { + value: formatFontSize( + getServerValue("fontSize"), + ), + }, + )} + </p> + </TooltipContent> + </Tooltip> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + fontSize: Math.max( + READER_SETTING_CONSTRAINTS.fontSize.min, + settings.fontSize - + READER_SETTING_CONSTRAINTS.fontSize.step, + ), + }) + } + > + <Minus className="h-3 w-3" /> + </Button> + <Slider + value={[settings.fontSize]} + onValueChange={([value]) => + updateSession({ fontSize: value }) + } + max={READER_SETTING_CONSTRAINTS.fontSize.max} + min={READER_SETTING_CONSTRAINTS.fontSize.min} + step={READER_SETTING_CONSTRAINTS.fontSize.step} + className={`flex-1 ${ + hasLocalOverride("fontSize") && + sessionOverrides.fontSize === undefined + ? "[&_[role=slider]]:border-primary/50" + : "" + }`} + /> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + fontSize: Math.min( + READER_SETTING_CONSTRAINTS.fontSize.max, + settings.fontSize + + READER_SETTING_CONSTRAINTS.fontSize.step, + ), + }) + } + > + <Plus className="h-3 w-3" /> + </Button> + </div> + </div> + + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium"> + {t("settings.info.reader_settings.line_height")} + </label> + <div className="flex items-center gap-1"> + <span className="text-sm text-muted-foreground"> + {formatLineHeight(settings.lineHeight)} + {sessionOverrides.lineHeight !== undefined && ( + <span className="ml-1 text-xs"> + {t("settings.info.reader_settings.preview_inline")} + </span> + )} + </span> + {hasLocalOverride("lineHeight") && + sessionOverrides.lineHeight === undefined && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-5 w-5 text-muted-foreground hover:text-foreground" + onClick={() => clearLocalOverride("lineHeight")} + > + <X className="h-3 w-3" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {t( + "settings.info.reader_settings.clear_override_hint", + { + value: formatLineHeight( + getServerValue("lineHeight"), + ), + }, + )} + </p> + </TooltipContent> + </Tooltip> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + lineHeight: Math.max( + READER_SETTING_CONSTRAINTS.lineHeight.min, + Math.round( + (settings.lineHeight - + READER_SETTING_CONSTRAINTS.lineHeight.step) * + 10, + ) / 10, + ), + }) + } + > + <Minus className="h-3 w-3" /> + </Button> + <Slider + value={[settings.lineHeight]} + onValueChange={([value]) => + updateSession({ lineHeight: value }) + } + max={READER_SETTING_CONSTRAINTS.lineHeight.max} + min={READER_SETTING_CONSTRAINTS.lineHeight.min} + step={READER_SETTING_CONSTRAINTS.lineHeight.step} + className={`flex-1 ${ + hasLocalOverride("lineHeight") && + sessionOverrides.lineHeight === undefined + ? "[&_[role=slider]]:border-primary/50" + : "" + }`} + /> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + lineHeight: Math.min( + READER_SETTING_CONSTRAINTS.lineHeight.max, + Math.round( + (settings.lineHeight + + READER_SETTING_CONSTRAINTS.lineHeight.step) * + 10, + ) / 10, + ), + }) + } + > + <Plus className="h-3 w-3" /> + </Button> + </div> + </div> + + {hasSessionChanges && ( + <> + <Separator /> + + <div className="space-y-2"> + <Button + variant="outline" + size="sm" + className="w-full" + onClick={() => clearSession()} + > + <RotateCcw className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.reset_preview")} + </Button> + + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + className="flex-1" + disabled={isSaving} + onClick={() => saveToDevice()} + > + <Laptop className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.save_to_device")} + </Button> + <Button + variant="default" + size="sm" + className="flex-1" + disabled={isSaving} + onClick={() => saveToServer()} + > + <Globe className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.save_to_all_devices")} + </Button> + </div> + + <p className="text-center text-xs text-muted-foreground"> + {t("settings.info.reader_settings.save_hint")} + </p> + </div> + </> + )} + + {!hasSessionChanges && ( + <p className="text-center text-xs text-muted-foreground"> + {t("settings.info.reader_settings.adjust_hint")} + </p> + )} + </div> + </div> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/web/components/settings/ReaderSettings.tsx b/apps/web/components/settings/ReaderSettings.tsx new file mode 100644 index 00000000..ce4017c7 --- /dev/null +++ b/apps/web/components/settings/ReaderSettings.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useState } from "react"; +import { useClientConfig } from "@/lib/clientConfig"; +import { useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; +import { AlertTriangle, BookOpen, Laptop, RotateCcw } from "lucide-react"; + +import { + formatFontSize, + formatLineHeight, + READER_DEFAULTS, + READER_FONT_FAMILIES, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; + +import { Alert, AlertDescription } from "../ui/alert"; +import { Button } from "../ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../ui/card"; +import { Label } from "../ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { Slider } from "../ui/slider"; +import { toast } from "../ui/use-toast"; + +export default function ReaderSettings() { + const { t } = useTranslation(); + const clientConfig = useClientConfig(); + const { + settings, + serverSettings, + localOverrides, + hasLocalOverrides, + clearServerDefaults, + clearLocalOverrides, + updateServerSetting, + } = useReaderSettings(); + + // Local state for slider dragging (null = not dragging, use server value) + const [draggingFontSize, setDraggingFontSize] = useState<number | null>(null); + const [draggingLineHeight, setDraggingLineHeight] = useState<number | null>( + null, + ); + + const hasServerSettings = + serverSettings.fontSize !== null || + serverSettings.lineHeight !== null || + serverSettings.fontFamily !== null; + + const handleClearDefaults = () => { + clearServerDefaults(); + toast({ description: t("settings.info.reader_settings.defaults_cleared") }); + }; + + const handleClearLocalOverrides = () => { + clearLocalOverrides(); + toast({ + description: t("settings.info.reader_settings.local_overrides_cleared"), + }); + }; + + // Format local override for display + const formatLocalOverride = ( + key: "fontSize" | "lineHeight" | "fontFamily", + ) => { + const value = localOverrides[key]; + if (value === undefined) return null; + if (key === "fontSize") return formatFontSize(value as number); + if (key === "lineHeight") return formatLineHeight(value as number); + if (key === "fontFamily") { + switch (value) { + case "serif": + return t("settings.info.reader_settings.serif"); + case "sans": + return t("settings.info.reader_settings.sans"); + case "mono": + return t("settings.info.reader_settings.mono"); + } + } + return String(value); + }; + + return ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-xl"> + <BookOpen className="h-5 w-5" /> + {t("settings.info.reader_settings.title")} + </CardTitle> + <CardDescription> + {t("settings.info.reader_settings.description")} + </CardDescription> + </CardHeader> + <CardContent className="space-y-6"> + {/* Local Overrides Warning */} + {hasLocalOverrides && ( + <Alert> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription className="flex flex-col gap-3"> + <div> + <p className="font-medium"> + {t("settings.info.reader_settings.local_overrides_title")} + </p> + <p className="mt-1 text-sm text-muted-foreground"> + {t( + "settings.info.reader_settings.local_overrides_description", + )} + </p> + <ul className="mt-2 text-sm text-muted-foreground"> + {localOverrides.fontFamily !== undefined && ( + <li> + {t("settings.info.reader_settings.font_family")}:{" "} + {formatLocalOverride("fontFamily")} + </li> + )} + {localOverrides.fontSize !== undefined && ( + <li> + {t("settings.info.reader_settings.font_size")}:{" "} + {formatLocalOverride("fontSize")} + </li> + )} + {localOverrides.lineHeight !== undefined && ( + <li> + {t("settings.info.reader_settings.line_height")}:{" "} + {formatLocalOverride("lineHeight")} + </li> + )} + </ul> + </div> + <Button + variant="outline" + size="sm" + onClick={handleClearLocalOverrides} + className="w-fit" + > + <Laptop className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.clear_local_overrides")} + </Button> + </AlertDescription> + </Alert> + )} + + {/* Font Family */} + <div className="space-y-2"> + <Label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_family")} + </Label> + <Select + disabled={!!clientConfig.demoMode} + value={serverSettings.fontFamily ?? "not-set"} + onValueChange={(value) => { + if (value !== "not-set") { + updateServerSetting({ + fontFamily: value as "serif" | "sans" | "mono", + }); + } + }} + > + <SelectTrigger className="h-11"> + <SelectValue + placeholder={t("settings.info.reader_settings.not_set")} + /> + </SelectTrigger> + <SelectContent> + <SelectItem value="not-set" disabled> + {t("settings.info.reader_settings.not_set")} ( + {t("common.default")}: {READER_DEFAULTS.fontFamily}) + </SelectItem> + <SelectItem value="serif"> + {t("settings.info.reader_settings.serif")} + </SelectItem> + <SelectItem value="sans"> + {t("settings.info.reader_settings.sans")} + </SelectItem> + <SelectItem value="mono"> + {t("settings.info.reader_settings.mono")} + </SelectItem> + </SelectContent> + </Select> + {serverSettings.fontFamily === null && ( + <p className="text-xs text-muted-foreground"> + {t("settings.info.reader_settings.using_default")}:{" "} + {READER_DEFAULTS.fontFamily} + </p> + )} + </div> + + {/* Font Size */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <Label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_size")} + </Label> + <span className="text-sm text-muted-foreground"> + {formatFontSize(draggingFontSize ?? settings.fontSize)} + {serverSettings.fontSize === null && + draggingFontSize === null && + ` (${t("common.default").toLowerCase()})`} + </span> + </div> + <Slider + disabled={!!clientConfig.demoMode} + value={[draggingFontSize ?? settings.fontSize]} + onValueChange={([value]) => setDraggingFontSize(value)} + onValueCommit={([value]) => { + updateServerSetting({ fontSize: value }); + setDraggingFontSize(null); + }} + max={READER_SETTING_CONSTRAINTS.fontSize.max} + min={READER_SETTING_CONSTRAINTS.fontSize.min} + step={READER_SETTING_CONSTRAINTS.fontSize.step} + /> + </div> + + {/* Line Height */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <Label className="text-sm font-medium"> + {t("settings.info.reader_settings.line_height")} + </Label> + <span className="text-sm text-muted-foreground"> + {formatLineHeight(draggingLineHeight ?? settings.lineHeight)} + {serverSettings.lineHeight === null && + draggingLineHeight === null && + ` (${t("common.default").toLowerCase()})`} + </span> + </div> + <Slider + disabled={!!clientConfig.demoMode} + value={[draggingLineHeight ?? settings.lineHeight]} + onValueChange={([value]) => setDraggingLineHeight(value)} + onValueCommit={([value]) => { + updateServerSetting({ lineHeight: value }); + setDraggingLineHeight(null); + }} + max={READER_SETTING_CONSTRAINTS.lineHeight.max} + min={READER_SETTING_CONSTRAINTS.lineHeight.min} + step={READER_SETTING_CONSTRAINTS.lineHeight.step} + /> + </div> + + {/* Clear Defaults Button */} + {hasServerSettings && ( + <Button + variant="outline" + onClick={handleClearDefaults} + className="w-full" + disabled={!!clientConfig.demoMode} + > + <RotateCcw className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.clear_defaults")} + </Button> + )} + + {/* Preview */} + <div className="rounded-lg border p-4"> + <p className="mb-2 text-sm font-medium text-muted-foreground"> + {t("settings.info.reader_settings.preview")} + </p> + <p + style={{ + fontFamily: READER_FONT_FAMILIES[settings.fontFamily], + fontSize: `${draggingFontSize ?? settings.fontSize}px`, + lineHeight: draggingLineHeight ?? settings.lineHeight, + }} + > + {t("settings.info.reader_settings.preview_text")} + <br /> + {t("settings.info.reader_settings.preview_text")} + <br /> + {t("settings.info.reader_settings.preview_text")} + </p> + </div> + </CardContent> + </Card> + ); +} diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 33c7d6e2..d05ca702 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -1,5 +1,6 @@ { "common": { + "default": "Default", "url": "URL", "name": "Name", "email": "Email", @@ -131,6 +132,38 @@ "show": "Show archived bookmarks in tags and lists", "hide": "Hide archived bookmarks in tags and lists" } + }, + "reader_settings": { + "title": "Reader Settings", + "description": "Configure default text settings for the reader view. These settings sync across all your devices.", + "font_family": "Font Family", + "font_size": "Font Size", + "line_height": "Line Height", + "save_as_default": "Save as default", + "clear_defaults": "Clear all defaults", + "not_set": "Not set", + "using_default": "Using client default", + "preview": "Preview", + "preview_text": "The quick brown fox jumps over the lazy dog. This is how your reader view text will appear.", + "defaults_cleared": "Reader defaults have been cleared", + "local_overrides_title": "Device-specific settings active", + "local_overrides_description": "This device has reader settings that differ from your global defaults:", + "local_overrides_cleared": "Device-specific settings have been cleared", + "clear_local_overrides": "Clear device settings", + "serif": "Serif", + "sans": "Sans Serif", + "mono": "Monospace", + "tooltip_default": "Reading settings", + "tooltip_preview": "Unsaved preview changes", + "tooltip_local": "Device settings differ from global", + "tooltip_preview_and_local": "Unsaved preview changes; device settings differ from global", + "reset_preview": "Reset preview", + "save_to_device": "This device", + "save_to_all_devices": "All devices", + "save_hint": "Save settings for this device only or sync across all devices", + "adjust_hint": "Adjust settings above to preview changes", + "clear_override_hint": "Clear device override to use global setting ({{value}})", + "preview_inline": "(preview)" } }, "stats": { diff --git a/apps/web/lib/readerSettings.tsx b/apps/web/lib/readerSettings.tsx new file mode 100644 index 00000000..5966f287 --- /dev/null +++ b/apps/web/lib/readerSettings.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +import { + ReaderSettingsProvider as BaseReaderSettingsProvider, + useReaderSettingsContext, +} from "@karakeep/shared-react/hooks/reader-settings"; +import { + ReaderSettings, + ReaderSettingsPartial, +} from "@karakeep/shared/types/readers"; + +const LOCAL_STORAGE_KEY = "karakeep-reader-settings"; + +function getLocalOverridesFromStorage(): ReaderSettingsPartial { + if (typeof window === "undefined") return {}; + try { + const stored = localStorage.getItem(LOCAL_STORAGE_KEY); + return stored ? JSON.parse(stored) : {}; + } catch { + return {}; + } +} + +function saveLocalOverridesToStorage(overrides: ReaderSettingsPartial): void { + if (typeof window === "undefined") return; + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(overrides)); +} + +// Session overrides context - web-specific feature for live preview +interface SessionOverridesContextValue { + sessionOverrides: ReaderSettingsPartial; + setSessionOverrides: React.Dispatch< + React.SetStateAction<ReaderSettingsPartial> + >; +} + +const SessionOverridesContext = + createContext<SessionOverridesContextValue | null>(null); + +export function ReaderSettingsProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [sessionOverrides, setSessionOverrides] = + useState<ReaderSettingsPartial>({}); + + const sessionValue = useMemo( + () => ({ + sessionOverrides, + setSessionOverrides, + }), + [sessionOverrides], + ); + + // Memoize callbacks to prevent unnecessary re-renders + const getLocalOverrides = useCallback(getLocalOverridesFromStorage, []); + const saveLocalOverrides = useCallback(saveLocalOverridesToStorage, []); + const onClearSessionOverrides = useCallback(() => { + setSessionOverrides({}); + }, []); + + return ( + <BaseReaderSettingsProvider + getLocalOverrides={getLocalOverrides} + saveLocalOverrides={saveLocalOverrides} + sessionOverrides={sessionOverrides} + onClearSessionOverrides={onClearSessionOverrides} + > + <SessionOverridesContext.Provider value={sessionValue}> + {children} + </SessionOverridesContext.Provider> + </BaseReaderSettingsProvider> + ); +} + +export function useReaderSettings() { + const sessionContext = useContext(SessionOverridesContext); + if (!sessionContext) { + throw new Error( + "useReaderSettings must be used within a ReaderSettingsProvider", + ); + } + + const { sessionOverrides, setSessionOverrides } = sessionContext; + const baseSettings = useReaderSettingsContext(); + + // Update session override (live preview, not persisted) + const updateSession = useCallback( + (updates: ReaderSettingsPartial) => { + setSessionOverrides((prev) => ({ ...prev, ...updates })); + }, + [setSessionOverrides], + ); + + // Clear all session overrides + const clearSession = useCallback(() => { + setSessionOverrides({}); + }, [setSessionOverrides]); + + // Save current settings to local storage (this device only) + const saveToDevice = useCallback(() => { + const newLocalOverrides = { + ...baseSettings.localOverrides, + ...sessionOverrides, + }; + baseSettings.setLocalOverrides(newLocalOverrides); + saveLocalOverridesToStorage(newLocalOverrides); + setSessionOverrides({}); + }, [baseSettings, sessionOverrides, setSessionOverrides]); + + // Clear a single local override + const clearLocalOverride = useCallback( + (key: keyof ReaderSettings) => { + baseSettings.clearLocal(key); + }, + [baseSettings], + ); + + // Check if there are unsaved session changes + const hasSessionChanges = Object.keys(sessionOverrides).length > 0; + + return { + // Current effective settings (what should be displayed) + settings: baseSettings.settings, + + // Raw values for UI indicators + serverSettings: baseSettings.serverDefaults, + localOverrides: baseSettings.localOverrides, + sessionOverrides, + + // State indicators + hasSessionChanges, + hasLocalOverrides: baseSettings.hasLocalOverrides, + isSaving: baseSettings.isSaving, + + // Actions + updateSession, + clearSession, + saveToDevice, + clearLocalOverrides: baseSettings.clearAllLocal, + clearLocalOverride, + saveToServer: baseSettings.saveAsDefault, + updateServerSetting: baseSettings.saveAsDefault, + clearServerDefaults: baseSettings.clearAllDefaults, + }; +} diff --git a/apps/web/lib/userSettings.tsx b/apps/web/lib/userSettings.tsx index c7a133b7..2bb7c8a5 100644 --- a/apps/web/lib/userSettings.tsx +++ b/apps/web/lib/userSettings.tsx @@ -13,6 +13,9 @@ export const UserSettingsContext = createContext<ZUserSettings>({ backupsEnabled: false, backupsFrequency: "daily", backupsRetentionDays: 7, + readerFontSize: null, + readerLineHeight: null, + readerFontFamily: null, }); export function UserSettingsContextProvider({ diff --git a/packages/db/drizzle/0070_add_reader_settings.sql b/packages/db/drizzle/0070_add_reader_settings.sql new file mode 100644 index 00000000..6a49c122 --- /dev/null +++ b/packages/db/drizzle/0070_add_reader_settings.sql @@ -0,0 +1,3 @@ +ALTER TABLE `user` ADD `readerFontSize` integer;--> statement-breakpoint +ALTER TABLE `user` ADD `readerLineHeight` real;--> statement-breakpoint +ALTER TABLE `user` ADD `readerFontFamily` text;
\ No newline at end of file diff --git a/packages/db/drizzle/meta/0070_snapshot.json b/packages/db/drizzle/meta/0070_snapshot.json new file mode 100644 index 00000000..4b9521e3 --- /dev/null +++ b/packages/db/drizzle/meta/0070_snapshot.json @@ -0,0 +1,2969 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ff48e807-eda8-4a9f-ab94-afd693f415eb", + "prevId": "d8334431-a9bc-4397-8bc8-21b09d7cd4b2", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyId": { + "name": "keyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apiKey_keyId_unique": { + "name": "apiKey_keyId_unique", + "columns": [ + "keyId" + ], + "isUnique": true + }, + "apiKey_name_userId_unique": { + "name": "apiKey_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "apiKey_userId_user_id_fk": { + "name": "apiKey_userId_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assets": { + "name": "assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "contentType": { + "name": "contentType", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assets_bookmarkId_idx": { + "name": "assets_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "assets_assetType_idx": { + "name": "assets_assetType_idx", + "columns": [ + "assetType" + ], + "isUnique": false + }, + "assets_userId_idx": { + "name": "assets_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "assets_bookmarkId_bookmarks_id_fk": { + "name": "assets_bookmarkId_bookmarks_id_fk", + "tableFrom": "assets", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_userId_user_id_fk": { + "name": "assets_userId_user_id_fk", + "tableFrom": "assets", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backups": { + "name": "backups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assetId": { + "name": "assetId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkCount": { + "name": "bookmarkCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "backups_userId_idx": { + "name": "backups_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "backups_createdAt_idx": { + "name": "backups_createdAt_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "backups_userId_user_id_fk": { + "name": "backups_userId_user_id_fk", + "tableFrom": "backups", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backups_assetId_assets_id_fk": { + "name": "backups_assetId_assets_id_fk", + "tableFrom": "backups", + "tableTo": "assets", + "columnsFrom": [ + "assetId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkAssets": { + "name": "bookmarkAssets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assetId": { + "name": "assetId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkAssets_id_bookmarks_id_fk": { + "name": "bookmarkAssets_id_bookmarks_id_fk", + "tableFrom": "bookmarkAssets", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLinks": { + "name": "bookmarkLinks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "datePublished": { + "name": "datePublished", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dateModified": { + "name": "dateModified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "htmlContent": { + "name": "htmlContent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contentAssetId": { + "name": "contentAssetId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawledAt": { + "name": "crawledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawlStatus": { + "name": "crawlStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "crawlStatusCode": { + "name": "crawlStatusCode", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 200 + } + }, + "indexes": { + "bookmarkLinks_url_idx": { + "name": "bookmarkLinks_url_idx", + "columns": [ + "url" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLinks_id_bookmarks_id_fk": { + "name": "bookmarkLinks_id_bookmarks_id_fk", + "tableFrom": "bookmarkLinks", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLists": { + "name": "bookmarkLists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rssToken": { + "name": "rssToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "bookmarkLists_userId_idx": { + "name": "bookmarkLists_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkLists_userId_id_idx": { + "name": "bookmarkLists_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkLists_userId_user_id_fk": { + "name": "bookmarkLists_userId_user_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarkLists_parentId_bookmarkLists_id_fk": { + "name": "bookmarkLists_parentId_bookmarkLists_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTags": { + "name": "bookmarkTags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkTags_name_idx": { + "name": "bookmarkTags_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "bookmarkTags_userId_idx": { + "name": "bookmarkTags_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkTags_userId_name_unique": { + "name": "bookmarkTags_userId_name_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + }, + "bookmarkTags_userId_id_idx": { + "name": "bookmarkTags_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkTags_userId_user_id_fk": { + "name": "bookmarkTags_userId_user_id_fk", + "tableFrom": "bookmarkTags", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTexts": { + "name": "bookmarkTexts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkTexts_id_bookmarks_id_fk": { + "name": "bookmarkTexts_id_bookmarks_id_fk", + "tableFrom": "bookmarkTexts", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarks": { + "name": "bookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "favourited": { + "name": "favourited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taggingStatus": { + "name": "taggingStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summarizationStatus": { + "name": "summarizationStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarks_userId_idx": { + "name": "bookmarks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarks_createdAt_idx": { + "name": "bookmarks_createdAt_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + }, + "bookmarks_userId_createdAt_id_idx": { + "name": "bookmarks_userId_createdAt_id_idx", + "columns": [ + "userId", + "createdAt", + "id" + ], + "isUnique": false + }, + "bookmarks_userId_archived_createdAt_id_idx": { + "name": "bookmarks_userId_archived_createdAt_id_idx", + "columns": [ + "userId", + "archived", + "createdAt", + "id" + ], + "isUnique": false + }, + "bookmarks_userId_favourited_createdAt_id_idx": { + "name": "bookmarks_userId_favourited_createdAt_id_idx", + "columns": [ + "userId", + "favourited", + "createdAt", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarks_userId_user_id_fk": { + "name": "bookmarks_userId_user_id_fk", + "tableFrom": "bookmarks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarksInLists": { + "name": "bookmarksInLists", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "listMembershipId": { + "name": "listMembershipId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarksInLists_bookmarkId_idx": { + "name": "bookmarksInLists_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "bookmarksInLists_listId_idx": { + "name": "bookmarksInLists_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + }, + "bookmarksInLists_listId_bookmarkId_idx": { + "name": "bookmarksInLists_listId_bookmarkId_idx", + "columns": [ + "listId", + "bookmarkId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarksInLists_bookmarkId_bookmarks_id_fk": { + "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listId_bookmarkLists_id_fk": { + "name": "bookmarksInLists_listId_bookmarkLists_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listMembershipId_listCollaborators_id_fk": { + "name": "bookmarksInLists_listMembershipId_listCollaborators_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "listCollaborators", + "columnsFrom": [ + "listMembershipId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarksInLists_bookmarkId_listId_pk": { + "columns": [ + "bookmarkId", + "listId" + ], + "name": "bookmarksInLists_bookmarkId_listId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "customPrompts": { + "name": "customPrompts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appliesTo": { + "name": "appliesTo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "customPrompts_userId_idx": { + "name": "customPrompts_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "customPrompts_userId_user_id_fk": { + "name": "customPrompts_userId_user_id_fk", + "tableFrom": "customPrompts", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "highlights": { + "name": "highlights", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "startOffset": { + "name": "startOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endOffset": { + "name": "endOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'yellow'" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "highlights_bookmarkId_idx": { + "name": "highlights_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "highlights_userId_idx": { + "name": "highlights_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "highlights_bookmarkId_bookmarks_id_fk": { + "name": "highlights_bookmarkId_bookmarks_id_fk", + "tableFrom": "highlights", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "highlights_userId_user_id_fk": { + "name": "highlights_userId_user_id_fk", + "tableFrom": "highlights", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "importSessionBookmarks": { + "name": "importSessionBookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "importSessionId": { + "name": "importSessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "importSessionBookmarks_sessionId_idx": { + "name": "importSessionBookmarks_sessionId_idx", + "columns": [ + "importSessionId" + ], + "isUnique": false + }, + "importSessionBookmarks_bookmarkId_idx": { + "name": "importSessionBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "importSessionBookmarks_importSessionId_bookmarkId_unique": { + "name": "importSessionBookmarks_importSessionId_bookmarkId_unique", + "columns": [ + "importSessionId", + "bookmarkId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "importSessionBookmarks_importSessionId_importSessions_id_fk": { + "name": "importSessionBookmarks_importSessionId_importSessions_id_fk", + "tableFrom": "importSessionBookmarks", + "tableTo": "importSessions", + "columnsFrom": [ + "importSessionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "importSessionBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "importSessionBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "importSessionBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "importSessions": { + "name": "importSessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rootListId": { + "name": "rootListId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "importSessions_userId_idx": { + "name": "importSessions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "importSessions_userId_user_id_fk": { + "name": "importSessions_userId_user_id_fk", + "tableFrom": "importSessions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "importSessions_rootListId_bookmarkLists_id_fk": { + "name": "importSessions_rootListId_bookmarkLists_id_fk", + "tableFrom": "importSessions", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "rootListId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invites": { + "name": "invites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitedBy": { + "name": "invitedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invites_token_unique": { + "name": "invites_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "invites_invitedBy_user_id_fk": { + "name": "invites_invitedBy_user_id_fk", + "tableFrom": "invites", + "tableTo": "user", + "columnsFrom": [ + "invitedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "listCollaborators": { + "name": "listCollaborators", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedBy": { + "name": "addedBy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "listCollaborators_listId_idx": { + "name": "listCollaborators_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + }, + "listCollaborators_userId_idx": { + "name": "listCollaborators_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "listCollaborators_listId_userId_unique": { + "name": "listCollaborators_listId_userId_unique", + "columns": [ + "listId", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "listCollaborators_listId_bookmarkLists_id_fk": { + "name": "listCollaborators_listId_bookmarkLists_id_fk", + "tableFrom": "listCollaborators", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listCollaborators_userId_user_id_fk": { + "name": "listCollaborators_userId_user_id_fk", + "tableFrom": "listCollaborators", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listCollaborators_addedBy_user_id_fk": { + "name": "listCollaborators_addedBy_user_id_fk", + "tableFrom": "listCollaborators", + "tableTo": "user", + "columnsFrom": [ + "addedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "listInvitations": { + "name": "listInvitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "invitedAt": { + "name": "invitedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedEmail": { + "name": "invitedEmail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitedBy": { + "name": "invitedBy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "listInvitations_listId_idx": { + "name": "listInvitations_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + }, + "listInvitations_userId_idx": { + "name": "listInvitations_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "listInvitations_status_idx": { + "name": "listInvitations_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "listInvitations_listId_userId_unique": { + "name": "listInvitations_listId_userId_unique", + "columns": [ + "listId", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "listInvitations_listId_bookmarkLists_id_fk": { + "name": "listInvitations_listId_bookmarkLists_id_fk", + "tableFrom": "listInvitations", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listInvitations_userId_user_id_fk": { + "name": "listInvitations_userId_user_id_fk", + "tableFrom": "listInvitations", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listInvitations_invitedBy_user_id_fk": { + "name": "listInvitations_invitedBy_user_id_fk", + "tableFrom": "listInvitations", + "tableTo": "user", + "columnsFrom": [ + "invitedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passwordResetToken": { + "name": "passwordResetToken", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "passwordResetToken_token_unique": { + "name": "passwordResetToken_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "passwordResetTokens_userId_idx": { + "name": "passwordResetTokens_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passwordResetToken_userId_user_id_fk": { + "name": "passwordResetToken_userId_user_id_fk", + "tableFrom": "passwordResetToken", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeedImports": { + "name": "rssFeedImports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entryId": { + "name": "entryId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rssFeedId": { + "name": "rssFeedId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rssFeedImports_feedIdIdx_idx": { + "name": "rssFeedImports_feedIdIdx_idx", + "columns": [ + "rssFeedId" + ], + "isUnique": false + }, + "rssFeedImports_entryIdIdx_idx": { + "name": "rssFeedImports_entryIdIdx_idx", + "columns": [ + "entryId" + ], + "isUnique": false + }, + "rssFeedImports_rssFeedId_bookmarkId_idx": { + "name": "rssFeedImports_rssFeedId_bookmarkId_idx", + "columns": [ + "rssFeedId", + "bookmarkId" + ], + "isUnique": false + }, + "rssFeedImports_rssFeedId_entryId_unique": { + "name": "rssFeedImports_rssFeedId_entryId_unique", + "columns": [ + "rssFeedId", + "entryId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rssFeedImports_rssFeedId_rssFeeds_id_fk": { + "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "rssFeeds", + "columnsFrom": [ + "rssFeedId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rssFeedImports_bookmarkId_bookmarks_id_fk": { + "name": "rssFeedImports_bookmarkId_bookmarks_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeeds": { + "name": "rssFeeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "importTags": { + "name": "importTags", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastFetchedAt": { + "name": "lastFetchedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastFetchedStatus": { + "name": "lastFetchedStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rssFeeds_userId_idx": { + "name": "rssFeeds_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rssFeeds_userId_user_id_fk": { + "name": "rssFeeds_userId_user_id_fk", + "tableFrom": "rssFeeds", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineActions": { + "name": "ruleEngineActions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ruleId": { + "name": "ruleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngineActions_userId_idx": { + "name": "ruleEngineActions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "ruleEngineActions_ruleId_idx": { + "name": "ruleEngineActions_ruleId_idx", + "columns": [ + "ruleId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineActions_userId_user_id_fk": { + "name": "ruleEngineActions_userId_user_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_ruleId_ruleEngineRules_id_fk": { + "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "ruleEngineRules", + "columnsFrom": [ + "ruleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_tagId_fk": { + "name": "ruleEngineActions_userId_tagId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_listId_fk": { + "name": "ruleEngineActions_userId_listId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineRules": { + "name": "ruleEngineRules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngine_userId_idx": { + "name": "ruleEngine_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineRules_userId_user_id_fk": { + "name": "ruleEngineRules_userId_user_id_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_tagId_fk": { + "name": "ruleEngineRules_userId_tagId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_listId_fk": { + "name": "ruleEngineRules_userId_listId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subscriptions": { + "name": "subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "priceId": { + "name": "priceId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "startDate": { + "name": "startDate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "endDate": { + "name": "endDate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "subscriptions_userId_unique": { + "name": "subscriptions_userId_unique", + "columns": [ + "userId" + ], + "isUnique": true + }, + "subscriptions_userId_idx": { + "name": "subscriptions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "subscriptions_stripeCustomerId_idx": { + "name": "subscriptions_stripeCustomerId_idx", + "columns": [ + "stripeCustomerId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subscriptions_userId_user_id_fk": { + "name": "subscriptions_userId_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tagsOnBookmarks": { + "name": "tagsOnBookmarks", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedAt": { + "name": "attachedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tagsOnBookmarks_tagId_idx": { + "name": "tagsOnBookmarks_tagId_idx", + "columns": [ + "tagId" + ], + "isUnique": false + }, + "tagsOnBookmarks_bookmarkId_idx": { + "name": "tagsOnBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "tagsOnBookmarks_tagId_bookmarkId_idx": { + "name": "tagsOnBookmarks_tagId_bookmarkId_idx", + "columns": [ + "tagId", + "bookmarkId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tagsOnBookmarks_tagId_bookmarkTags_id_fk": { + "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tagsOnBookmarks_bookmarkId_tagId_pk": { + "columns": [ + "bookmarkId", + "tagId" + ], + "name": "tagsOnBookmarks_bookmarkId_tagId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "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 + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + }, + "bookmarkQuota": { + "name": "bookmarkQuota", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storageQuota": { + "name": "storageQuota", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "browserCrawlingEnabled": { + "name": "browserCrawlingEnabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkClickAction": { + "name": "bookmarkClickAction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open_original_link'" + }, + "archiveDisplayBehaviour": { + "name": "archiveDisplayBehaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'show'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'UTC'" + }, + "backupsEnabled": { + "name": "backupsEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "backupsFrequency": { + "name": "backupsFrequency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'weekly'" + }, + "backupsRetentionDays": { + "name": "backupsRetentionDays", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "readerFontSize": { + "name": "readerFontSize", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readerLineHeight": { + "name": "readerLineHeight", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readerFontFamily": { + "name": "readerFontFamily", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhooks_userId_idx": { + "name": "webhooks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "webhooks_userId_user_id_fk": { + "name": "webhooks_userId_user_id_fk", + "tableFrom": "webhooks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +}
\ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 25b110c3..49e684a5 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -491,6 +491,13 @@ "when": 1765721715670, "tag": "0069_fix_pending_summarization", "breakpoints": true + }, + { + "idx": 70, + "version": "6", + "when": 1765744716304, + "tag": "0070_add_reader_settings", + "breakpoints": true } ] }
\ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index fdae5d4f..cc4d4b56 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -7,6 +7,7 @@ import { index, integer, primaryKey, + real, sqliteTable, text, unique, @@ -69,6 +70,13 @@ export const users = sqliteTable("user", { .notNull() .default("weekly"), backupsRetentionDays: integer("backupsRetentionDays").notNull().default(30), + + // Reader view settings (nullable = opt-in, null means use client default) + readerFontSize: integer("readerFontSize"), + readerLineHeight: real("readerLineHeight"), + readerFontFamily: text("readerFontFamily", { + enum: ["serif", "sans", "mono"], + }), }); export const accounts = sqliteTable( diff --git a/packages/shared-react/hooks/reader-settings.tsx b/packages/shared-react/hooks/reader-settings.tsx new file mode 100644 index 00000000..2705a050 --- /dev/null +++ b/packages/shared-react/hooks/reader-settings.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +import { + READER_DEFAULTS, + ReaderSettings, + ReaderSettingsPartial, +} from "@karakeep/shared/types/readers"; + +import { api } from "../trpc"; + +export interface UseReaderSettingsOptions { + /** + * Get local overrides (device-specific settings stored locally) + */ + getLocalOverrides: () => ReaderSettingsPartial; + /** + * Save local overrides to local storage + */ + saveLocalOverrides: (overrides: ReaderSettingsPartial) => void; + /** + * Optional session overrides (for live preview in web). + * If provided, these take highest precedence. + */ + sessionOverrides?: ReaderSettingsPartial; + /** + * Callback when session overrides should be cleared (after successful server save) + */ + onClearSessionOverrides?: () => void; +} + +export function useReaderSettings(options: UseReaderSettingsOptions) { + const { + getLocalOverrides, + saveLocalOverrides, + sessionOverrides = {}, + onClearSessionOverrides, + } = options; + + const [localOverrides, setLocalOverrides] = useState<ReaderSettingsPartial>( + {}, + ); + const [pendingServerSave, setPendingServerSave] = + useState<ReaderSettings | null>(null); + + const { data: serverSettings } = api.users.settings.useQuery(); + const apiUtils = api.useUtils(); + + // Load local overrides on mount + useEffect(() => { + setLocalOverrides(getLocalOverrides()); + }, [getLocalOverrides]); + + // Clear pending state when server settings match what we saved + useEffect(() => { + if (pendingServerSave && serverSettings) { + const serverMatches = + serverSettings.readerFontSize === pendingServerSave.fontSize && + // Tolerate minor float normalization differences for lineHeight + Math.abs( + (serverSettings.readerLineHeight ?? 0) - pendingServerSave.lineHeight, + ) < 1e-6 && + serverSettings.readerFontFamily === pendingServerSave.fontFamily; + if (serverMatches) { + setPendingServerSave(null); + } + } + }, [serverSettings, pendingServerSave]); + + const { mutate: updateServerSettings, isPending: isSaving } = + api.users.updateSettings.useMutation({ + onSettled: async () => { + await apiUtils.users.settings.refetch(); + }, + }); + + // Separate mutation for saving defaults (clears local overrides on success) + const { mutate: saveServerSettings, isPending: isSavingDefaults } = + api.users.updateSettings.useMutation({ + onSuccess: () => { + // Clear local and session overrides after successful server save + setLocalOverrides({}); + saveLocalOverrides({}); + onClearSessionOverrides?.(); + }, + onError: () => { + // Clear pending state so we don't show values that failed to persist + setPendingServerSave(null); + }, + onSettled: async () => { + await apiUtils.users.settings.refetch(); + }, + }); + + // Compute effective settings with precedence: session → local → pendingSave → server → default + const settings: ReaderSettings = useMemo( + () => ({ + fontSize: + sessionOverrides.fontSize ?? + localOverrides.fontSize ?? + pendingServerSave?.fontSize ?? + serverSettings?.readerFontSize ?? + READER_DEFAULTS.fontSize, + lineHeight: + sessionOverrides.lineHeight ?? + localOverrides.lineHeight ?? + pendingServerSave?.lineHeight ?? + serverSettings?.readerLineHeight ?? + READER_DEFAULTS.lineHeight, + fontFamily: + sessionOverrides.fontFamily ?? + localOverrides.fontFamily ?? + pendingServerSave?.fontFamily ?? + serverSettings?.readerFontFamily ?? + READER_DEFAULTS.fontFamily, + }), + [sessionOverrides, localOverrides, pendingServerSave, serverSettings], + ); + + // Get the server setting values (for UI indicators) + const serverDefaults: ReaderSettingsPartial = useMemo( + () => ({ + fontSize: serverSettings?.readerFontSize ?? undefined, + lineHeight: serverSettings?.readerLineHeight ?? undefined, + fontFamily: serverSettings?.readerFontFamily ?? undefined, + }), + [serverSettings], + ); + + // Update local override (per-device, immediate) + const updateLocal = useCallback( + (updates: ReaderSettingsPartial) => { + setLocalOverrides((prev) => { + const newOverrides = { ...prev, ...updates }; + saveLocalOverrides(newOverrides); + return newOverrides; + }); + }, + [saveLocalOverrides], + ); + + // Clear a specific local override + const clearLocal = useCallback( + (key: keyof ReaderSettings) => { + setLocalOverrides((prev) => { + const { [key]: _, ...rest } = prev; + saveLocalOverrides(rest); + return rest; + }); + }, + [saveLocalOverrides], + ); + + // Clear all local overrides + const clearAllLocal = useCallback(() => { + setLocalOverrides({}); + saveLocalOverrides({}); + }, [saveLocalOverrides]); + + // Save current effective settings as server default (syncs across devices) + const saveAsDefault = useCallback( + (settingsToSave?: ReaderSettingsPartial) => { + const toSave: ReaderSettings = { + fontSize: settingsToSave?.fontSize ?? settings.fontSize, + lineHeight: settingsToSave?.lineHeight ?? settings.lineHeight, + fontFamily: settingsToSave?.fontFamily ?? settings.fontFamily, + }; + // Set pending state to prevent flicker while server syncs + setPendingServerSave(toSave); + saveServerSettings({ + readerFontSize: toSave.fontSize, + readerLineHeight: toSave.lineHeight, + readerFontFamily: toSave.fontFamily, + }); + }, + [settings, saveServerSettings], + ); + + // Clear a specific server default (set to null) + const clearDefault = useCallback( + (key: keyof ReaderSettings) => { + const serverKeyMap = { + fontSize: "readerFontSize", + lineHeight: "readerLineHeight", + fontFamily: "readerFontFamily", + } as const; + updateServerSettings({ [serverKeyMap[key]]: null }); + }, + [updateServerSettings], + ); + + // Clear all server defaults + const clearAllDefaults = useCallback(() => { + updateServerSettings({ + readerFontSize: null, + readerLineHeight: null, + readerFontFamily: null, + }); + }, [updateServerSettings]); + + // Check if there are any local overrides + const hasLocalOverrides = Object.keys(localOverrides).length > 0; + + // Check if there are any server defaults + const hasServerDefaults = + serverSettings?.readerFontSize != null || + serverSettings?.readerLineHeight != null || + serverSettings?.readerFontFamily != null; + + return { + // Current effective settings (what should be displayed) + settings, + + // Raw values for UI indicators + localOverrides, + serverDefaults, + + // Status flags + hasLocalOverrides, + hasServerDefaults, + isSaving: isSaving || isSavingDefaults, + + // Internal state setters (for web's context-based approach) + setLocalOverrides, + + // Actions + updateLocal, + clearLocal, + clearAllLocal, + saveAsDefault, + clearDefault, + clearAllDefaults, + }; +} + +// Context for sharing reader settings state across components +export type ReaderSettingsContextValue = ReturnType<typeof useReaderSettings>; + +const ReaderSettingsContext = createContext<ReaderSettingsContextValue | null>( + null, +); + +export interface ReaderSettingsProviderProps extends UseReaderSettingsOptions { + children: ReactNode; +} + +/** + * Provider that creates a single instance of reader settings state + * and shares it across all child components. + */ +export function ReaderSettingsProvider({ + children, + ...options +}: ReaderSettingsProviderProps) { + const value = useReaderSettings(options); + + return ( + <ReaderSettingsContext.Provider value={value}> + {children} + </ReaderSettingsContext.Provider> + ); +} + +/** + * Hook to access shared reader settings from context. + * Must be used within a ReaderSettingsProvider. + */ +export function useReaderSettingsContext() { + const context = useContext(ReaderSettingsContext); + if (!context) { + throw new Error( + "useReaderSettingsContext must be used within a ReaderSettingsProvider", + ); + } + return context; +} diff --git a/packages/shared/types/readers.ts b/packages/shared/types/readers.ts new file mode 100644 index 00000000..117dd51b --- /dev/null +++ b/packages/shared/types/readers.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +import { ZReaderFontFamily, zReaderFontFamilySchema } from "./users"; + +export const READER_DEFAULTS = { + fontSize: 18, + lineHeight: 1.6, + fontFamily: "serif" as const, +} as const; + +export const READER_FONT_FAMILIES: Record<ZReaderFontFamily, string> = { + serif: "ui-serif, Georgia, Cambria, serif", + sans: "ui-sans-serif, system-ui, sans-serif", + mono: "ui-monospace, Menlo, Monaco, monospace", +} as const; + +// Setting constraints for UI controls +export const READER_SETTING_CONSTRAINTS = { + fontSize: { min: 12, max: 24, step: 1 }, + lineHeight: { min: 1.2, max: 2.5, step: 0.1 }, +} as const; + +// Formatting functions for display +export function formatFontSize(value: number): string { + return `${value}px`; +} + +export function formatLineHeight(value: number): string { + return value.toFixed(1); +} + +export function formatFontFamily( + value: ZReaderFontFamily, + t?: (key: string) => string, +): string { + if (t) { + return t(`settings.info.reader_settings.${value}`); + } + // Fallback labels when no translation function provided + switch (value) { + case "serif": + return "Serif"; + case "sans": + return "Sans Serif"; + case "mono": + return "Monospace"; + } +} + +export const zReaderSettings = z.object({ + fontSize: z.number().int().min(12).max(24), + lineHeight: z.number().min(1.2).max(2.5), + fontFamily: zReaderFontFamilySchema, +}); + +export type ReaderSettings = z.infer<typeof zReaderSettings>; + +export const zReaderSettingsPartial = zReaderSettings.partial(); +export type ReaderSettingsPartial = z.infer<typeof zReaderSettingsPartial>; diff --git a/packages/shared/types/users.ts b/packages/shared/types/users.ts index 9f020d52..73b99885 100644 --- a/packages/shared/types/users.ts +++ b/packages/shared/types/users.ts @@ -102,6 +102,9 @@ export const zUserStatsResponseSchema = z.object({ ), }); +export const zReaderFontFamilySchema = z.enum(["serif", "sans", "mono"]); +export type ZReaderFontFamily = z.infer<typeof zReaderFontFamilySchema>; + export const zUserSettingsSchema = z.object({ bookmarkClickAction: z.enum([ "open_original_link", @@ -112,6 +115,10 @@ export const zUserSettingsSchema = z.object({ backupsEnabled: z.boolean(), backupsFrequency: z.enum(["daily", "weekly"]), backupsRetentionDays: z.number().int().min(1).max(365), + // Reader settings (nullable = opt-in, null means use client default) + readerFontSize: z.number().int().min(12).max(24).nullable(), + readerLineHeight: z.number().min(1.2).max(2.5).nullable(), + readerFontFamily: zReaderFontFamilySchema.nullable(), }); export type ZUserSettings = z.infer<typeof zUserSettingsSchema>; @@ -123,6 +130,9 @@ export const zUpdateUserSettingsSchema = zUserSettingsSchema.partial().pick({ backupsEnabled: true, backupsFrequency: true, backupsRetentionDays: true, + readerFontSize: true, + readerLineHeight: true, + readerFontFamily: true, }); export const zUpdateBackupSettingsSchema = zUpdateUserSettingsSchema.pick({ diff --git a/packages/trpc/models/users.ts b/packages/trpc/models/users.ts index a1f32f02..85aed639 100644 --- a/packages/trpc/models/users.ts +++ b/packages/trpc/models/users.ts @@ -433,6 +433,9 @@ export class User { backupsEnabled: true, backupsFrequency: true, backupsRetentionDays: true, + readerFontSize: true, + readerLineHeight: true, + readerFontFamily: true, }, }); @@ -450,6 +453,9 @@ export class User { backupsEnabled: settings.backupsEnabled, backupsFrequency: settings.backupsFrequency, backupsRetentionDays: settings.backupsRetentionDays, + readerFontSize: settings.readerFontSize, + readerLineHeight: settings.readerLineHeight, + readerFontFamily: settings.readerFontFamily, }; } @@ -472,6 +478,9 @@ export class User { backupsEnabled: input.backupsEnabled, backupsFrequency: input.backupsFrequency, backupsRetentionDays: input.backupsRetentionDays, + readerFontSize: input.readerFontSize, + readerLineHeight: input.readerLineHeight, + readerFontFamily: input.readerFontFamily, }) .where(eq(users.id, this.user.id)); } @@ -553,23 +562,23 @@ export class User { // Top domains this.ctx.db .select({ - domain: sql<string>`CASE - WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN - CASE + domain: sql<string>`CASE + WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN + CASE WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') > 0 THEN SUBSTR(${bookmarkLinks.url}, 9, INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') - 1) ELSE SUBSTR(${bookmarkLinks.url}, 9) END - WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN - CASE + WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN + CASE WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') > 0 THEN SUBSTR(${bookmarkLinks.url}, 8, INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') - 1) ELSE SUBSTR(${bookmarkLinks.url}, 8) END - ELSE - CASE + ELSE + CASE WHEN INSTR(${bookmarkLinks.url}, '/') > 0 THEN SUBSTR(${bookmarkLinks.url}, 1, INSTR(${bookmarkLinks.url}, '/') - 1) ELSE @@ -582,23 +591,23 @@ export class User { .innerJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id)) .where(eq(bookmarks.userId, this.user.id)) .groupBy( - sql`CASE - WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN - CASE + sql`CASE + WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN + CASE WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') > 0 THEN SUBSTR(${bookmarkLinks.url}, 9, INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') - 1) ELSE SUBSTR(${bookmarkLinks.url}, 9) END - WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN - CASE + WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN + CASE WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') > 0 THEN SUBSTR(${bookmarkLinks.url}, 8, INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') - 1) ELSE SUBSTR(${bookmarkLinks.url}, 8) END - ELSE - CASE + ELSE + CASE WHEN INSTR(${bookmarkLinks.url}, '/') > 0 THEN SUBSTR(${bookmarkLinks.url}, 1, INSTR(${bookmarkLinks.url}, '/') - 1) ELSE diff --git a/packages/trpc/routers/users.test.ts b/packages/trpc/routers/users.test.ts index a2f2be9f..557d025d 100644 --- a/packages/trpc/routers/users.test.ts +++ b/packages/trpc/routers/users.test.ts @@ -158,6 +158,11 @@ describe("User Routes", () => { backupsEnabled: false, backupsFrequency: "weekly", backupsRetentionDays: 30, + + // Reader settings + readerFontFamily: null, + readerFontSize: null, + readerLineHeight: null, }); // Update settings @@ -166,6 +171,11 @@ describe("User Routes", () => { backupsEnabled: true, backupsFrequency: "daily", backupsRetentionDays: 7, + + // Reader settings + readerFontFamily: "serif", + readerFontSize: 12, + readerLineHeight: 1.5, }); // Verify updated settings @@ -177,6 +187,11 @@ describe("User Routes", () => { backupsEnabled: true, backupsFrequency: "daily", backupsRetentionDays: 7, + + // Reader settings + readerFontFamily: "serif", + readerFontSize: 12, + readerLineHeight: 1.5, }); // Test invalid update (e.g., empty input, if schema enforces it) |
