From 5885f077fe8b4ed887b7956036d4c2418697a5a8 Mon Sep 17 00:00:00 2001 From: s0hv Date: Thu, 9 May 2024 15:19:48 +0300 Subject: [PATCH 1/3] Fix embeds --- web/loader.js | 17 +++++++- web/server/db/manga.ts | 40 +++++++++++-------- web/src/api/services.ts | 2 +- .../manga/{[mangaId].jsx => [mangaId].tsx} | 29 ++++++++------ web/src/utils/utilities.tsx | 8 ++-- 5 files changed, 60 insertions(+), 36 deletions(-) rename web/src/pages/manga/{[mangaId].jsx => [mangaId].tsx} (58%) diff --git a/web/loader.js b/web/loader.js index 67f2b21c..9f7c3b70 100644 --- a/web/loader.js +++ b/web/loader.js @@ -6,12 +6,27 @@ import { resolve as resolveTs } from 'ts-node/esm'; // eslint-disable-next-line import/no-extraneous-dependencies import * as tsConfigPaths from 'tsconfig-paths'; import { pathToFileURL } from 'url'; +import fs from 'fs'; const { absoluteBaseUrl, paths } = tsConfigPaths.loadConfig(); const matchPath = tsConfigPaths.createMatchPath(absoluteBaseUrl, paths); export function resolve(specifier, ctx, defaultResolve) { - const match = matchPath(specifier); + let match = matchPath(specifier); + // Only resolve extensions for path shortcuts + if (specifier.startsWith('@') && match && match.indexOf('.') === -1) { + // If match is a directory point to the index file + if (fs.existsSync(match) && fs.lstatSync(match).isDirectory()) { + match = `${match}/index`; + } + + // First try .ts extension and then .js + const newFile = `${match}.ts`; + match = fs.existsSync(newFile) ? + newFile : + `${match}.js`; + } + return match ? resolveTs(pathToFileURL(`${match}`).href, ctx, defaultResolve) : resolveTs(specifier, ctx, defaultResolve); diff --git a/web/server/db/manga.ts b/web/server/db/manga.ts index fe2add07..5d6bcfcc 100644 --- a/web/server/db/manga.ts +++ b/web/server/db/manga.ts @@ -1,16 +1,13 @@ +import type { MangaInfoData } from '@/types/api/manga'; +import type { Follow } from '@/types/db/follows'; +import type { Manga } from '@/types/db/manga'; +import type { DatabaseId, MangaId } from '@/types/dbTypes'; import camelcaseKeys from 'camelcase-keys'; -import { - INVALID_TEXT_REPRESENTATION, - NUMERIC_VALUE_OUT_OF_RANGE, -} from 'pg-error-constants'; - -import { db } from './helpers'; -import { fetchExtraInfo, MANGADEX_ID } from './mangadex.js'; +import { INVALID_TEXT_REPRESENTATION, NUMERIC_VALUE_OUT_OF_RANGE, } from 'pg-error-constants'; import { HttpError } from '../utils/errors.js'; import { mangadexLogger } from '../utils/logging.js'; -import type { DatabaseId, MangaId } from '@/types/dbTypes'; -import type { Manga } from '@/types/db/manga'; -import type { Follow } from '@/types/db/follows'; +import { db } from './helpers'; +import { fetchExtraInfo, MANGADEX_ID } from './mangadex.js'; const links = { al: 'https://anilist.co/manga/', @@ -33,23 +30,32 @@ export function formatLinks(row: Record) { }); } +export type MangaData = { + mangaId: number, + title: string, + releaseInterval?: Date | null, + latestRelease?: Date | null, + estimatedRelease?: Date | null, + latestChapter?: number | null, + lastUpdated? : Date | null, +} & Omit + export type FullManga = { services?: object[], chapters?: object[], aliases?: string[] - manga: object + manga: MangaData } type FullMangaUnformatted = { services: any[], chapters?: any[], aliases: string[], - [key: string]: any -} +} & MangaData function formatFullManga(obj: Partial): FullManga { const out: FullManga = { - manga: {}, + manga: {} as any, }; if (obj.services) { @@ -66,7 +72,7 @@ function formatFullManga(obj: Partial): FullManga { out.aliases = obj.aliases; delete obj.aliases; } - out.manga = obj; + out.manga = obj as MangaData; return out; } @@ -91,12 +97,12 @@ export function getFullManga(mangaId: MangaId): Promise { const mdIdx = row.services.findIndex(v => v.serviceId === MANGADEX_ID); // If info doesn't exist or 2 weeks since last update - if ((!row.lastUpdated || (Date.now() - row.lastUpdated)/8.64E7 > 14) && mdIdx >= 0) { + if ((!row.lastUpdated || (Date.now() - (row.lastUpdated as any))/8.64E7 > 14) && mdIdx >= 0) { fetchExtraInfo(row.services[mdIdx].titleId, mangaId) .catch(mangadexLogger.error); } - formatLinks(row); + formatLinks(row as any); return formatFullManga(row); }); } diff --git a/web/src/api/services.ts b/web/src/api/services.ts index bc4d5542..50bd9e45 100644 --- a/web/src/api/services.ts +++ b/web/src/api/services.ts @@ -1,5 +1,5 @@ +import { ServiceForApi } from '@/types/api/services'; import { handleError, handleResponse } from './utilities'; -import { ServiceForApi } from '../../types/api/services'; /** * Fetches all services diff --git a/web/src/pages/manga/[mangaId].jsx b/web/src/pages/manga/[mangaId].tsx similarity index 58% rename from web/src/pages/manga/[mangaId].jsx rename to web/src/pages/manga/[mangaId].tsx index b78aa484..15dbbcd6 100644 --- a/web/src/pages/manga/[mangaId].jsx +++ b/web/src/pages/manga/[mangaId].tsx @@ -1,38 +1,41 @@ import { NextSeo } from 'next-seo'; import React from 'react'; +import { getUserFollows } from '@/db/db'; +import { getFullManga } from '@/db/manga'; +import type { FullMangaData } from '@/types/api/manga.js'; +import type { GetServerSidePropsExpress } from '@/types/nextjs'; +import { isInteger } from '@/webUtils/utilities'; import Manga from '../../components/Manga'; import withError from '../../utils/withError'; -import { isInteger } from '../../utils/utilities'; -import { getFullManga } from '../../../server/db/manga'; -import { getUserFollows } from '../../../server/db/db'; - -function MangaPage(props) { +function MangaPage(props: { manga: FullMangaData, follows: number[] }) { const { - manga, + manga: fullManga, follows, } = props; + const manga = fullManga.manga; + return ( <> - + ); } -export async function getServerSideProps({ req, params }) { - if (!isInteger(params.mangaId)) { +export const getServerSideProps: GetServerSidePropsExpress = async ({ req, params }) => { + if (!params || !isInteger(params.mangaId)) { return { props: { error: 404 }}; } @@ -49,7 +52,7 @@ export async function getServerSideProps({ req, params }) { .map(service => service.serviceId); } } catch (e) { - return { props: { error: e?.status || 404 }}; + return { props: { error: (e as any)?.status || 404 }}; } if (!manga) { @@ -64,5 +67,5 @@ export async function getServerSideProps({ req, params }) { follows: userFollows || [], }, }; -} +}; export default withError(MangaPage); diff --git a/web/src/utils/utilities.tsx b/web/src/utils/utilities.tsx index 696a32bb..ab373e93 100644 --- a/web/src/utils/utilities.tsx +++ b/web/src/utils/utilities.tsx @@ -1,11 +1,11 @@ +import type { FormValues } from '@/components/notifications/types'; +import type { NotificationField } from '@/types/api/notifications'; +import type { DatabaseId, MangaId } from '@/types/dbTypes'; import { format, formatDistanceToNowStrict } from 'date-fns'; import enLocale from 'date-fns/locale/en-GB'; import throttle from 'lodash.throttle'; import type { MouseEvent, MouseEventHandler } from 'react'; import { csrfHeader } from './csrf'; -import type { DatabaseId, MangaId } from '@/types/dbTypes'; -import type { FormValues } from '@/components/notifications/types'; -import type { NotificationField } from '@/types/api/notifications'; export const followUnfollow = (csrf: string, mangaId: MangaId, serviceId: DatabaseId | null): MouseEventHandler => { const url = serviceId ? `/api/user/follows?mangaId=${mangaId}&serviceId=${serviceId}` : @@ -190,7 +190,7 @@ export const statusToString = (status: number | string) => { } }; -export const isInteger = (s: any) => ( +export const isInteger = (s: any): s is number | string => ( Number.isInteger(s) || /^-?\d+$/.test(s) ); From 287b99c54c09440c7421e9472fefdb2a4ac2c452 Mon Sep 17 00:00:00 2001 From: s0hv Date: Thu, 9 May 2024 15:29:18 +0300 Subject: [PATCH 2/3] eslint fixes --- web/server/db/chapter.ts | 10 +++++++--- web/server/db/manga.ts | 7 +++++-- web/src/utils/utilities.tsx | 6 +++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/web/server/db/chapter.ts b/web/server/db/chapter.ts index ab29ac0b..9d5a596d 100644 --- a/web/server/db/chapter.ts +++ b/web/server/db/chapter.ts @@ -1,9 +1,13 @@ -import type { ChapterRelease, ChapterReleaseDates, MangaChapter, } from '@/types/api/chapter'; +import camelcaseKeys from 'camelcase-keys'; +import type { PendingQuery } from 'postgres'; +import type { + ChapterRelease, + ChapterReleaseDates, + MangaChapter, +} from '@/types/api/chapter'; import type { Chapter } from '@/types/db/chapter'; import type { DatabaseId, MangaId } from '@/types/dbTypes'; import type { DefaultExcept, PartialExcept } from '@/types/utility'; -import camelcaseKeys from 'camelcase-keys'; -import type { PendingQuery } from 'postgres'; import { NO_GROUP } from '../utils/constants.js'; import { db } from './helpers'; import { generateUpdate } from './utils'; diff --git a/web/server/db/manga.ts b/web/server/db/manga.ts index 5d6bcfcc..de6fbf36 100644 --- a/web/server/db/manga.ts +++ b/web/server/db/manga.ts @@ -1,9 +1,12 @@ +import camelcaseKeys from 'camelcase-keys'; +import { + INVALID_TEXT_REPRESENTATION, + NUMERIC_VALUE_OUT_OF_RANGE, +} from 'pg-error-constants'; import type { MangaInfoData } from '@/types/api/manga'; import type { Follow } from '@/types/db/follows'; import type { Manga } from '@/types/db/manga'; import type { DatabaseId, MangaId } from '@/types/dbTypes'; -import camelcaseKeys from 'camelcase-keys'; -import { INVALID_TEXT_REPRESENTATION, NUMERIC_VALUE_OUT_OF_RANGE, } from 'pg-error-constants'; import { HttpError } from '../utils/errors.js'; import { mangadexLogger } from '../utils/logging.js'; import { db } from './helpers'; diff --git a/web/src/utils/utilities.tsx b/web/src/utils/utilities.tsx index ab373e93..a9525ca9 100644 --- a/web/src/utils/utilities.tsx +++ b/web/src/utils/utilities.tsx @@ -1,10 +1,10 @@ -import type { FormValues } from '@/components/notifications/types'; -import type { NotificationField } from '@/types/api/notifications'; -import type { DatabaseId, MangaId } from '@/types/dbTypes'; import { format, formatDistanceToNowStrict } from 'date-fns'; import enLocale from 'date-fns/locale/en-GB'; import throttle from 'lodash.throttle'; import type { MouseEvent, MouseEventHandler } from 'react'; +import type { DatabaseId, MangaId } from '@/types/dbTypes'; +import type { NotificationField } from '@/types/api/notifications'; +import type { FormValues } from '@/components/notifications/types'; import { csrfHeader } from './csrf'; export const followUnfollow = (csrf: string, mangaId: MangaId, serviceId: DatabaseId | null): MouseEventHandler => { From 346114bacbfd6448f21dbdefca46e6a71cb15aee Mon Sep 17 00:00:00 2001 From: s0hv Date: Thu, 9 May 2024 16:01:47 +0300 Subject: [PATCH 3/3] Add release date on hover to grouped chapter list and link to manga page --- .../components/GroupedChapterList.test.jsx | 6 +-- web/server/db/chapter.ts | 51 +++++++++++-------- web/src/components/GroupedChapterList.tsx | 39 +++++++++++--- 3 files changed, 63 insertions(+), 33 deletions(-) diff --git a/web/__tests__/components/GroupedChapterList.test.jsx b/web/__tests__/components/GroupedChapterList.test.jsx index ec2b3e97..ee145d8e 100644 --- a/web/__tests__/components/GroupedChapterList.test.jsx +++ b/web/__tests__/components/GroupedChapterList.test.jsx @@ -3,16 +3,16 @@ import { render, screen } from '@testing-library/react'; import { vi } from 'vitest'; import { - GroupedChapterList, ChapterGroupWithCover, ChapterWithLink, + GroupedChapterList, } from '../../src/components/GroupedChapterList'; import { testChapterUrlFormat } from '../constants'; import { formatChapterTitle, formatChapterUrl, } from '../../src/utils/formatting'; -import { setupFaker, generateNSchemas, LatestChapter } from '../schemas'; +import { generateNSchemas, LatestChapter, setupFaker } from '../schemas'; describe('ChapterGroupWithCover', () => { @@ -71,7 +71,7 @@ describe('ChapterWithLink', () => { expect(screen.getByRole('listitem')).toBeInTheDocument(); // Chapter title should be properly formatted - expect(screen.getByText(new RegExp(formatChapterTitle(chapter) + '.+?'))).toBeInTheDocument(); + expect(screen.getByText(formatChapterTitle(chapter))).toBeInTheDocument(); // Link button should exist const linkBtn = screen.getByRole('button', { name: /Open chapter in new tab/i }); diff --git a/web/server/db/chapter.ts b/web/server/db/chapter.ts index 9d5a596d..5938045c 100644 --- a/web/server/db/chapter.ts +++ b/web/server/db/chapter.ts @@ -20,28 +20,35 @@ export const getChapterReleases = (mangaId: MangaId) => { export const getLatestChapters = (limit: number, offset: number, userId?: DatabaseId) => { return db.manyOrNone` - ${userId ? db.sql`WITH follows AS (SELECT DISTINCT manga_id, service_id FROM user_follows WHERE user_id=${userId})` : db.sql``} - SELECT - chapter_id, - chapters.title, - chapter_number, - chapter_decimal, - release_date, - g.name as "group", - chapters.service_id, - chapter_identifier, - m.title as manga, - m.manga_id, - ms.title_id, - mi.cover - FROM chapters - INNER JOIN groups g ON g.group_id = chapters.group_id - INNER JOIN manga m ON chapters.manga_id = m.manga_id - INNER JOIN manga_service ms ON chapters.manga_id = ms.manga_id AND chapters.service_id=ms.service_id - LEFT JOIN manga_info mi ON m.manga_id = mi.manga_id - ${userId ? db.sql`INNER JOIN follows f ON f.manga_id=m.manga_id AND (f.service_id IS NULL OR f.service_id=ms.service_id)` : db.sql``} - ORDER BY release_date DESC - LIMIT ${limit} ${offset ? db.sql`OFFSET ${offset}` : db.sql``}`; + ${userId ? db.sql`WITH follow_all AS ( + SELECT manga_id FROM user_follows WHERE user_id=${userId} GROUP BY manga_id HAVING COUNT(*) != COUNT(service_id) + ), + follows AS ( + SELECT DISTINCT manga_id, service_id FROM user_follows WHERE user_id=${userId} AND manga_id NOT IN (SELECT manga_id FROM follow_all) + UNION ALL + SELECT manga_id, NULL as service_id FROM follow_all + )` : db.sql``} + SELECT + chapter_id, + chapters.title, + chapter_number, + chapter_decimal, + release_date, + g.name as "group", + chapters.service_id, + chapter_identifier, + m.title as manga, + m.manga_id, + ms.title_id, + mi.cover + FROM chapters + INNER JOIN groups g ON g.group_id = chapters.group_id + INNER JOIN manga m ON chapters.manga_id = m.manga_id + INNER JOIN manga_service ms ON chapters.manga_id = ms.manga_id AND chapters.service_id=ms.service_id + LEFT JOIN manga_info mi ON m.manga_id = mi.manga_id + ${userId ? db.sql`INNER JOIN follows f ON f.manga_id=m.manga_id AND (f.service_id IS NULL OR f.service_id=ms.service_id)` : db.sql``} + ORDER BY release_date DESC + LIMIT ${limit} ${offset ? db.sql`OFFSET ${offset}` : db.sql``}`; }; export type AddChapter = DefaultExcept, diff --git a/web/src/components/GroupedChapterList.tsx b/web/src/components/GroupedChapterList.tsx index 45bd2ae6..934b86d9 100644 --- a/web/src/components/GroupedChapterList.tsx +++ b/web/src/components/GroupedChapterList.tsx @@ -10,6 +10,7 @@ import { IconButton, Paper, Skeleton, + Tooltip, Typography, } from '@mui/material'; @@ -19,6 +20,8 @@ import { formatChapterTitle, formatChapterUrl } from '../utils/formatting'; import { MangaCover } from './MangaCover'; import type { ServiceForApi } from '@/types/api/services'; import type { ChapterRelease } from '@/types/api/chapter'; +import { defaultDateFormat } from '@/webUtils/utilities'; +import type { DatabaseId } from '@/types/dbTypes'; export type ChapterComponentProps = { chapter: ChapterRelease @@ -27,9 +30,10 @@ export type ChapterComponentProps = { export type GroupComponentProps = PropsWithChildren<{ groupString: string | React.ReactNode group: string + mangaId: DatabaseId }> -export const ChapterGroupBase: FC> = ({ groupString, children }) => ( +export const ChapterGroupBase: FC> = ({ groupString, children }) => ( > = ({ group ); -export const ChapterGroupWithCover = (mangaToCover: Record): FC => ({ group, groupString, children }) => ( +export const ChapterGroupWithCover = (mangaToCover: Record): FC => ( + { mangaId, group, groupString, children } +) => (
- + + +
{children} @@ -64,7 +72,21 @@ export const ChapterWithLink = (services: Record): FC
- {formatChapterTitle(chapter)} + + + {formatChapterTitle(chapter)} + + = ({