Support reading progress synchronization across platforms (#34)
This commit is contained in:
@@ -0,0 +1,209 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PostgrestError } from '@supabase/supabase-js';
|
||||||
|
import { supabase, createSupabaseClient } from '@/utils/supabase';
|
||||||
|
import { BookDataRecord } from '@/types/book';
|
||||||
|
import { transformBookConfigToDB } from '@/utils/transform';
|
||||||
|
import { transformBookNoteToDB } from '@/utils/transform';
|
||||||
|
import { transformBookToDB } from '@/utils/transform';
|
||||||
|
import { SyncData, SyncResult, SyncType } from '@/libs/sync';
|
||||||
|
|
||||||
|
const transformsToDB = {
|
||||||
|
books: transformBookToDB,
|
||||||
|
book_notes: transformBookNoteToDB,
|
||||||
|
book_configs: transformBookConfigToDB,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DBSyncTypeMap = {
|
||||||
|
books: 'books',
|
||||||
|
book_notes: 'notes',
|
||||||
|
book_configs: 'configs',
|
||||||
|
};
|
||||||
|
|
||||||
|
type TableName = keyof typeof transformsToDB;
|
||||||
|
|
||||||
|
type DBError = { table: TableName; error: PostgrestError };
|
||||||
|
|
||||||
|
const getUserAndToken = async (req: NextRequest) => {
|
||||||
|
const authHeader = req.headers.get('authorization');
|
||||||
|
if (!authHeader) return {};
|
||||||
|
|
||||||
|
const token = authHeader.replace('Bearer ', '');
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
error,
|
||||||
|
} = await supabase.auth.getUser(token);
|
||||||
|
|
||||||
|
if (error || !user) return {};
|
||||||
|
return { user, token };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { user, token } = await getUserAndToken(req);
|
||||||
|
if (!user || !token) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const supabase = createSupabaseClient(token);
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const sinceParam = searchParams.get('since');
|
||||||
|
const typeParam = searchParams.get('type') as SyncType | undefined;
|
||||||
|
const bookParam = searchParams.get('book');
|
||||||
|
|
||||||
|
if (!sinceParam) {
|
||||||
|
return NextResponse.json({ error: '"since" query parameter is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const since = new Date(Number(sinceParam));
|
||||||
|
if (isNaN(since.getTime())) {
|
||||||
|
return NextResponse.json({ error: 'Invalid "since" timestamp' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sinceIso = since.toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results: SyncResult = { books: [], configs: [], notes: [] };
|
||||||
|
const errors: Record<TableName, DBError | null> = {
|
||||||
|
books: null,
|
||||||
|
book_notes: null,
|
||||||
|
book_configs: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryTables = async (table: TableName) => {
|
||||||
|
let query = supabase.from(table).select('*').eq('user_id', user.id);
|
||||||
|
if (bookParam) {
|
||||||
|
query.eq('book_hash', bookParam);
|
||||||
|
}
|
||||||
|
query = query.or(`updated_at.gt.${sinceIso},deleted_at.gt.${sinceIso}`);
|
||||||
|
console.log('Querying table:', table, 'since:', sinceIso);
|
||||||
|
const { data, error } = await query;
|
||||||
|
if (error) throw { table, error } as DBError;
|
||||||
|
results[DBSyncTypeMap[table] as SyncType] = data || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!typeParam || typeParam === 'books') {
|
||||||
|
await queryTables('books').catch((err) => (errors['books'] = err));
|
||||||
|
}
|
||||||
|
if (!typeParam || typeParam === 'configs') {
|
||||||
|
await queryTables('book_configs').catch((err) => (errors['book_configs'] = err));
|
||||||
|
}
|
||||||
|
if (!typeParam || typeParam === 'notes') {
|
||||||
|
await queryTables('book_notes').catch((err) => (errors['book_notes'] = err));
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbErrors = Object.values(errors).filter((err) => err !== null);
|
||||||
|
if (dbErrors.length > 0) {
|
||||||
|
console.error('Errors occurred:', dbErrors);
|
||||||
|
const errorMsg = dbErrors
|
||||||
|
.map((err) => `${err.table}: ${err.error.message || 'Unknown error'}`)
|
||||||
|
.join('; ');
|
||||||
|
return NextResponse.json({ error: errorMsg }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(results, { status: 200 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(error);
|
||||||
|
const errorMessage = (error as PostgrestError).message || 'Unknown error';
|
||||||
|
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { user, token } = await getUserAndToken(req);
|
||||||
|
if (!user || !token) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const supabase = createSupabaseClient(token);
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { books = [], configs = [], notes = [] } = body as SyncData;
|
||||||
|
|
||||||
|
const upsertRecords = async (
|
||||||
|
table: TableName,
|
||||||
|
primaryKeys: (keyof BookDataRecord)[],
|
||||||
|
records: BookDataRecord[],
|
||||||
|
) => {
|
||||||
|
const authoritativeRecords: BookDataRecord[] = [];
|
||||||
|
|
||||||
|
for (const rec of records) {
|
||||||
|
const dbRec = transformsToDB[table](rec, user.id);
|
||||||
|
rec.user_id = user.id;
|
||||||
|
rec.book_hash = dbRec.book_hash;
|
||||||
|
const matchConditions: Record<string, string | number> = { user_id: user.id };
|
||||||
|
for (const pk of primaryKeys) {
|
||||||
|
matchConditions[pk] = rec[pk]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: serverData, error: fetchError } = await supabase
|
||||||
|
.from(table)
|
||||||
|
.select()
|
||||||
|
.match(matchConditions)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (fetchError && fetchError.code !== 'PGRST116') {
|
||||||
|
return { error: fetchError.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serverData) {
|
||||||
|
const { data: inserted, error: insertError } = await supabase
|
||||||
|
.from(table)
|
||||||
|
.insert(dbRec)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
if (insertError) return { error: insertError.message };
|
||||||
|
authoritativeRecords.push(inserted);
|
||||||
|
} else {
|
||||||
|
const clientUpdatedAt = dbRec.updated_at ? new Date(dbRec.updated_at).getTime() : 0;
|
||||||
|
const serverUpdatedAt = serverData.updated_at
|
||||||
|
? new Date(serverData.updated_at).getTime()
|
||||||
|
: 0;
|
||||||
|
const clientDeletedAt = dbRec.deleted_at ? new Date(dbRec.deleted_at).getTime() : 0;
|
||||||
|
const serverDeletedAt = serverData.deleted_at
|
||||||
|
? new Date(serverData.deleted_at).getTime()
|
||||||
|
: 0;
|
||||||
|
const clientIsNewer =
|
||||||
|
clientDeletedAt > serverDeletedAt || clientUpdatedAt > serverUpdatedAt;
|
||||||
|
|
||||||
|
if (clientIsNewer) {
|
||||||
|
const { data: updated, error: updateError } = await supabase
|
||||||
|
.from(table)
|
||||||
|
.update(dbRec)
|
||||||
|
.match(matchConditions)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
console.log('Updating record:', updated);
|
||||||
|
if (updateError) return { error: updateError.message };
|
||||||
|
authoritativeRecords.push(updated);
|
||||||
|
} else {
|
||||||
|
authoritativeRecords.push(serverData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: authoritativeRecords };
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [booksResult, configsResult, notesResult] = await Promise.all([
|
||||||
|
upsertRecords('books', ['book_hash'], books as BookDataRecord[]),
|
||||||
|
upsertRecords('book_configs', ['book_hash'], configs as BookDataRecord[]),
|
||||||
|
upsertRecords('book_notes', ['book_hash', 'id'], notes as BookDataRecord[]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (booksResult?.error) throw new Error(booksResult.error);
|
||||||
|
if (configsResult?.error) throw new Error(configsResult.error);
|
||||||
|
if (notesResult?.error) throw new Error(notesResult.error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
books: booksResult?.data || [],
|
||||||
|
configs: configsResult?.data || [],
|
||||||
|
notes: notesResult?.data || [],
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(error);
|
||||||
|
const errorMessage = (error as PostgrestError).message || 'Unknown error';
|
||||||
|
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,19 +26,19 @@ const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
|
|||||||
const booksGroup = acc[acc.findIndex((group) => group.name === book.group)];
|
const booksGroup = acc[acc.findIndex((group) => group.name === book.group)];
|
||||||
if (booksGroup) {
|
if (booksGroup) {
|
||||||
booksGroup.books.push(book);
|
booksGroup.books.push(book);
|
||||||
booksGroup.lastUpdated = Math.max(acc[groupIndex]!.lastUpdated, book.lastUpdated);
|
booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt);
|
||||||
} else {
|
} else {
|
||||||
acc.push({
|
acc.push({
|
||||||
name: book.group,
|
name: book.group,
|
||||||
books: [book],
|
books: [book],
|
||||||
lastUpdated: book.lastUpdated,
|
updatedAt: book.updatedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
const ungroupedBooks: Book[] = groups.find((group) => group.name === UNGROUPED_NAME)?.books || [];
|
const ungroupedBooks: Book[] = groups.find((group) => group.name === UNGROUPED_NAME)?.books || [];
|
||||||
const groupedBooks: BooksGroup[] = groups.filter((group) => group.name !== UNGROUPED_NAME);
|
const groupedBooks: BooksGroup[] = groups.filter((group) => group.name !== UNGROUPED_NAME);
|
||||||
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.lastUpdated - a.lastUpdated);
|
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface BookshelfProps {
|
interface BookshelfProps {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { setAboutDialogVisible } from '@/components/AboutWindow';
|
|||||||
import { isWebAppPlatform } from '@/services/environment';
|
import { isWebAppPlatform } from '@/services/environment';
|
||||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { useEnv } from '@/context/EnvContext';
|
||||||
|
import { useSettingsStore } from '@/store/settingsStore';
|
||||||
import MenuItem from '@/components/MenuItem';
|
import MenuItem from '@/components/MenuItem';
|
||||||
|
|
||||||
interface BookMenuProps {
|
interface BookMenuProps {
|
||||||
@@ -16,7 +18,9 @@ interface BookMenuProps {
|
|||||||
|
|
||||||
const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { envConfig } = useEnv();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||||
|
|
||||||
const showAboutReadest = () => {
|
const showAboutReadest = () => {
|
||||||
setAboutDialogVisible(true);
|
setAboutDialogVisible(true);
|
||||||
@@ -34,6 +38,9 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
|||||||
|
|
||||||
const handleUserLogout = () => {
|
const handleUserLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
|
settings.keepLogin = false;
|
||||||
|
setSettings(settings);
|
||||||
|
saveSettings(envConfig, settings);
|
||||||
setIsDropdownOpen?.(false);
|
setIsDropdownOpen?.(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { checkForAppUpdates } from '@/helpers/updater';
|
|||||||
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
|
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
|
||||||
|
|
||||||
import { useEnv } from '@/context/EnvContext';
|
import { useEnv } from '@/context/EnvContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { useLibraryStore } from '@/store/libraryStore';
|
import { useLibraryStore } from '@/store/libraryStore';
|
||||||
import { useSettingsStore } from '@/store/settingsStore';
|
import { useSettingsStore } from '@/store/settingsStore';
|
||||||
@@ -26,6 +27,7 @@ import { AboutWindow } from '@/components/AboutWindow';
|
|||||||
const LibraryPage = () => {
|
const LibraryPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { envConfig, appService } = useEnv();
|
const { envConfig, appService } = useEnv();
|
||||||
|
const { token, user } = useAuth();
|
||||||
const {
|
const {
|
||||||
library: libraryBooks,
|
library: libraryBooks,
|
||||||
setLibrary,
|
setLibrary,
|
||||||
@@ -33,7 +35,7 @@ const LibraryPage = () => {
|
|||||||
clearOpenWithBooks,
|
clearOpenWithBooks,
|
||||||
} = useLibraryStore();
|
} = useLibraryStore();
|
||||||
useTheme();
|
useTheme();
|
||||||
const { setSettings } = useSettingsStore();
|
const { setSettings, saveSettings } = useSettingsStore();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const isInitiating = useRef(false);
|
const isInitiating = useRef(false);
|
||||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||||
@@ -74,6 +76,20 @@ const LibraryPage = () => {
|
|||||||
if (isInitiating.current) return;
|
if (isInitiating.current) return;
|
||||||
isInitiating.current = true;
|
isInitiating.current = true;
|
||||||
|
|
||||||
|
const initLogin = async () => {
|
||||||
|
const appService = await envConfig.getAppService();
|
||||||
|
const settings = await appService.loadSettings();
|
||||||
|
if (token && user) {
|
||||||
|
if (!settings.keepLogin) {
|
||||||
|
settings.keepLogin = true;
|
||||||
|
setSettings(settings);
|
||||||
|
saveSettings(envConfig, settings);
|
||||||
|
}
|
||||||
|
} else if (settings.keepLogin) {
|
||||||
|
router.push('/auth');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadingTimeout = setTimeout(() => setLoading(true), 300);
|
const loadingTimeout = setTimeout(() => setLoading(true), 300);
|
||||||
const initLibrary = async () => {
|
const initLibrary = async () => {
|
||||||
const appService = await envConfig.getAppService();
|
const appService = await envConfig.getAppService();
|
||||||
@@ -104,6 +120,7 @@ const LibraryPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
initLogin();
|
||||||
initLibrary();
|
initLibrary();
|
||||||
return () => {
|
return () => {
|
||||||
clearOpenWithBooks();
|
clearOpenWithBooks();
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
|
|||||||
cfi,
|
cfi,
|
||||||
text: truncatedText,
|
text: truncatedText,
|
||||||
note: '',
|
note: '',
|
||||||
created: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
bookmarks.push(bookmark);
|
bookmarks.push(bookmark);
|
||||||
const updatedConfig = updateBooknotes(bookKey, bookmarks);
|
const updatedConfig = updateBooknotes(bookKey, bookmarks);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { BookDoc, getDirection } from '@/libs/document';
|
import { BookDoc, getDirection } from '@/libs/document';
|
||||||
import { BookConfig, BookNote, BookSearchConfig, BookSearchResult } from '@/types/book';
|
import { BookConfig } from '@/types/book';
|
||||||
|
import { FoliateView, wrappedFoliateView } from '@/types/view';
|
||||||
import { useReaderStore } from '@/store/readerStore';
|
import { useReaderStore } from '@/store/readerStore';
|
||||||
import { useParallelViewStore } from '@/store/parallelViewStore';
|
import { useParallelViewStore } from '@/store/parallelViewStore';
|
||||||
|
import { useClickEvent } from '../hooks/useClickEvent';
|
||||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||||
import { getOSPlatform } from '@/utils/misc';
|
import { useProgressSync } from '../hooks/useProgressSync';
|
||||||
|
import { useAutoHideScrollbar } from '../hooks/useAutoHideScrollbar';
|
||||||
import { getStyles, mountAdditionalFonts } from '@/utils/style';
|
import { getStyles, mountAdditionalFonts } from '@/utils/style';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
|
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
|
||||||
@@ -15,57 +18,7 @@ import {
|
|||||||
handleClick,
|
handleClick,
|
||||||
handleWheel,
|
handleWheel,
|
||||||
} from '../utils/iframeEventHandlers';
|
} from '../utils/iframeEventHandlers';
|
||||||
import { eventDispatcher } from '@/utils/event';
|
import Toast from '@/components/Toast';
|
||||||
|
|
||||||
export interface FoliateView extends HTMLElement {
|
|
||||||
open: (book: BookDoc) => Promise<void>;
|
|
||||||
close: () => void;
|
|
||||||
init: (options: { lastLocation: string }) => void;
|
|
||||||
goTo: (href: string) => void;
|
|
||||||
goToFraction: (fraction: number) => void;
|
|
||||||
prev: (distance: number) => void;
|
|
||||||
next: (distance: number) => void;
|
|
||||||
goLeft: () => void;
|
|
||||||
goRight: () => void;
|
|
||||||
getCFI: (index: number, range: Range) => string;
|
|
||||||
addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string };
|
|
||||||
search: (config: BookSearchConfig) => AsyncGenerator<BookSearchResult | string, void, void>;
|
|
||||||
clearSearch: () => void;
|
|
||||||
select: (target: string | number | { fraction: number }) => void;
|
|
||||||
deselect: () => void;
|
|
||||||
history: {
|
|
||||||
canGoBack: boolean;
|
|
||||||
canGoForward: boolean;
|
|
||||||
back: () => void;
|
|
||||||
forward: () => void;
|
|
||||||
clear: () => void;
|
|
||||||
};
|
|
||||||
renderer: {
|
|
||||||
scrolled?: boolean;
|
|
||||||
viewSize: number;
|
|
||||||
setAttribute: (name: string, value: string | number) => void;
|
|
||||||
removeAttribute: (name: string) => void;
|
|
||||||
next: () => Promise<void>;
|
|
||||||
prev: () => Promise<void>;
|
|
||||||
goTo?: (params: { index: number; anchor: number }) => void;
|
|
||||||
setStyles?: (css: string) => void;
|
|
||||||
addEventListener: (type: string, listener: EventListener) => void;
|
|
||||||
removeEventListener: (type: string, listener: EventListener) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrappedFoliateView = (originalView: FoliateView): FoliateView => {
|
|
||||||
const originalAddAnnotation = originalView.addAnnotation.bind(originalView);
|
|
||||||
originalView.addAnnotation = (note: BookNote, remove = false) => {
|
|
||||||
// transform BookNote to foliate annotation
|
|
||||||
const annotation = {
|
|
||||||
value: note.cfi,
|
|
||||||
...note,
|
|
||||||
};
|
|
||||||
return originalAddAnnotation(annotation, remove);
|
|
||||||
};
|
|
||||||
return originalView;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FoliateViewer: React.FC<{
|
const FoliateViewer: React.FC<{
|
||||||
bookKey: string;
|
bookKey: string;
|
||||||
@@ -75,12 +28,18 @@ const FoliateViewer: React.FC<{
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<FoliateView | null>(null);
|
const viewRef = useRef<FoliateView | null>(null);
|
||||||
const isViewCreated = useRef(false);
|
const isViewCreated = useRef(false);
|
||||||
const { getView, setView: setFoliateView, setProgress, getViewSettings } = useReaderStore();
|
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
|
||||||
const { hoveredBookKey, setHoveredBookKey, setViewSettings } = useReaderStore();
|
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||||
const { getParallels } = useParallelViewStore();
|
const { getParallels } = useParallelViewStore();
|
||||||
const { themeCode } = useTheme();
|
const { themeCode } = useTheme();
|
||||||
|
|
||||||
const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform());
|
const [toastMessage, setToastMessage] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setToastMessage(''), 2000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [toastMessage]);
|
||||||
|
|
||||||
|
useProgressSync(bookKey, config, setToastMessage);
|
||||||
|
|
||||||
const progressRelocateHandler = (event: Event) => {
|
const progressRelocateHandler = (event: Event) => {
|
||||||
const detail = (event as CustomEvent).detail;
|
const detail = (event as CustomEvent).detail;
|
||||||
@@ -88,34 +47,7 @@ const FoliateViewer: React.FC<{
|
|||||||
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
|
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScrollbarAutoHide = (doc: Document) => {
|
const { shouldAutoHideScrollbar, handleScrollbarAutoHide } = useAutoHideScrollbar();
|
||||||
if (doc && doc.defaultView && doc.defaultView.frameElement) {
|
|
||||||
const iframe = doc.defaultView.frameElement as HTMLIFrameElement;
|
|
||||||
const container = iframe.parentElement?.parentElement;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
let hideScrollbarTimeout: ReturnType<typeof setTimeout>;
|
|
||||||
const showScrollbar = () => {
|
|
||||||
container.style.overflow = 'auto';
|
|
||||||
container.style.scrollbarWidth = 'thin';
|
|
||||||
};
|
|
||||||
|
|
||||||
const hideScrollbar = () => {
|
|
||||||
container.style.overflow = 'hidden';
|
|
||||||
container.style.scrollbarWidth = 'none';
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
container.style.overflow = 'auto';
|
|
||||||
});
|
|
||||||
};
|
|
||||||
container.addEventListener('scroll', () => {
|
|
||||||
showScrollbar();
|
|
||||||
clearTimeout(hideScrollbarTimeout);
|
|
||||||
hideScrollbarTimeout = setTimeout(hideScrollbar, 1000);
|
|
||||||
});
|
|
||||||
hideScrollbar();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const docLoadHandler = (event: Event) => {
|
const docLoadHandler = (event: Event) => {
|
||||||
const detail = (event as CustomEvent).detail;
|
const detail = (event as CustomEvent).detail;
|
||||||
console.log('doc loaded:', detail);
|
console.log('doc loaded:', detail);
|
||||||
@@ -157,72 +89,7 @@ const FoliateViewer: React.FC<{
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTurnPage = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
const { handleTurnPage } = useClickEvent(bookKey, viewRef, containerRef);
|
||||||
if (msg instanceof MessageEvent) {
|
|
||||||
if (msg.data && msg.data.bookKey === bookKey) {
|
|
||||||
const viewSettings = getViewSettings(bookKey)!;
|
|
||||||
if (msg.data.type === 'iframe-single-click') {
|
|
||||||
if (viewSettings.disableClick!) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const viewElement = containerRef.current;
|
|
||||||
if (viewElement) {
|
|
||||||
const rect = viewElement.getBoundingClientRect();
|
|
||||||
const { screenX, screenY } = msg.data;
|
|
||||||
const consumed = eventDispatcher.dispatchSync('iframe-single-click', {
|
|
||||||
screenX,
|
|
||||||
screenY,
|
|
||||||
});
|
|
||||||
if (!consumed) {
|
|
||||||
const centerStartX = rect.left + rect.width * 0.375;
|
|
||||||
const centerEndX = rect.left + rect.width * 0.625;
|
|
||||||
const centerStartY = rect.top + rect.height * 0.375;
|
|
||||||
const centerEndY = rect.top + rect.height * 0.625;
|
|
||||||
if (
|
|
||||||
screenX >= centerStartX &&
|
|
||||||
screenX <= centerEndX &&
|
|
||||||
screenY >= centerStartY &&
|
|
||||||
screenY <= centerEndY
|
|
||||||
) {
|
|
||||||
// toggle visibility of the header bar and the footer bar
|
|
||||||
setHoveredBookKey(hoveredBookKey ? '' : bookKey);
|
|
||||||
} else if (screenX >= rect.left + rect.width / 2) {
|
|
||||||
viewRef.current?.goRight();
|
|
||||||
} else if (screenX < rect.left + rect.width / 2) {
|
|
||||||
viewRef.current?.goLeft();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
|
|
||||||
const { deltaY } = msg.data;
|
|
||||||
if (deltaY > 0) {
|
|
||||||
viewRef.current?.next(1);
|
|
||||||
} else if (deltaY < 0) {
|
|
||||||
viewRef.current?.prev(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const { clientX } = msg;
|
|
||||||
const width = window.innerWidth;
|
|
||||||
const leftThreshold = width * 0.5;
|
|
||||||
const rightThreshold = width * 0.5;
|
|
||||||
if (clientX < leftThreshold) {
|
|
||||||
viewRef.current?.goLeft();
|
|
||||||
} else if (clientX > rightThreshold) {
|
|
||||||
viewRef.current?.goRight();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.addEventListener('message', handleTurnPage);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('message', handleTurnPage);
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [hoveredBookKey, viewRef]);
|
|
||||||
|
|
||||||
useFoliateEvents(viewRef.current, {
|
useFoliateEvents(viewRef.current, {
|
||||||
onLoad: docLoadHandler,
|
onLoad: docLoadHandler,
|
||||||
onRelocate: progressRelocateHandler,
|
onRelocate: progressRelocateHandler,
|
||||||
@@ -291,11 +158,16 @@ const FoliateViewer: React.FC<{
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className='foliate-viewer h-[100%] w-[100%]'
|
<div
|
||||||
onClick={(event) => handleTurnPage(event)}
|
className='foliate-viewer h-[100%] w-[100%]'
|
||||||
ref={containerRef}
|
onClick={(event) => handleTurnPage(event)}
|
||||||
/>
|
ref={containerRef}
|
||||||
|
/>
|
||||||
|
{toastMessage && (
|
||||||
|
<Toast message={toastMessage} toastClass='toast-top toast-end' alertClass='alert-success' />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
|||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { getStyles } from '@/utils/style';
|
import { getStyles } from '@/utils/style';
|
||||||
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
|
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
|
||||||
import { FoliateView } from './FoliateViewer';
|
import { FoliateView } from '@/types/view';
|
||||||
import { FootnoteHandler } from 'foliate-js/footnotes.js';
|
import { FootnoteHandler } from 'foliate-js/footnotes.js';
|
||||||
import Popup from '@/components/Popup';
|
import Popup from '@/components/Popup';
|
||||||
|
|
||||||
|
|||||||
@@ -225,7 +225,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
|||||||
cfi,
|
cfi,
|
||||||
text: selection.text,
|
text: selection.text,
|
||||||
note: '',
|
note: '',
|
||||||
created: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const existingIndex = annotations.findIndex(
|
const existingIndex = annotations.findIndex(
|
||||||
@@ -260,7 +261,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
|||||||
color,
|
color,
|
||||||
text: selection.text,
|
text: selection.text,
|
||||||
note: '',
|
note: '',
|
||||||
created: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
const existingIndex = annotations.findIndex(
|
const existingIndex = annotations.findIndex(
|
||||||
(annotation) => annotation.cfi === cfi && annotation.type === 'annotation',
|
(annotation) => annotation.cfi === cfi && annotation.type === 'annotation',
|
||||||
@@ -389,7 +391,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{toastMessage && <Toast message={toastMessage} />}
|
{toastMessage && <Toast message={toastMessage} alertClass='bg-neutual text-content' />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ const Notebook: React.FC = ({}) => {
|
|||||||
cfi,
|
cfi,
|
||||||
note,
|
note,
|
||||||
text: selection.text,
|
text: selection.text,
|
||||||
created: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
annotations.push(annotation);
|
annotations.push(annotation);
|
||||||
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
|
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
|
||||||
@@ -89,7 +90,7 @@ const Notebook: React.FC = ({}) => {
|
|||||||
const { booknotes: annotations = [] } = config;
|
const { booknotes: annotations = [] } = config;
|
||||||
const existingIndex = annotations.findIndex((item) => item.id === annotation.id);
|
const existingIndex = annotations.findIndex((item) => item.id === annotation.id);
|
||||||
if (existingIndex === -1) return;
|
if (existingIndex === -1) return;
|
||||||
annotation.modified = Date.now();
|
annotation.updatedAt = Date.now();
|
||||||
annotations[existingIndex] = annotation;
|
annotations[existingIndex] = annotation;
|
||||||
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
|
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
|
||||||
if (updatedConfig) {
|
if (updatedConfig) {
|
||||||
@@ -117,10 +118,10 @@ const Notebook: React.FC = ({}) => {
|
|||||||
const { booknotes: allNotes = [] } = config || {};
|
const { booknotes: allNotes = [] } = config || {};
|
||||||
const annotationNotes = allNotes
|
const annotationNotes = allNotes
|
||||||
.filter((note) => note.type === 'annotation' && note.note)
|
.filter((note) => note.type === 'annotation' && note.note)
|
||||||
.sort((a, b) => b.created - a.created);
|
.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
const excerptNotes = allNotes
|
const excerptNotes = allNotes
|
||||||
.filter((note) => note.type === 'excerpt')
|
.filter((note) => note.type === 'excerpt')
|
||||||
.sort((a, b) => a.created - b.created);
|
.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
|
||||||
return isNotebookVisible ? (
|
return isNotebookVisible ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { getOSPlatform } from '@/utils/misc';
|
||||||
|
|
||||||
|
export const useAutoHideScrollbar = () => {
|
||||||
|
const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform());
|
||||||
|
const handleScrollbarAutoHide = (doc: Document) => {
|
||||||
|
if (doc && doc.defaultView && doc.defaultView.frameElement) {
|
||||||
|
const iframe = doc.defaultView.frameElement as HTMLIFrameElement;
|
||||||
|
const container = iframe.parentElement?.parentElement;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let hideScrollbarTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
const showScrollbar = () => {
|
||||||
|
container.style.overflow = 'auto';
|
||||||
|
container.style.scrollbarWidth = 'thin';
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideScrollbar = () => {
|
||||||
|
container.style.overflow = 'hidden';
|
||||||
|
container.style.scrollbarWidth = 'none';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
container.style.overflow = 'auto';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
container.addEventListener('scroll', () => {
|
||||||
|
showScrollbar();
|
||||||
|
clearTimeout(hideScrollbarTimeout);
|
||||||
|
hideScrollbarTimeout = setTimeout(hideScrollbar, 1000);
|
||||||
|
});
|
||||||
|
hideScrollbar();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { shouldAutoHideScrollbar, handleScrollbarAutoHide };
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { FoliateView } from '@/types/view';
|
||||||
|
import { useReaderStore } from '@/store/readerStore';
|
||||||
|
import { eventDispatcher } from '@/utils/event';
|
||||||
|
|
||||||
|
export const useClickEvent = (
|
||||||
|
bookKey: string,
|
||||||
|
viewRef: React.MutableRefObject<FoliateView | null>,
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>,
|
||||||
|
) => {
|
||||||
|
const { getViewSettings } = useReaderStore();
|
||||||
|
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
|
||||||
|
const handleTurnPage = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
|
if (msg instanceof MessageEvent) {
|
||||||
|
if (msg.data && msg.data.bookKey === bookKey) {
|
||||||
|
const viewSettings = getViewSettings(bookKey)!;
|
||||||
|
if (msg.data.type === 'iframe-single-click') {
|
||||||
|
if (viewSettings.disableClick!) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const viewElement = containerRef.current;
|
||||||
|
if (viewElement) {
|
||||||
|
const rect = viewElement.getBoundingClientRect();
|
||||||
|
const { screenX, screenY } = msg.data;
|
||||||
|
const consumed = eventDispatcher.dispatchSync('iframe-single-click', {
|
||||||
|
screenX,
|
||||||
|
screenY,
|
||||||
|
});
|
||||||
|
if (!consumed) {
|
||||||
|
const centerStartX = rect.left + rect.width * 0.375;
|
||||||
|
const centerEndX = rect.left + rect.width * 0.625;
|
||||||
|
const centerStartY = rect.top + rect.height * 0.375;
|
||||||
|
const centerEndY = rect.top + rect.height * 0.625;
|
||||||
|
if (
|
||||||
|
screenX >= centerStartX &&
|
||||||
|
screenX <= centerEndX &&
|
||||||
|
screenY >= centerStartY &&
|
||||||
|
screenY <= centerEndY
|
||||||
|
) {
|
||||||
|
// toggle visibility of the header bar and the footer bar
|
||||||
|
setHoveredBookKey(hoveredBookKey ? '' : bookKey);
|
||||||
|
} else if (screenX >= rect.left + rect.width / 2) {
|
||||||
|
viewRef.current?.goRight();
|
||||||
|
} else if (screenX < rect.left + rect.width / 2) {
|
||||||
|
viewRef.current?.goLeft();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
|
||||||
|
const { deltaY } = msg.data;
|
||||||
|
if (deltaY > 0) {
|
||||||
|
viewRef.current?.next(1);
|
||||||
|
} else if (deltaY < 0) {
|
||||||
|
viewRef.current?.prev(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { clientX } = msg;
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const leftThreshold = width * 0.5;
|
||||||
|
const rightThreshold = width * 0.5;
|
||||||
|
if (clientX < leftThreshold) {
|
||||||
|
viewRef.current?.goLeft();
|
||||||
|
} else if (clientX > rightThreshold) {
|
||||||
|
viewRef.current?.goRight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('message', handleTurnPage);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('message', handleTurnPage);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hoveredBookKey, viewRef]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleTurnPage,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { FoliateView } from '@/app/reader/components/FoliateViewer';
|
import { FoliateView } from '@/types/view';
|
||||||
|
|
||||||
type FoliateEventHandler = {
|
type FoliateEventHandler = {
|
||||||
onLoad?: (event: Event) => void;
|
onLoad?: (event: Event) => void;
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { useSync } from '@/hooks/useSync';
|
||||||
|
import { BookConfig } from '@/types/book';
|
||||||
|
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
|
||||||
|
import { useBookDataStore } from '@/store/bookDataStore';
|
||||||
|
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
|
||||||
|
import { useReaderStore } from '@/store/readerStore';
|
||||||
|
import { useSettingsStore } from '@/store/settingsStore';
|
||||||
|
|
||||||
|
export const useProgressSync = (
|
||||||
|
bookKey: string,
|
||||||
|
config: BookConfig,
|
||||||
|
setToastMessage?: React.Dispatch<React.SetStateAction<string>>,
|
||||||
|
) => {
|
||||||
|
const { setConfig } = useBookDataStore();
|
||||||
|
const { getView } = useReaderStore();
|
||||||
|
const { settings } = useSettingsStore();
|
||||||
|
const { syncedConfigs, syncConfigs } = useSync();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const view = getView(bookKey);
|
||||||
|
|
||||||
|
const pushConfig = (bookKey: string, config: BookConfig) => {
|
||||||
|
const bookHash = bookKey.split('-')[0]!;
|
||||||
|
const newConfig = { bookHash, ...config };
|
||||||
|
const compressedConfig = JSON.parse(
|
||||||
|
serializeConfig(newConfig, settings.globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG),
|
||||||
|
);
|
||||||
|
syncConfigs([compressedConfig], bookHash, 'push');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const bookHash = bookKey.split('-')[0]!;
|
||||||
|
syncConfigs([], bookHash, 'pull');
|
||||||
|
return () => {
|
||||||
|
pushConfig(bookKey, config);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const lastProgressSyncTime = useRef<number>(0);
|
||||||
|
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastSync = now - lastProgressSyncTime.current;
|
||||||
|
if (configSynced.current && timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
|
||||||
|
lastProgressSyncTime.current = now;
|
||||||
|
pushConfig(bookKey, config);
|
||||||
|
} else {
|
||||||
|
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
|
||||||
|
syncTimeoutRef.current = setTimeout(
|
||||||
|
() => {
|
||||||
|
lastProgressSyncTime.current = Date.now();
|
||||||
|
pushConfig(bookKey, config);
|
||||||
|
syncTimeoutRef.current = null;
|
||||||
|
},
|
||||||
|
SYNC_PROGRESS_INTERVAL_SEC * 1000 - timeSinceLastSync,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// sync progress once when the book is opened
|
||||||
|
const configSynced = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!configSynced.current && syncedConfigs?.length > 0) {
|
||||||
|
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
|
||||||
|
if (syncedConfig) {
|
||||||
|
const newConfig = deserializeConfig(
|
||||||
|
JSON.stringify(syncedConfig),
|
||||||
|
settings.globalViewSettings,
|
||||||
|
DEFAULT_BOOK_SEARCH_CONFIG,
|
||||||
|
);
|
||||||
|
setConfig(bookKey, { ...config, ...newConfig });
|
||||||
|
configSynced.current = true;
|
||||||
|
if ((syncedConfig.progress?.[1] ?? 0) > 0 && (config.progress?.[1] ?? 0) > 0) {
|
||||||
|
const syncedFraction = syncedConfig.progress![0] / syncedConfig.progress![1];
|
||||||
|
const configFraction = config.progress![0] / config.progress![1];
|
||||||
|
if (syncedFraction > configFraction) {
|
||||||
|
view?.goToFraction(syncedFraction);
|
||||||
|
setToastMessage?.('Progress synced');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [syncedConfigs]);
|
||||||
|
};
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const Toast: React.FC<{ message: string }> = ({ message }) => (
|
const Toast: React.FC<{ message: string; toastClass?: string; alertClass?: string }> = ({
|
||||||
<div className='toast toast-center toast-middle'>
|
message,
|
||||||
<div className='alert flex items-center justify-center border-0 bg-gray-600 text-white'>
|
toastClass,
|
||||||
|
alertClass,
|
||||||
|
}) => (
|
||||||
|
<div className={clsx('toast toast-center toast-middle', toastClass)}>
|
||||||
|
<div className={clsx('alert flex items-center justify-center border-0', alertClass)}>
|
||||||
<span>{message}</span>
|
<span>{message}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const fetchSession = async () => {
|
const fetchSession = async () => {
|
||||||
const {
|
const {
|
||||||
data: { session },
|
data: { session },
|
||||||
error,
|
|
||||||
} = await supabase.auth.getSession();
|
} = await supabase.auth.getSession();
|
||||||
if (session) {
|
if (session) {
|
||||||
const { access_token, user } = session;
|
const { access_token, user } = session;
|
||||||
@@ -40,8 +39,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
setUser(user);
|
setUser(user);
|
||||||
localStorage.setItem('token', access_token);
|
localStorage.setItem('token', access_token);
|
||||||
localStorage.setItem('user', JSON.stringify(user));
|
localStorage.setItem('user', JSON.stringify(user));
|
||||||
} else if (error) {
|
} else {
|
||||||
console.error('Error fetching session:', error);
|
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
setToken(null);
|
setToken(null);
|
||||||
@@ -54,6 +52,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const login = (newToken: string, newUser: User) => {
|
const login = (newToken: string, newUser: User) => {
|
||||||
|
console.log('Logging in');
|
||||||
setToken(newToken);
|
setToken(newToken);
|
||||||
setUser(newUser);
|
setUser(newUser);
|
||||||
localStorage.setItem('token', newToken);
|
localStorage.setItem('token', newToken);
|
||||||
@@ -61,6 +60,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
|
console.log('Logging out');
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext } from 'react';
|
||||||
|
import { SyncClient } from '@/libs/sync';
|
||||||
|
|
||||||
|
const syncClient = new SyncClient();
|
||||||
|
|
||||||
|
interface SyncContextType {
|
||||||
|
syncClient: SyncClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SyncContext = createContext<SyncContextType>({ syncClient });
|
||||||
|
|
||||||
|
export const SyncProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
return <SyncContext.Provider value={{ syncClient }}>{children}</SyncContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSyncContext = () => useContext(SyncContext);
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSyncContext } from '@/context/SyncContext';
|
||||||
|
import { SyncData, SyncOp, SyncResult, SyncType } from '@/libs/sync';
|
||||||
|
import { useSettingsStore } from '@/store/settingsStore';
|
||||||
|
import { useBookDataStore } from '@/store/bookDataStore';
|
||||||
|
import { transformBookConfigFromDB } from '@/utils/transform';
|
||||||
|
import { transformBookNoteFromDB } from '@/utils/transform';
|
||||||
|
import { transformBookFromDB } from '@/utils/transform';
|
||||||
|
import { DBBook, DBBookConfig, DBBookNote } from '@/types/records';
|
||||||
|
import { Book, BookConfig, BookDataRecord, BookNote } from '@/types/book';
|
||||||
|
|
||||||
|
const transformsFromDB = {
|
||||||
|
books: transformBookFromDB,
|
||||||
|
notes: transformBookNoteFromDB,
|
||||||
|
configs: transformBookConfigFromDB,
|
||||||
|
};
|
||||||
|
|
||||||
|
const computeMaxTimestamp = (records: BookDataRecord[]): number => {
|
||||||
|
let maxTime = 0;
|
||||||
|
for (const rec of records) {
|
||||||
|
if (rec.updated_at) {
|
||||||
|
const updatedTime = new Date(rec.updated_at).getTime();
|
||||||
|
maxTime = Math.max(maxTime, updatedTime);
|
||||||
|
}
|
||||||
|
if (rec.deleted_at) {
|
||||||
|
const deletedTime = new Date(rec.deleted_at).getTime();
|
||||||
|
maxTime = Math.max(maxTime, deletedTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSync(bookKey?: string) {
|
||||||
|
const { settings } = useSettingsStore();
|
||||||
|
const { getConfig } = useBookDataStore();
|
||||||
|
|
||||||
|
const config = bookKey ? getConfig(bookKey) : null;
|
||||||
|
|
||||||
|
const [syncingBooks, setSyncingBooks] = useState(false);
|
||||||
|
const [syncingConfigs, setSyncingConfigs] = useState(false);
|
||||||
|
const [syncingNotes, setSyncingNotes] = useState(false);
|
||||||
|
|
||||||
|
const [syncError, setSyncError] = useState<string | null>(null);
|
||||||
|
const [lastSyncedAtBooks, setLastSyncedAtBooks] = useState<number>(
|
||||||
|
settings.lastSyncedAtBooks ?? 0,
|
||||||
|
);
|
||||||
|
const [lastSyncedAtConfigs, setLastSyncedAtConfigs] = useState<number>(
|
||||||
|
config?.lastSyncedAtConfig ?? settings.lastSyncedAtConfigs ?? 0,
|
||||||
|
);
|
||||||
|
const [lastSyncedAtNotes, setLastSyncedAtNotes] = useState<number>(
|
||||||
|
config?.lastSyncedAtNotes ?? settings.lastSyncedAtNotes ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [syncResult, setSyncResult] = useState<SyncResult>({
|
||||||
|
books: [],
|
||||||
|
configs: [],
|
||||||
|
notes: [],
|
||||||
|
});
|
||||||
|
const [syncedBooks, setSyncedBooks] = useState<Book[]>([]);
|
||||||
|
const [syncedConfigs, setSyncedConfigs] = useState<BookConfig[]>([]);
|
||||||
|
const [syncedNotes, setSyncedNotes] = useState<BookNote[]>([]);
|
||||||
|
|
||||||
|
const { syncClient } = useSyncContext();
|
||||||
|
|
||||||
|
// bookId is for configs and notes only, if bookId is provided, only pull changes for that book
|
||||||
|
// and update the lastSyncedAt for that book in the book config
|
||||||
|
const pullChanges = async (
|
||||||
|
type: SyncType,
|
||||||
|
since: number,
|
||||||
|
setLastSyncedAt: React.Dispatch<React.SetStateAction<number>>,
|
||||||
|
setSyncing: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
|
bookId?: string,
|
||||||
|
) => {
|
||||||
|
setSyncing(true);
|
||||||
|
setSyncError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncClient.pullChanges(since, type, bookId);
|
||||||
|
const maxTime = computeMaxTimestamp(result[type]);
|
||||||
|
setLastSyncedAt(maxTime);
|
||||||
|
switch (type) {
|
||||||
|
case 'books':
|
||||||
|
settings.lastSyncedAtBooks = maxTime;
|
||||||
|
break;
|
||||||
|
case 'configs':
|
||||||
|
if (!bookId) {
|
||||||
|
settings.lastSyncedAtConfigs = maxTime;
|
||||||
|
} else if (config) {
|
||||||
|
config.lastSyncedAtConfig = maxTime;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'notes':
|
||||||
|
if (!bookId) {
|
||||||
|
settings.lastSyncedAtNotes = maxTime;
|
||||||
|
} else if (config) {
|
||||||
|
config.lastSyncedAtNotes = maxTime;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(err);
|
||||||
|
if (err instanceof Error) {
|
||||||
|
setSyncError(err.message || `Error pulling ${type}`);
|
||||||
|
} else {
|
||||||
|
setSyncError(`Error pulling ${type}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushChanges = async (payload: SyncData) => {
|
||||||
|
setSyncing(true);
|
||||||
|
setSyncError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncClient.pushChanges(payload);
|
||||||
|
setSyncResult(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(err);
|
||||||
|
if (err instanceof Error) {
|
||||||
|
setSyncError(err.message || 'Error pushing changes');
|
||||||
|
} else {
|
||||||
|
setSyncError('Error pushing changes');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncBooks = async (books?: Book[], op: SyncOp = 'both') => {
|
||||||
|
if ((op === 'push' || op === 'both') && books?.length) {
|
||||||
|
await pushChanges({ books });
|
||||||
|
}
|
||||||
|
if (op === 'pull' || op === 'both') {
|
||||||
|
await pullChanges('books', lastSyncedAtBooks, setLastSyncedAtBooks, setSyncingBooks);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncConfigs = async (bookConfigs?: BookConfig[], bookId?: string, op: SyncOp = 'both') => {
|
||||||
|
if ((op === 'push' || op === 'both') && bookConfigs?.length) {
|
||||||
|
await pushChanges({ configs: bookConfigs });
|
||||||
|
}
|
||||||
|
if (op === 'pull' || op === 'both') {
|
||||||
|
await pullChanges(
|
||||||
|
'configs',
|
||||||
|
lastSyncedAtConfigs,
|
||||||
|
setLastSyncedAtConfigs,
|
||||||
|
setSyncingConfigs,
|
||||||
|
bookId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncNotes = async (bookNotes?: BookNote[], bookId?: string, op: SyncOp = 'both') => {
|
||||||
|
if ((op === 'push' || op === 'both') && bookNotes?.length) {
|
||||||
|
await pushChanges({ notes: bookNotes });
|
||||||
|
}
|
||||||
|
if (op === 'pull' || op === 'both') {
|
||||||
|
await pullChanges('notes', lastSyncedAtNotes, setLastSyncedAtNotes, setSyncingNotes, bookId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!syncing && syncResult) {
|
||||||
|
const { books: dbBooks, configs: dbBookConfigs, notes: dbBookNotes } = syncResult;
|
||||||
|
const books = dbBooks.map((dbBook) => transformsFromDB['books'](dbBook as unknown as DBBook));
|
||||||
|
const configs = dbBookConfigs.map((dbBookConfig) =>
|
||||||
|
transformsFromDB['configs'](dbBookConfig as unknown as DBBookConfig),
|
||||||
|
);
|
||||||
|
const notes = dbBookNotes.map((dbBookNote) =>
|
||||||
|
transformsFromDB['notes'](dbBookNote as unknown as DBBookNote),
|
||||||
|
);
|
||||||
|
if (books.length) setSyncedBooks(books);
|
||||||
|
if (configs.length) setSyncedConfigs(configs);
|
||||||
|
if (notes.length) setSyncedNotes(notes);
|
||||||
|
}
|
||||||
|
}, [syncResult, syncing]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
syncing: syncingBooks || syncingConfigs || syncingNotes,
|
||||||
|
syncError,
|
||||||
|
syncResult,
|
||||||
|
syncedBooks,
|
||||||
|
syncedConfigs,
|
||||||
|
syncedNotes,
|
||||||
|
pullChanges,
|
||||||
|
pushChanges,
|
||||||
|
syncBooks,
|
||||||
|
syncConfigs,
|
||||||
|
syncNotes,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { supabase } from '@/utils/supabase';
|
||||||
|
import { Book, BookConfig, BookNote, BookDataRecord } from '@/types/book';
|
||||||
|
import { READEST_WEB_BASE_URL } from '@/services/constants';
|
||||||
|
|
||||||
|
const BASE_URL = process.env['NODE_ENV'] === 'production' ? READEST_WEB_BASE_URL : '';
|
||||||
|
|
||||||
|
export type SyncType = 'books' | 'configs' | 'notes';
|
||||||
|
export type SyncOp = 'push' | 'pull' | 'both';
|
||||||
|
|
||||||
|
interface BookRecord extends BookDataRecord, Book {}
|
||||||
|
interface BookConfigRecord extends BookDataRecord, BookConfig {}
|
||||||
|
interface BookNoteRecord extends BookDataRecord, BookNote {}
|
||||||
|
|
||||||
|
export interface SyncResult {
|
||||||
|
books: BookRecord[];
|
||||||
|
notes: BookNoteRecord[];
|
||||||
|
configs: BookConfigRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncData {
|
||||||
|
books?: Partial<BookRecord>[];
|
||||||
|
notes?: Partial<BookNoteRecord>[];
|
||||||
|
configs?: Partial<BookConfigRecord>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncClient {
|
||||||
|
/**
|
||||||
|
* Pull incremental changes since a given timestamp (in ms).
|
||||||
|
* Returns updated or deleted records since that time.
|
||||||
|
*/
|
||||||
|
async pullChanges(since: number, type?: SyncType, book?: string): Promise<SyncResult> {
|
||||||
|
const token = await this.getAccessToken();
|
||||||
|
if (!token) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
const url = `${BASE_URL}/api/sync?since=${encodeURIComponent(since)}&type=${type ?? ''}&book=${book ?? ''}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(`Failed to pull changes: ${error.error || res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push local changes to the server.
|
||||||
|
* Uses last-writer-wins logic as implemented on the server side.
|
||||||
|
*/
|
||||||
|
async pushChanges(payload: SyncData): Promise<SyncResult> {
|
||||||
|
const token = await this.getAccessToken();
|
||||||
|
if (!token) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}/api/sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(`Failed to push changes: ${error.error || res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAccessToken(): Promise<string | null> {
|
||||||
|
const { data } = await supabase.auth.getSession();
|
||||||
|
return data?.session?.access_token ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { AppPlatform, AppService, ToastType } from '@/types/system';
|
|||||||
|
|
||||||
import { SystemSettings } from '@/types/settings';
|
import { SystemSettings } from '@/types/settings';
|
||||||
import { FileSystem, BaseDir } from '@/types/system';
|
import { FileSystem, BaseDir } from '@/types/system';
|
||||||
import { Book, BookConfig, BookContent, BookFormat, ViewSettings } from '@/types/book';
|
import { Book, BookConfig, BookContent, BookFormat } from '@/types/book';
|
||||||
import {
|
import {
|
||||||
getDir,
|
getDir,
|
||||||
getFilename,
|
getFilename,
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
DEFAULT_BOOK_SEARCH_CONFIG,
|
DEFAULT_BOOK_SEARCH_CONFIG,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import { isValidURL } from '@/utils/misc';
|
import { isValidURL } from '@/utils/misc';
|
||||||
|
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
|
||||||
|
|
||||||
export abstract class BaseAppService implements AppService {
|
export abstract class BaseAppService implements AppService {
|
||||||
localBooksDir: string = '';
|
localBooksDir: string = '';
|
||||||
@@ -70,6 +71,10 @@ export abstract class BaseAppService implements AppService {
|
|||||||
settings = {
|
settings = {
|
||||||
version: SYSTEM_SETTINGS_VERSION,
|
version: SYSTEM_SETTINGS_VERSION,
|
||||||
localBooksDir: await this.getInitBooksDir(),
|
localBooksDir: await this.getInitBooksDir(),
|
||||||
|
lastSyncedAtBooks: 0,
|
||||||
|
lastSyncedAtConfigs: 0,
|
||||||
|
lastSyncedAtNotes: 0,
|
||||||
|
keepLogin: false,
|
||||||
globalReadSettings: DEFAULT_READSETTINGS,
|
globalReadSettings: DEFAULT_READSETTINGS,
|
||||||
globalViewSettings: {
|
globalViewSettings: {
|
||||||
...DEFAULT_BOOK_LAYOUT,
|
...DEFAULT_BOOK_LAYOUT,
|
||||||
@@ -126,10 +131,10 @@ export abstract class BaseAppService implements AppService {
|
|||||||
const hash = await partialMD5(fileobj);
|
const hash = await partialMD5(fileobj);
|
||||||
const existingBook = books.filter((b) => b.hash === hash)[0];
|
const existingBook = books.filter((b) => b.hash === hash)[0];
|
||||||
if (existingBook) {
|
if (existingBook) {
|
||||||
if (existingBook.isRemoved) {
|
if (existingBook.deletedAt) {
|
||||||
delete existingBook.isRemoved;
|
delete existingBook.deletedAt;
|
||||||
}
|
}
|
||||||
existingBook.lastUpdated = Date.now();
|
existingBook.updatedAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
const book: Book = {
|
const book: Book = {
|
||||||
@@ -137,7 +142,8 @@ export abstract class BaseAppService implements AppService {
|
|||||||
format,
|
format,
|
||||||
title: formatTitle(loadedBook.metadata.title),
|
title: formatTitle(loadedBook.metadata.title),
|
||||||
author: formatAuthors(loadedBook.metadata.language, loadedBook.metadata.author),
|
author: formatAuthors(loadedBook.metadata.language, loadedBook.metadata.author),
|
||||||
lastUpdated: Date.now(),
|
createdAt: existingBook ? existingBook.createdAt : Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
if (!(await this.fs.exists(getDir(book), 'Books'))) {
|
if (!(await this.fs.exists(getDir(book), 'Books'))) {
|
||||||
await this.fs.createDir(getDir(book), 'Books');
|
await this.fs.createDir(getDir(book), 'Books');
|
||||||
@@ -206,33 +212,22 @@ export abstract class BaseAppService implements AppService {
|
|||||||
async loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig> {
|
async loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig> {
|
||||||
try {
|
try {
|
||||||
const str = await this.fs.readFile(getConfigFilename(book), 'Books', 'text');
|
const str = await this.fs.readFile(getConfigFilename(book), 'Books', 'text');
|
||||||
const config = JSON.parse(str as string) as BookConfig;
|
|
||||||
const { globalViewSettings } = settings;
|
const { globalViewSettings } = settings;
|
||||||
const { viewSettings } = config;
|
return deserializeConfig(str as string, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
|
||||||
config.viewSettings = { ...globalViewSettings, ...viewSettings };
|
|
||||||
config.searchConfig ??= DEFAULT_BOOK_SEARCH_CONFIG;
|
|
||||||
return config;
|
|
||||||
} catch {
|
} catch {
|
||||||
return INIT_BOOK_CONFIG;
|
return INIT_BOOK_CONFIG;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings) {
|
async saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings) {
|
||||||
|
let serializedConfig: string;
|
||||||
if (settings) {
|
if (settings) {
|
||||||
config = JSON.parse(JSON.stringify(config));
|
const { globalViewSettings } = settings;
|
||||||
const globalViewSettings = settings.globalViewSettings as ViewSettings;
|
serializedConfig = serializeConfig(config, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
|
||||||
const viewSettings = config.viewSettings as Partial<ViewSettings>;
|
} else {
|
||||||
config.viewSettings = Object.entries(viewSettings).reduce(
|
serializedConfig = JSON.stringify(config);
|
||||||
(acc: Partial<Record<keyof ViewSettings, unknown>>, [key, value]) => {
|
|
||||||
if (globalViewSettings[key as keyof ViewSettings] !== value) {
|
|
||||||
acc[key as keyof ViewSettings] = value;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Partial<Record<keyof ViewSettings, unknown>>,
|
|
||||||
) as Partial<ViewSettings>;
|
|
||||||
}
|
}
|
||||||
await this.fs.writeFile(getConfigFilename(book), 'Books', JSON.stringify(config));
|
await this.fs.writeFile(getConfigFilename(book), 'Books', serializedConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadLibraryBooks(): Promise<Book[]> {
|
async loadLibraryBooks(): Promise<Book[]> {
|
||||||
@@ -255,6 +250,7 @@ export abstract class BaseAppService implements AppService {
|
|||||||
} else {
|
} else {
|
||||||
book.coverImageUrl = this.getCoverImageUrl(book);
|
book.coverImageUrl = this.getCoverImageUrl(book);
|
||||||
}
|
}
|
||||||
|
book.updatedAt ??= book.lastUpdated || Date.now();
|
||||||
return book;
|
return book;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -84,3 +84,7 @@ export const ONE_COLUMN_MAX_INLINE_SIZE = 9999;
|
|||||||
export const BOOK_IDS_SEPARATOR = '+';
|
export const BOOK_IDS_SEPARATOR = '+';
|
||||||
|
|
||||||
export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web';
|
export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web';
|
||||||
|
|
||||||
|
export const READEST_WEB_BASE_URL = 'https://web.readest.com';
|
||||||
|
|
||||||
|
export const SYNC_PROGRESS_INTERVAL_SEC = 30;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface BookData {
|
|||||||
interface BookDataState {
|
interface BookDataState {
|
||||||
booksData: { [id: string]: BookData };
|
booksData: { [id: string]: BookData };
|
||||||
getConfig: (key: string | null) => BookConfig | null;
|
getConfig: (key: string | null) => BookConfig | null;
|
||||||
|
setConfig: (key: string, config: BookConfig) => void;
|
||||||
saveConfig: (
|
saveConfig: (
|
||||||
envConfig: EnvConfigType,
|
envConfig: EnvConfigType,
|
||||||
bookKey: string,
|
bookKey: string,
|
||||||
@@ -38,6 +39,20 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
|
|||||||
const id = key.split('-')[0]!;
|
const id = key.split('-')[0]!;
|
||||||
return get().booksData[id]?.config || null;
|
return get().booksData[id]?.config || null;
|
||||||
},
|
},
|
||||||
|
setConfig: (key: string, config: BookConfig) => {
|
||||||
|
set((state: BookDataState) => {
|
||||||
|
const id = key.split('-')[0]!;
|
||||||
|
return {
|
||||||
|
booksData: {
|
||||||
|
...state.booksData,
|
||||||
|
[id]: {
|
||||||
|
...state.booksData[id]!,
|
||||||
|
config,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
saveConfig: async (
|
saveConfig: async (
|
||||||
envConfig: EnvConfigType,
|
envConfig: EnvConfigType,
|
||||||
bookKey: string,
|
bookKey: string,
|
||||||
@@ -49,10 +64,10 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
|
|||||||
const bookIndex = library.findIndex((b) => b.hash === bookKey.split('-')[0]);
|
const bookIndex = library.findIndex((b) => b.hash === bookKey.split('-')[0]);
|
||||||
if (bookIndex == -1) return;
|
if (bookIndex == -1) return;
|
||||||
const book = library.splice(bookIndex, 1)[0]!;
|
const book = library.splice(bookIndex, 1)[0]!;
|
||||||
book.lastUpdated = Date.now();
|
book.updatedAt = Date.now();
|
||||||
library.unshift(book);
|
library.unshift(book);
|
||||||
setLibrary(library);
|
setLibrary(library);
|
||||||
config.lastUpdated = Date.now();
|
config.updatedAt = Date.now();
|
||||||
appService.saveBookConfig(book, config, settings);
|
appService.saveBookConfig(book, config, settings);
|
||||||
appService.saveLibraryBooks(library);
|
appService.saveLibraryBooks(library);
|
||||||
},
|
},
|
||||||
@@ -67,7 +82,7 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
|
|||||||
);
|
);
|
||||||
updatedConfig = {
|
updatedConfig = {
|
||||||
...book.config,
|
...book.config,
|
||||||
lastUpdated: Date.now(),
|
updatedAt: Date.now(),
|
||||||
booknotes: dedupedBooknotes,
|
booknotes: dedupedBooknotes,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
@@ -77,7 +92,7 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
|
|||||||
...book,
|
...book,
|
||||||
config: {
|
config: {
|
||||||
...book.config,
|
...book.config,
|
||||||
lastUpdated: Date.now(),
|
updatedAt: Date.now(),
|
||||||
booknotes: dedupedBooknotes,
|
booknotes: dedupedBooknotes,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
|||||||
|
|
||||||
import { BookContent, BookConfig, PageInfo, BookProgress, ViewSettings } from '@/types/book';
|
import { BookContent, BookConfig, PageInfo, BookProgress, ViewSettings } from '@/types/book';
|
||||||
import { EnvConfigType } from '@/services/environment';
|
import { EnvConfigType } from '@/services/environment';
|
||||||
import { FoliateView } from '@/app/reader/components/FoliateViewer';
|
import { FoliateView } from '@/types/view';
|
||||||
import { BookDoc, DocumentLoader, SectionItem, TOCItem } from '@/libs/document';
|
import { BookDoc, DocumentLoader, SectionItem, TOCItem } from '@/libs/document';
|
||||||
import { updateTocCFI, updateTocID } from '@/utils/toc';
|
import { updateTocCFI, updateTocID } from '@/utils/toc';
|
||||||
import { useSettingsStore } from './settingsStore';
|
import { useSettingsStore } from './settingsStore';
|
||||||
@@ -182,7 +182,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
|||||||
...bookData,
|
...bookData,
|
||||||
config: {
|
config: {
|
||||||
...bookData.config,
|
...bookData.config,
|
||||||
lastUpdated: Date.now(),
|
updatedAt: Date.now(),
|
||||||
viewSettings,
|
viewSettings,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -216,7 +216,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
|||||||
const oldConfig = bookData.config;
|
const oldConfig = bookData.config;
|
||||||
const newConfig = {
|
const newConfig = {
|
||||||
...bookData.config,
|
...bookData.config,
|
||||||
lastUpdated: Date.now(),
|
updatedAt: Date.now(),
|
||||||
progress: [pageinfo.current, pageinfo.total] as [number, number],
|
progress: [pageinfo.current, pageinfo.total] as [number, number],
|
||||||
location,
|
location,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ export interface Book {
|
|||||||
author: string;
|
author: string;
|
||||||
group?: string;
|
group?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
lastUpdated: number;
|
|
||||||
isRemoved?: boolean;
|
|
||||||
coverImageUrl?: string | null;
|
coverImageUrl?: string | null;
|
||||||
|
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
deletedAt?: number;
|
||||||
|
|
||||||
|
lastUpdated?: number; // deprecated in favor of updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageInfo {
|
export interface PageInfo {
|
||||||
@@ -24,6 +28,7 @@ export interface PageInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BookNote {
|
export interface BookNote {
|
||||||
|
bookHash?: string;
|
||||||
id: string;
|
id: string;
|
||||||
type: BookNoteType;
|
type: BookNoteType;
|
||||||
cfi: string;
|
cfi: string;
|
||||||
@@ -31,8 +36,10 @@ export interface BookNote {
|
|||||||
style?: HighlightStyle;
|
style?: HighlightStyle;
|
||||||
color?: HighlightColor;
|
color?: HighlightColor;
|
||||||
note: string;
|
note: string;
|
||||||
created: number;
|
|
||||||
modified?: number;
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
deletedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookLayout {
|
export interface BookLayout {
|
||||||
@@ -107,18 +114,32 @@ export interface BookSearchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BookConfig {
|
export interface BookConfig {
|
||||||
lastUpdated: number;
|
bookHash?: string;
|
||||||
progress?: [number, number];
|
progress?: [number, number];
|
||||||
location?: string;
|
location?: string;
|
||||||
booknotes?: BookNote[];
|
booknotes?: BookNote[];
|
||||||
searchConfig?: BookSearchConfig;
|
searchConfig?: Partial<BookSearchConfig>;
|
||||||
viewSettings?: Partial<ViewSettings>;
|
viewSettings?: Partial<ViewSettings>;
|
||||||
|
|
||||||
|
lastSyncedAtConfig?: number;
|
||||||
|
lastSyncedAtNotes?: number;
|
||||||
|
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookDataRecord {
|
||||||
|
id: string;
|
||||||
|
book_hash: string;
|
||||||
|
user_id: string;
|
||||||
|
updated_at: number | null;
|
||||||
|
deleted_at: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BooksGroup {
|
export interface BooksGroup {
|
||||||
name: string;
|
name: string;
|
||||||
books: Book[];
|
books: Book[];
|
||||||
lastUpdated: number;
|
|
||||||
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
export interface BookContent {
|
export interface BookContent {
|
||||||
book: Book;
|
book: Book;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export interface DBBook {
|
||||||
|
user_id: string;
|
||||||
|
book_hash: string;
|
||||||
|
format: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
group?: string;
|
||||||
|
tags?: string;
|
||||||
|
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DBBookConfig {
|
||||||
|
user_id: string;
|
||||||
|
book_hash: string;
|
||||||
|
location?: string;
|
||||||
|
progress?: string;
|
||||||
|
search_config?: string;
|
||||||
|
view_settings?: string;
|
||||||
|
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DBBookNote {
|
||||||
|
user_id: string;
|
||||||
|
book_hash: string;
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
cfi: string;
|
||||||
|
text?: string;
|
||||||
|
style?: string;
|
||||||
|
color?: string;
|
||||||
|
note: string;
|
||||||
|
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ export interface ReadSettings {
|
|||||||
export interface SystemSettings {
|
export interface SystemSettings {
|
||||||
version: number;
|
version: number;
|
||||||
localBooksDir: string;
|
localBooksDir: string;
|
||||||
|
keepLogin: boolean;
|
||||||
|
|
||||||
|
lastSyncedAtBooks: number;
|
||||||
|
lastSyncedAtConfigs: number;
|
||||||
|
lastSyncedAtNotes: number;
|
||||||
|
|
||||||
globalReadSettings: ReadSettings;
|
globalReadSettings: ReadSettings;
|
||||||
globalViewSettings: ViewSettings;
|
globalViewSettings: ViewSettings;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { BookDoc } from '@/libs/document';
|
||||||
|
import { BookNote, BookSearchConfig, BookSearchResult } from '@/types/book';
|
||||||
|
|
||||||
|
export interface FoliateView extends HTMLElement {
|
||||||
|
open: (book: BookDoc) => Promise<void>;
|
||||||
|
close: () => void;
|
||||||
|
init: (options: { lastLocation: string }) => void;
|
||||||
|
goTo: (href: string) => void;
|
||||||
|
goToFraction: (fraction: number) => void;
|
||||||
|
prev: (distance: number) => void;
|
||||||
|
next: (distance: number) => void;
|
||||||
|
goLeft: () => void;
|
||||||
|
goRight: () => void;
|
||||||
|
getCFI: (index: number, range: Range) => string;
|
||||||
|
addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string };
|
||||||
|
search: (config: BookSearchConfig) => AsyncGenerator<BookSearchResult | string, void, void>;
|
||||||
|
clearSearch: () => void;
|
||||||
|
select: (target: string | number | { fraction: number }) => void;
|
||||||
|
deselect: () => void;
|
||||||
|
history: {
|
||||||
|
canGoBack: boolean;
|
||||||
|
canGoForward: boolean;
|
||||||
|
back: () => void;
|
||||||
|
forward: () => void;
|
||||||
|
clear: () => void;
|
||||||
|
};
|
||||||
|
renderer: {
|
||||||
|
scrolled?: boolean;
|
||||||
|
viewSize: number;
|
||||||
|
setAttribute: (name: string, value: string | number) => void;
|
||||||
|
removeAttribute: (name: string) => void;
|
||||||
|
next: () => Promise<void>;
|
||||||
|
prev: () => Promise<void>;
|
||||||
|
goTo?: (params: { index: number; anchor: number }) => void;
|
||||||
|
setStyles?: (css: string) => void;
|
||||||
|
addEventListener: (type: string, listener: EventListener) => void;
|
||||||
|
removeEventListener: (type: string, listener: EventListener) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wrappedFoliateView = (originalView: FoliateView): FoliateView => {
|
||||||
|
const originalAddAnnotation = originalView.addAnnotation.bind(originalView);
|
||||||
|
originalView.addAnnotation = (note: BookNote, remove = false) => {
|
||||||
|
// transform BookNote to foliate annotation
|
||||||
|
const annotation = {
|
||||||
|
value: note.cfi,
|
||||||
|
...note,
|
||||||
|
};
|
||||||
|
return originalAddAnnotation(annotation, remove);
|
||||||
|
};
|
||||||
|
return originalView;
|
||||||
|
};
|
||||||
@@ -26,7 +26,7 @@ export const getBaseFilename = (filename: string) => {
|
|||||||
return baseName;
|
return baseName;
|
||||||
};
|
};
|
||||||
export const INIT_BOOK_CONFIG: BookConfig = {
|
export const INIT_BOOK_CONFIG: BookConfig = {
|
||||||
lastUpdated: 0,
|
updatedAt: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface LanguageMap {
|
interface LanguageMap {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { BookConfig, BookSearchConfig, ViewSettings } from '@/types/book';
|
||||||
|
|
||||||
|
export const serializeConfig = (
|
||||||
|
config: BookConfig,
|
||||||
|
globalViewSettings: ViewSettings,
|
||||||
|
defaultSearchConfig: BookSearchConfig,
|
||||||
|
): string => {
|
||||||
|
config = JSON.parse(JSON.stringify(config));
|
||||||
|
const viewSettings = config.viewSettings as Partial<ViewSettings>;
|
||||||
|
const searchConfig = config.searchConfig as Partial<BookSearchConfig>;
|
||||||
|
config.viewSettings = Object.entries(viewSettings).reduce(
|
||||||
|
(acc: Partial<Record<keyof ViewSettings, unknown>>, [key, value]) => {
|
||||||
|
if (globalViewSettings[key as keyof ViewSettings] !== value) {
|
||||||
|
acc[key as keyof ViewSettings] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Partial<Record<keyof ViewSettings, unknown>>,
|
||||||
|
) as Partial<ViewSettings>;
|
||||||
|
config.searchConfig = Object.entries(searchConfig).reduce(
|
||||||
|
(acc: Partial<Record<keyof BookSearchConfig, unknown>>, [key, value]) => {
|
||||||
|
if (defaultSearchConfig[key as keyof BookSearchConfig] !== value) {
|
||||||
|
acc[key as keyof BookSearchConfig] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Partial<BookSearchConfig>,
|
||||||
|
) as Partial<BookSearchConfig>;
|
||||||
|
|
||||||
|
return JSON.stringify(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deserializeConfig = (
|
||||||
|
str: string,
|
||||||
|
globalViewSettings: ViewSettings,
|
||||||
|
defaultSearchConfig: BookSearchConfig,
|
||||||
|
): BookConfig => {
|
||||||
|
const config = JSON.parse(str) as BookConfig;
|
||||||
|
const { viewSettings, searchConfig } = config;
|
||||||
|
config.viewSettings = { ...globalViewSettings, ...viewSettings };
|
||||||
|
config.searchConfig = { ...defaultSearchConfig, ...searchConfig };
|
||||||
|
config.updatedAt ??= Date.now();
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compressConfig = (
|
||||||
|
config: BookConfig,
|
||||||
|
globalViewSettings: ViewSettings,
|
||||||
|
defaultSearchConfig: BookSearchConfig,
|
||||||
|
): string => {
|
||||||
|
return JSON.parse(serializeConfig(config, globalViewSettings, defaultSearchConfig));
|
||||||
|
};
|
||||||
@@ -6,3 +6,15 @@ const supabaseAnonKey =
|
|||||||
process.env['NEXT_PUBLIC_SUPABASE_ANON_KEY'] || process.env['NEXT_PUBLIC_DEV_SUPABASE_ANON_KEY']!;
|
process.env['NEXT_PUBLIC_SUPABASE_ANON_KEY'] || process.env['NEXT_PUBLIC_DEV_SUPABASE_ANON_KEY']!;
|
||||||
|
|
||||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||||
|
|
||||||
|
export const createSupabaseClient = (accessToken?: string) => {
|
||||||
|
return createClient(supabaseUrl, supabaseAnonKey, {
|
||||||
|
global: {
|
||||||
|
headers: accessToken
|
||||||
|
? {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
Book,
|
||||||
|
BookConfig,
|
||||||
|
BookFormat,
|
||||||
|
BookNote,
|
||||||
|
BookNoteType,
|
||||||
|
HighlightColor,
|
||||||
|
HighlightStyle,
|
||||||
|
} from '@/types/book';
|
||||||
|
import { DBBookConfig, DBBook, DBBookNote } from '@/types/records';
|
||||||
|
|
||||||
|
export const transformBookConfigToDB = (bookConfig: unknown, userId: string): DBBookConfig => {
|
||||||
|
const { bookHash, progress, location, searchConfig, viewSettings, updatedAt } =
|
||||||
|
bookConfig as BookConfig;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user_id: userId,
|
||||||
|
book_hash: bookHash!,
|
||||||
|
location: location,
|
||||||
|
progress: progress && JSON.stringify(progress),
|
||||||
|
search_config: searchConfig && JSON.stringify(searchConfig),
|
||||||
|
view_settings: viewSettings && JSON.stringify(viewSettings),
|
||||||
|
updated_at: new Date(updatedAt).toISOString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const transformBookConfigFromDB = (dbBookConfig: DBBookConfig): BookConfig => {
|
||||||
|
const { book_hash, progress, location, search_config, view_settings, updated_at } = dbBookConfig;
|
||||||
|
return {
|
||||||
|
bookHash: book_hash,
|
||||||
|
location,
|
||||||
|
progress: progress && JSON.parse(progress),
|
||||||
|
searchConfig: search_config && JSON.parse(search_config),
|
||||||
|
viewSettings: view_settings && JSON.parse(view_settings),
|
||||||
|
updatedAt: new Date(updated_at!).getTime(),
|
||||||
|
} as BookConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||||
|
const { hash, format, title, author, group, tags, createdAt, updatedAt, deletedAt } =
|
||||||
|
book as Book;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user_id: userId,
|
||||||
|
book_hash: hash,
|
||||||
|
format,
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
group,
|
||||||
|
tags: tags && JSON.stringify(tags),
|
||||||
|
created_at: new Date(createdAt).toISOString(),
|
||||||
|
updated_at: new Date(updatedAt).toISOString(),
|
||||||
|
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||||
|
const { book_hash, format, title, author, group, tags, created_at, updated_at, deleted_at } =
|
||||||
|
dbBook;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: book_hash,
|
||||||
|
format: format as BookFormat,
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
group,
|
||||||
|
tags: tags && JSON.parse(tags),
|
||||||
|
createdAt: new Date(created_at!).getTime(),
|
||||||
|
updatedAt: new Date(updated_at!).getTime(),
|
||||||
|
deletedAt: deleted_at ? new Date(deleted_at!).getTime() : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const transformBookNoteToDB = (bookNote: unknown, userId: string): DBBookNote => {
|
||||||
|
const { bookHash, id, type, cfi, text, style, color, note, createdAt, updatedAt, deletedAt } =
|
||||||
|
bookNote as BookNote;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user_id: userId,
|
||||||
|
book_hash: bookHash!,
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
cfi,
|
||||||
|
text,
|
||||||
|
style,
|
||||||
|
color,
|
||||||
|
note,
|
||||||
|
created_at: new Date(createdAt).toISOString(),
|
||||||
|
updated_at: new Date(updatedAt).toISOString(),
|
||||||
|
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const transformBookNoteFromDB = (dbBookNote: DBBookNote): BookNote => {
|
||||||
|
const { book_hash, id, type, cfi, text, style, color, note, created_at, updated_at, deleted_at } =
|
||||||
|
dbBookNote;
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookHash: book_hash,
|
||||||
|
id,
|
||||||
|
type: type as BookNoteType,
|
||||||
|
cfi,
|
||||||
|
text,
|
||||||
|
style: style as HighlightStyle,
|
||||||
|
color: color as HighlightColor,
|
||||||
|
note,
|
||||||
|
createdAt: new Date(created_at!).getTime(),
|
||||||
|
updatedAt: new Date(updated_at!).getTime(),
|
||||||
|
deletedAt: deleted_at ? new Date(deleted_at!).getTime() : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user