Add sidebar in reader page
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { MdInfoOutline } from 'react-icons/md';
|
||||
import { formatAuthors } from '@/utils/book';
|
||||
|
||||
interface BookCardProps {
|
||||
cover: string;
|
||||
title: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
const BookCard: React.FC<BookCardProps> = ({ cover, title, author }) => {
|
||||
return (
|
||||
<div className='flex w-full items-center'>
|
||||
<img src={cover} alt='Book cover' className='mr-4 w-[15%] rounded-sm object-cover' />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='w-[90%] truncate text-base font-semibold'>{title}</h4>
|
||||
<p className='truncate text-sm text-gray-600'>{formatAuthors(author)}</p>
|
||||
</div>
|
||||
<button
|
||||
className='btn btn-ghost h-6 min-h-6 w-6 rounded-full p-0 transition-colors hover:bg-gray-300'
|
||||
aria-label='More info'
|
||||
>
|
||||
<MdInfoOutline size={18} className='text-gray-600' />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookCard;
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { BsLayoutSidebar } from 'react-icons/bs';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
@@ -9,21 +11,31 @@ import { BookDoc, DocumentLoader } from '@/libs/document';
|
||||
|
||||
import Spinner from '@/components/Spinner';
|
||||
import FoliateViewer from './FoliateViewer';
|
||||
import SideBar from './SideBar';
|
||||
|
||||
interface ReaderContentProps {
|
||||
isClosingBook: boolean;
|
||||
}
|
||||
interface ReaderContentProps {}
|
||||
|
||||
const ReaderContent: React.FC<ReaderContentProps> = ({ isClosingBook }) => {
|
||||
const ReaderContent: React.FC<ReaderContentProps> = ({}) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const id = searchParams.get('id');
|
||||
|
||||
const [bookDoc, setBookDoc] = React.useState<BookDoc>();
|
||||
const { envConfig } = useEnv();
|
||||
const { library, books, fetchBook, setLibrary } = useReaderStore();
|
||||
const { library, books, settings, fetchBook, setLibrary } = useReaderStore();
|
||||
const defaultBookState = { loading: true, error: null, file: null, book: null, config: null };
|
||||
const bookState = id ? books[id] || defaultBookState : defaultBookState;
|
||||
|
||||
const [sideBarWidth, setSideBarWidth] = useState(
|
||||
settings.globalReadSettings.sideBarWidth ?? '20%',
|
||||
);
|
||||
const [isSideBarPinned, setIsSideBarPinned] = useState(
|
||||
settings.globalReadSettings.isSideBarPinned ?? true,
|
||||
);
|
||||
const [isSideBarVisible, setSideBarVisibility] = useState(isSideBarPinned);
|
||||
const [isClosingBook, setClosingBook] = useState(false);
|
||||
const [isTopBarVisible, setTopBarVisibility] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!id) {
|
||||
return;
|
||||
@@ -37,18 +49,17 @@ const ReaderContent: React.FC<ReaderContentProps> = ({ isClosingBook }) => {
|
||||
library[bookIndex] = book;
|
||||
}
|
||||
setLibrary(library);
|
||||
envConfig.initAppService().then((appService) => {
|
||||
envConfig.getAppService().then((appService) => {
|
||||
config.lastUpdated = Date.now();
|
||||
appService.saveBookConfig(book, config);
|
||||
appService.saveLibraryBooks(library);
|
||||
appService.saveSettings(settings);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id && !file) {
|
||||
envConfig.initAppService().then((appService) => {
|
||||
fetchBook(appService, id);
|
||||
});
|
||||
fetchBook(envConfig, id);
|
||||
}
|
||||
if (!bookDoc && bookState.file) {
|
||||
const loadDocument = async () => {
|
||||
@@ -59,22 +70,71 @@ const ReaderContent: React.FC<ReaderContentProps> = ({ isClosingBook }) => {
|
||||
};
|
||||
loadDocument();
|
||||
}
|
||||
return;
|
||||
}, [isClosingBook, bookState.file, envConfig, fetchBook, id]);
|
||||
|
||||
if (!id || !bookDoc || !bookState.config) {
|
||||
const handleResize = (newWidth: string) => {
|
||||
setSideBarWidth(newWidth);
|
||||
settings.globalReadSettings.sideBarWidth = newWidth;
|
||||
};
|
||||
|
||||
const handleTogglePin = () => {
|
||||
if (isSideBarPinned && isSideBarVisible) {
|
||||
setSideBarVisibility(false);
|
||||
}
|
||||
setIsSideBarPinned(!isSideBarPinned);
|
||||
settings.globalReadSettings.isSideBarPinned = !isSideBarPinned;
|
||||
};
|
||||
|
||||
const handleCloseBook = () => {
|
||||
setClosingBook(true);
|
||||
router.back();
|
||||
};
|
||||
|
||||
const topBarWidth = isSideBarPinned && isSideBarVisible ? `calc(100% - ${sideBarWidth})` : '100%';
|
||||
|
||||
if (!id || !bookDoc || !bookState.config || !bookState.book) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{bookState.loading && <Spinner loading={bookState.loading} />}
|
||||
{bookState.error && (
|
||||
<div className='text-center'>
|
||||
<h2 className='text-red-500'>{bookState.error}</h2>
|
||||
<div className='flex h-screen overflow-hidden'>
|
||||
<SideBar
|
||||
book={bookState.book}
|
||||
width={sideBarWidth}
|
||||
isVisible={isSideBarVisible}
|
||||
isPinned={isSideBarPinned}
|
||||
onResize={handleResize}
|
||||
onTogglePin={handleTogglePin}
|
||||
onGoToLibrary={handleCloseBook}
|
||||
onSetVisibility={(visibility: boolean) => setSideBarVisibility(visibility)}
|
||||
/>
|
||||
|
||||
<div className={`flex-1`}>
|
||||
{bookState.loading && <Spinner loading={bookState.loading} />}
|
||||
{bookState.error && (
|
||||
<div className='text-center'>
|
||||
<h2 className='text-red-500'>{bookState.error}</h2>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`topbar absolute top-0 z-10 h-10 border-b ${
|
||||
isTopBarVisible ? 'opacity-100' : 'opacity-0'
|
||||
} flex items-center px-4`}
|
||||
style={{ width: topBarWidth }}
|
||||
onMouseEnter={() => setTopBarVisibility(true)}
|
||||
onMouseLeave={() => setTopBarVisibility(false)}
|
||||
>
|
||||
<div className='absolute left-4 flex h-full items-center p-2'>
|
||||
<button onClick={() => setSideBarVisibility(!isSideBarVisible)}>
|
||||
<BsLayoutSidebar size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className='absolute left-1/2 -translate-x-1/2 transform'>
|
||||
<h2 className='text-center text-sm font-semibold'>{bookState.book.title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<FoliateViewer bookId={id} bookDoc={bookDoc} bookConfig={bookState.config} />
|
||||
<FoliateViewer bookId={id} bookDoc={bookDoc} bookConfig={bookState.config} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Book } from '@/types/book';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
MdSearch,
|
||||
MdOutlinePushPin,
|
||||
MdPushPin,
|
||||
MdToc,
|
||||
MdEditNote,
|
||||
MdBookmarkBorder,
|
||||
} from 'react-icons/md';
|
||||
import { GiBookshelf } from 'react-icons/gi';
|
||||
|
||||
import BookCard from './BookCard';
|
||||
|
||||
interface SideBarProps {
|
||||
book: Book;
|
||||
width: string;
|
||||
isVisible: boolean;
|
||||
isPinned: boolean;
|
||||
onSetVisibility: (visibility: boolean) => void;
|
||||
onTogglePin: () => void;
|
||||
onResize: (newWidth: string) => void;
|
||||
onGoToLibrary: () => void;
|
||||
}
|
||||
|
||||
const MIN_SIDEBAR_WIDTH = '10em';
|
||||
const MAX_SIDEBAR_WIDTH = '40em';
|
||||
|
||||
const SideBar: React.FC<SideBarProps> = ({
|
||||
book,
|
||||
width,
|
||||
isPinned,
|
||||
isVisible,
|
||||
onTogglePin,
|
||||
onSetVisibility,
|
||||
onResize,
|
||||
onGoToLibrary,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('toc');
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {}, [isPinned, isVisible]);
|
||||
|
||||
const handleClickOverlay = () => {
|
||||
onSetVisibility(false);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const newWidthPx = e.clientX;
|
||||
const width = `${Math.round((newWidthPx / window.innerWidth) * 10000) / 100}%`;
|
||||
const minWidthPx = parseFloat(MIN_SIDEBAR_WIDTH) * 16;
|
||||
const maxWidthPx = parseFloat(MAX_SIDEBAR_WIDTH) * 16;
|
||||
if (newWidthPx >= minWidthPx && newWidthPx <= maxWidthPx) {
|
||||
onResize(width);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
return (
|
||||
isVisible && (
|
||||
<div
|
||||
className='sidebar-container z-20 h-full bg-gray-200'
|
||||
style={{
|
||||
width: `${width}`,
|
||||
height: '100%',
|
||||
minWidth: MIN_SIDEBAR_WIDTH,
|
||||
maxWidth: MAX_SIDEBAR_WIDTH,
|
||||
position: isPinned ? 'relative' : 'absolute',
|
||||
}}
|
||||
>
|
||||
<div ref={sidebarRef} className={'sidebar h-full'}>
|
||||
<div className='flex h-10 items-center justify-between pl-1.5 pr-3'>
|
||||
<div className='flex items-center'>
|
||||
<button className='btn btn-ghost h-8 min-h-8 w-8 p-0' onClick={onGoToLibrary}>
|
||||
<GiBookshelf size={20} className='text-gray-600' />
|
||||
</button>
|
||||
</div>
|
||||
<div className='flex size-[30%] min-w-20 items-center justify-between'>
|
||||
<button className='btn btn-ghost left-0 h-8 min-h-8 w-8 p-0'>
|
||||
<MdSearch size={20} className='text-gray-600' />
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePin}
|
||||
className={`${isPinned ? 'bg-gray-400' : 'bg-gray-300'} btn btn-ghost btn-circle right-0 h-6 min-h-6 w-6`}
|
||||
>
|
||||
{isPinned ? <MdPushPin size={14} /> : <MdOutlinePushPin size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-b p-3 shadow-sm'>
|
||||
<BookCard cover={book.coverImageUrl!} title={book.title} author={book.author} />
|
||||
</div>
|
||||
<div className='absolute bottom-0 flex w-full'>
|
||||
<button
|
||||
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === 'toc' ? 'bg-gray-300' : ''}`}
|
||||
onClick={() => setActiveTab('toc')}
|
||||
>
|
||||
<MdToc size={20} className='mx-auto' />
|
||||
</button>
|
||||
<button
|
||||
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === 'annotations' ? 'bg-gray-300' : ''}`}
|
||||
onClick={() => setActiveTab('annotations')}
|
||||
>
|
||||
<MdEditNote size={20} className='mx-auto' />
|
||||
</button>
|
||||
<button
|
||||
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === 'bookmarks' ? 'bg-gray-300' : ''}`}
|
||||
onClick={() => setActiveTab('bookmarks')}
|
||||
>
|
||||
<MdBookmarkBorder size={20} className='mx-auto' />
|
||||
</button>
|
||||
</div>
|
||||
<div className='p-4'>
|
||||
{activeTab === 'toc' && <div>Table of Contents</div>}
|
||||
{activeTab === 'bookmarks' && <div>Bookmarks</div>}
|
||||
{activeTab === 'annotations' && <div>Annotations</div>}
|
||||
</div>
|
||||
<div
|
||||
className='drag-bar bg-base-300 absolute right-0 top-0 h-full w-0.5 cursor-col-resize'
|
||||
onMouseDown={handleMouseDown}
|
||||
></div>
|
||||
</div>
|
||||
{!isPinned && (
|
||||
<div
|
||||
className='overlay fixed top-0'
|
||||
style={{
|
||||
left: width,
|
||||
width: `calc(100% - ${width})`,
|
||||
height: '100%',
|
||||
background: 'rgba(0, 0, 0, 0.2)',
|
||||
}}
|
||||
onClick={() => handleClickOverlay()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default SideBar;
|
||||
@@ -1,53 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, Suspense } from 'react';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
|
||||
import NavBar from '@/components/NavBar';
|
||||
import ReaderContent from './components/ReaderContent';
|
||||
import { DEFAULT_READSETTINGS } from '@/services/constants';
|
||||
|
||||
const ReaderPage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const [isNavBarVisible, setIsNavBarVisible] = useState(false);
|
||||
const [isClosingBook, setIsClosingBook] = useState(false);
|
||||
const { envConfig } = useEnv();
|
||||
const { setLibrary } = useReaderStore();
|
||||
|
||||
const handleBack = () => {
|
||||
console.log('Back to bookshelf');
|
||||
setIsClosingBook(true);
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleTap = () => {
|
||||
setIsNavBarVisible((pre) => !pre);
|
||||
};
|
||||
const { settings, setLibrary, setSettings } = useReaderStore();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLibrary = async () => {
|
||||
const appService = await envConfig.initAppService();
|
||||
const initLibrary = async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const settings = await appService.loadSettings();
|
||||
if (!settings.globalReadSettings) {
|
||||
settings.globalReadSettings = DEFAULT_READSETTINGS;
|
||||
}
|
||||
setSettings(settings);
|
||||
setLibrary(await appService.loadLibraryBooks());
|
||||
};
|
||||
|
||||
fetchLibrary();
|
||||
initLibrary();
|
||||
}, [envConfig, setLibrary]);
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-100'>
|
||||
<div
|
||||
className={`absolute inset-0 z-20 ${isNavBarVisible ? 'mt-10' : 'ml-20 h-20'}`}
|
||||
onClick={handleTap}
|
||||
/>
|
||||
<NavBar onBack={handleBack} isVisible={isNavBarVisible} />
|
||||
<Suspense>
|
||||
<ReaderContent isClosingBook={isClosingBook} />
|
||||
</Suspense>
|
||||
</div>
|
||||
settings.globalReadSettings && (
|
||||
<div className='min-h-screen bg-gray-100'>
|
||||
<Suspense>
|
||||
<ReaderContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { FaChevronLeft } from 'react-icons/fa';
|
||||
|
||||
interface NavBarProps {
|
||||
onBack: () => void;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
const NavBar: React.FC<NavBarProps> = ({ onBack, isVisible }) => {
|
||||
return (
|
||||
isVisible && (
|
||||
<div className='fixed left-0 right-0 top-0 z-10 bg-gray-50 p-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='absolute left-2 text-gray-500' onClick={onBack}>
|
||||
<FaChevronLeft className='w-10' />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default NavBar;
|
||||
@@ -18,7 +18,7 @@ export const EnvProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [appService, setAppService] = useState<AppService | null>(null);
|
||||
|
||||
const getAppService = async (envConfig: EnvConfigType): Promise<AppService> => {
|
||||
const service = await envConfig.initAppService();
|
||||
const service = await envConfig.getAppService();
|
||||
setAppService(service);
|
||||
return service;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { RemoteFile } from '@/utils/file';
|
||||
import { partialMD5 } from '@/utils/md5';
|
||||
import { BookDoc, DocumentLoader } from '@/libs/document';
|
||||
import { DEFAULT_READSETTINGS } from './constants';
|
||||
|
||||
export abstract class BaseAppService implements AppService {
|
||||
localBooksDir: string = '';
|
||||
@@ -47,13 +48,7 @@ export abstract class BaseAppService implements AppService {
|
||||
} catch {
|
||||
settings = {
|
||||
localBooksDir: await this.getInitBooksDir(),
|
||||
globalReadSettings: {
|
||||
themeType: 'auto',
|
||||
fontFamily: '',
|
||||
fontSize: 1.0,
|
||||
wordSpacing: 0.16,
|
||||
lineSpacing: 1.5,
|
||||
},
|
||||
globalReadSettings: DEFAULT_READSETTINGS,
|
||||
};
|
||||
|
||||
await this.fs.createDir('', 'Books', true);
|
||||
|
||||
@@ -1,2 +1,15 @@
|
||||
import { ReadSettings } from '@/types/settings';
|
||||
|
||||
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
|
||||
export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
|
||||
|
||||
export const DEFAULT_READSETTINGS: ReadSettings = {
|
||||
themeType: 'auto',
|
||||
fontFamily: '',
|
||||
fontSize: 1.0,
|
||||
wordSpacing: 0.16,
|
||||
lineSpacing: 1.5,
|
||||
|
||||
sideBarWidth: '20%',
|
||||
isSideBarPinned: false,
|
||||
};
|
||||
|
||||
@@ -3,11 +3,11 @@ import { AppService } from '@/types/system';
|
||||
let appService: AppService | null = null;
|
||||
|
||||
export interface EnvConfigType {
|
||||
initAppService: () => Promise<AppService>;
|
||||
getAppService: () => Promise<AppService>;
|
||||
}
|
||||
|
||||
const environmentConfig: EnvConfigType = {
|
||||
initAppService: async () => {
|
||||
getAppService: async () => {
|
||||
if (!appService) {
|
||||
const { NativeAppService } = await import('@/services/nativeAppService');
|
||||
appService = new NativeAppService();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { BookNote, BookContent, Book, BookConfig, PageInfo } from '@/types/book';
|
||||
import { AppService } from '@/types/system';
|
||||
import { EnvConfigType } from '@/services/environment';
|
||||
import { SystemSettings } from '@/types/settings';
|
||||
|
||||
interface BookState {
|
||||
loading?: boolean;
|
||||
@@ -15,9 +16,11 @@ interface BookState {
|
||||
interface ReaderStore {
|
||||
library: Book[];
|
||||
books: Record<string, BookState>;
|
||||
settings: SystemSettings;
|
||||
|
||||
setLibrary: (books: Book[]) => void;
|
||||
fetchBook: (appService: AppService, id: string) => Promise<Book | null>;
|
||||
setSettings: (settings: SystemSettings) => void;
|
||||
fetchBook: (envConfig: EnvConfigType, id: string) => Promise<Book | null>;
|
||||
setProgress: (id: string, progress: number, location: string, pageinfo: PageInfo) => void;
|
||||
addBookmark: (id: string, bookmark: BookNote) => void;
|
||||
}
|
||||
@@ -25,10 +28,16 @@ interface ReaderStore {
|
||||
export const useReaderStore = create<ReaderStore>((set) => ({
|
||||
library: [],
|
||||
books: {},
|
||||
settings: {} as SystemSettings,
|
||||
|
||||
setLibrary: (books: Book[]) => set({ library: books }),
|
||||
setSettings: (settings: SystemSettings) => set({ settings }),
|
||||
saveSettings: async (envConfig: EnvConfigType, settings: SystemSettings) => {
|
||||
const appService = await envConfig.getAppService();
|
||||
await appService.saveSettings(settings);
|
||||
},
|
||||
|
||||
fetchBook: async (appService: AppService, id: string) => {
|
||||
fetchBook: async (envConfig: EnvConfigType, id: string) => {
|
||||
set((state) => ({
|
||||
books: {
|
||||
...state.books,
|
||||
@@ -37,6 +46,7 @@ export const useReaderStore = create<ReaderStore>((set) => ({
|
||||
}));
|
||||
|
||||
try {
|
||||
const appService = await envConfig.getAppService();
|
||||
const library = await appService.loadLibraryBooks();
|
||||
const book = library.find((b) => b.hash === id);
|
||||
if (!book) {
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface ReadSettings {
|
||||
fontSize: number;
|
||||
wordSpacing: number;
|
||||
lineSpacing: number;
|
||||
|
||||
sideBarWidth: string;
|
||||
isSideBarPinned: boolean;
|
||||
}
|
||||
|
||||
export interface SystemSettings {
|
||||
|
||||
@@ -27,3 +27,29 @@ export const getBaseFilename = (filename: string) => {
|
||||
export const INIT_BOOK_CONFIG: BookConfig = {
|
||||
lastUpdated: 0,
|
||||
};
|
||||
|
||||
interface LanguageMap {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const formatLanguageMap = (x: string | LanguageMap): string => {
|
||||
if (!x) return '';
|
||||
if (typeof x === 'string') return x;
|
||||
const keys = Object.keys(x);
|
||||
return x[keys[0]!]!;
|
||||
};
|
||||
|
||||
const listFormat = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' });
|
||||
|
||||
const formatContributors = (contributors: any) =>
|
||||
Array.isArray(contributors)
|
||||
? listFormat.format(
|
||||
contributors.map((contributor) =>
|
||||
typeof contributor === 'string' ? contributor : formatLanguageMap(contributor?.name),
|
||||
),
|
||||
)
|
||||
: typeof contributors === 'string'
|
||||
? contributors
|
||||
: formatLanguageMap(contributors?.name);
|
||||
|
||||
export const formatAuthors = (authors: any) => formatContributors(authors);
|
||||
|
||||
Reference in New Issue
Block a user