implemented ratings, fixes
This commit is contained in:
parent
4801cf3a13
commit
d1da980501
31 changed files with 775 additions and 236 deletions
|
|
@ -4,7 +4,8 @@ import { BookChapterDeleteMutation } from '@generated/types';
|
|||
import { Link } from '@tanstack/react-router';
|
||||
import { bookFragment } from '../fragments';
|
||||
import { useToastMutation } from '../hooks/useToastMutation';
|
||||
import { bookChapterListQuery } from '../pages/work/book/$bookId/overview';
|
||||
import { bookChapterQuery } from '../pages/work/book/$bookId/chapter/$chapterId/view';
|
||||
import { bookChapterListQuery } from '../pages/work/book/$bookId/overview/contents';
|
||||
|
||||
export function BookChapterCard({
|
||||
bookId,
|
||||
|
|
@ -20,7 +21,7 @@ export function BookChapterCard({
|
|||
const { update: chapterDelete } = useToastMutation<BookChapterDeleteMutation>({
|
||||
mutation,
|
||||
successMsg: 'Chapter deleted successfully',
|
||||
refetchQueries: [bookChapterListQuery]
|
||||
refetchQueries: [bookChapterListQuery, bookChapterQuery]
|
||||
});
|
||||
return (
|
||||
<Card.Root width="100%" variant="elevated">
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export function BookChapterForm({
|
|||
</Box>
|
||||
<Stack direction="row">
|
||||
<Button onClick={async () => onSubmit()}>Submit</Button>
|
||||
<Link to={`/work/book/${bookId}/overview`}>
|
||||
<Link to={`/work/book/${bookId}/overview/contents`}>
|
||||
<Button>Go back</Button>
|
||||
</Link>
|
||||
</Stack>
|
||||
|
|
|
|||
23
app/client/components/BookOverviewTabs.tsx
Normal file
23
app/client/components/BookOverviewTabs.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Tabs } from '@chakra-ui/react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
|
||||
export function BookOverviewTabs({
|
||||
bookId,
|
||||
currentTab
|
||||
}: {
|
||||
readonly bookId: string;
|
||||
readonly currentTab: 'contents' | 'reviews';
|
||||
}) {
|
||||
return (
|
||||
<Tabs.Root defaultValue="members" value={currentTab}>
|
||||
<Tabs.List>
|
||||
<Link to={`/work/book/${bookId}/overview/contents`}>
|
||||
<Tabs.Trigger value="contents">Contents</Tabs.Trigger>
|
||||
</Link>
|
||||
<Link to={`/work/book/${bookId}/overview/reviews`}>
|
||||
<Tabs.Trigger value="reviews">Reviews</Tabs.Trigger>
|
||||
</Link>
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
48
app/client/components/RatingPanel.tsx
Normal file
48
app/client/components/RatingPanel.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { Button } from '@app/client/components/ui/button';
|
||||
import { Icon, Stack } from '@chakra-ui/react';
|
||||
import { RatingType } from '@generated/types';
|
||||
|
||||
export function RatingPanel({
|
||||
personalRating,
|
||||
onRatingSet
|
||||
}: {
|
||||
readonly personalRating: RatingType;
|
||||
readonly onRatingSet: (rating: RatingType) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<Stack direction="row">
|
||||
<Button
|
||||
size="sm"
|
||||
backgroundColor={personalRating === RatingType.Up ? 'green.500/80' : 'green.500'}
|
||||
borderColor="green.500"
|
||||
onClick={async () => {
|
||||
if (personalRating !== RatingType.Up) {
|
||||
await onRatingSet(RatingType.Up);
|
||||
return;
|
||||
}
|
||||
await onRatingSet(RatingType.None);
|
||||
}}
|
||||
>
|
||||
<Icon>
|
||||
<i className="nf nf-oct-thumbsup" />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
backgroundColor={personalRating === RatingType.Down ? 'red.500/80' : 'red.500'}
|
||||
borderColor="red.500"
|
||||
onClick={async () => {
|
||||
if (personalRating !== RatingType.Down) {
|
||||
await onRatingSet(RatingType.Down);
|
||||
return;
|
||||
}
|
||||
await onRatingSet(RatingType.None);
|
||||
}}
|
||||
>
|
||||
<Icon>
|
||||
<i className="nf nf-oct-thumbsdown" />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
54
app/client/components/ReviewCard.tsx
Normal file
54
app/client/components/ReviewCard.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Avatar } from '@app/client/components/ui/avatar';
|
||||
import { toFormattedDate } from '@app/client/helpers';
|
||||
import { Box, Card, Stack, Text } from '@chakra-ui/react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
|
||||
export function ReviewCard({
|
||||
review
|
||||
}: {
|
||||
readonly review: {
|
||||
readonly id: string;
|
||||
readonly username: string;
|
||||
readonly createdAt: string;
|
||||
readonly text: string;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<Stack direction="row" alignItems="start">
|
||||
<Link to={`/profile/${review.username}`}>
|
||||
<Avatar size="sm" fallback={review.username[0]} />
|
||||
</Link>
|
||||
<Card.Root variant="subtle" width="100%">
|
||||
<Card.Body paddingX="3" paddingY="1">
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" gap={1}>
|
||||
<Link to={`/profile/${review.username}`}>
|
||||
<Text
|
||||
color="colorPalette.400"
|
||||
_hover={{
|
||||
textDecoration: 'underline',
|
||||
textDecorationColor: 'colorPalette.700'
|
||||
}}
|
||||
>
|
||||
{review.username}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<Text color="fg.muted">{toFormattedDate({ value: review.createdAt })}</Text>
|
||||
</Stack>
|
||||
<Box width="100%" position="relative" padding={0}>
|
||||
<Text>{review.text}</Text>
|
||||
{/* <Button */}
|
||||
{/* size="sm" */}
|
||||
{/* position="absolute" */}
|
||||
{/* right="2px" */}
|
||||
{/* bottom="5px" */}
|
||||
{/* padding={0} */}
|
||||
{/* variant="ghost" */}
|
||||
{/* // onClick={async () => onReviewSubmit(text)} */}
|
||||
{/* ></Button> */}
|
||||
</Box>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
47
app/client/components/ReviewInput.tsx
Normal file
47
app/client/components/ReviewInput.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Avatar } from '@app/client/components/ui/avatar';
|
||||
import { Button, Icon, Stack, Textarea } from '@chakra-ui/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function ReviewInput({
|
||||
username,
|
||||
onReviewSubmit
|
||||
}: {
|
||||
readonly username: string;
|
||||
readonly onReviewSubmit: (text: string) => Promise<void>;
|
||||
}) {
|
||||
const [text, setText] = useState<string>('');
|
||||
return (
|
||||
<Stack direction="row" alignItems="start">
|
||||
<Avatar size="sm" fallback={username[0]} />
|
||||
<Stack width="100%" position="relative" borderRadius="md">
|
||||
<Textarea
|
||||
variant="subtle"
|
||||
placeholder="Write your review here..."
|
||||
resize="none"
|
||||
maxLength={700}
|
||||
maxLines={10}
|
||||
fontSize="md"
|
||||
autoresize
|
||||
value={text}
|
||||
onChange={event => setText(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
position="absolute"
|
||||
right="2px"
|
||||
bottom="5px"
|
||||
padding={0}
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
await onReviewSubmit(text);
|
||||
setText('');
|
||||
}}
|
||||
>
|
||||
<Icon>
|
||||
<i className="nf nf-fa-send" />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,9 +22,9 @@ export function WorkingListCard({
|
|||
<Card.Root width="100%" variant="elevated">
|
||||
<Card.Body>
|
||||
<Stack>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Stack direction={'row'} justifyContent="space-between">
|
||||
<Text as="div" minWidth={0}>
|
||||
<Link to={`/work/book/${id}/overview`}>
|
||||
<Link to={`/work/book/${id}/overview/contents`}>
|
||||
<Heading
|
||||
color="colorPalette.400"
|
||||
_hover={{
|
||||
|
|
@ -37,7 +37,7 @@ export function WorkingListCard({
|
|||
</Link>
|
||||
</Text>
|
||||
|
||||
<Stack direction="row" justifyContent="center" alignItems="center">
|
||||
<Stack direction="row" justifyContent="center" alignItems="center" hideBelow="md">
|
||||
<WorkingRatingBadge ratingTotal={ratingTotal} />
|
||||
<WorkingReviewsBadge reviewsTotal={reviewsTotal} />
|
||||
<WorkingProgressStatusBadge progressStatus={progressStatus} />
|
||||
|
|
@ -60,7 +60,7 @@ export function WorkingListCard({
|
|||
</Link>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row">
|
||||
<Stack maxWidth="100%" direction="row" overflow="auto" flexWrap="wrap">
|
||||
<Box>
|
||||
<Badge size="md" variant="solid">
|
||||
<Stack direction="row">
|
||||
|
|
@ -88,6 +88,12 @@ export function WorkingListCard({
|
|||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" justifyContent="left" alignItems="center" hideFrom="md">
|
||||
<WorkingRatingBadge ratingTotal={ratingTotal} />
|
||||
<WorkingReviewsBadge reviewsTotal={reviewsTotal} />
|
||||
<WorkingProgressStatusBadge progressStatus={progressStatus} />
|
||||
</Stack>
|
||||
|
||||
<Stack>
|
||||
<Text lineClamp={4}>{description}</Text>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -38,3 +38,16 @@ export const bookChapterFragment = gql`
|
|||
reviewsTotal
|
||||
}
|
||||
`;
|
||||
|
||||
export const reviewFragment = gql`
|
||||
fragment Review on Review {
|
||||
id
|
||||
author {
|
||||
id
|
||||
username
|
||||
}
|
||||
text
|
||||
ratingTotal
|
||||
createdAt
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ const isProd = mode === 'prod';
|
|||
|
||||
export const appName = import.meta.env.VITE_APP_NAME || 'project-ln';
|
||||
|
||||
const host = import.meta.env.VITE_HOST || 'localhost';
|
||||
const host = import.meta.env.VITE_DOMAIN_NAME || import.meta.env.VITE_HOST || 'localhost';
|
||||
const port = import.meta.env.VITE_PORT || '3000';
|
||||
export const url = `https://${host}:${port}`;
|
||||
const graphqlUri = `${url}/graphql`;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||
|
||||
<Stack flexDirection="row" alignItems="center" gap={4}>
|
||||
<Stack
|
||||
hideBelow="md"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
paddingY={1}
|
||||
|
|
@ -124,7 +125,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||
{identity != null ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger css={{ '&:hover': { cursor: 'pointer' } }}>
|
||||
<Avatar size="sm" />
|
||||
<Avatar size="sm" fallback={identity.data.user.username[0]} />
|
||||
</Menu.Trigger>
|
||||
<Menu.Positioner mt={2}>
|
||||
<Menu.Content backgroundColor={'bg.subtle'}>
|
||||
|
|
@ -169,7 +170,9 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||
</Stack>
|
||||
<Separator style={{ padding: '4px' }} />
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
<Stack hidden>
|
||||
<TanStackRouterDevtools />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ function NewBookForm() {
|
|||
});
|
||||
if (data != null)
|
||||
navigate({
|
||||
to: `/work/book/${data.bookCreate.id}/overview`
|
||||
to: `/work/book/${data.bookCreate.id}/overview/contents`
|
||||
});
|
||||
})}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ function RouteComponent() {
|
|||
}
|
||||
});
|
||||
|
||||
navigate({ to: `/work/book/${bookId}/overview` });
|
||||
navigate({ to: `/work/book/${bookId}/overview/contents` });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { gql, useQuery } from '@apollo/client';
|
||||
import { EditorSlate } from '@app/client/components/EditorSlate';
|
||||
import { bookChapterFragment } from '@app/client/fragments';
|
||||
import { Button, Heading, Icon, Skeleton, Stack, Text } from '@chakra-ui/react';
|
||||
import { BookChapterViewQuery, BookChapterViewNavigationQuery } from '@generated/types';
|
||||
import { Button, Heading, Icon, Separator, Skeleton, Stack, Text } from '@chakra-ui/react';
|
||||
import { BookChapterViewNavigationQuery, BookChapterViewQuery } from '@generated/types';
|
||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import { Descendant } from 'slate';
|
||||
|
||||
export const Route = createFileRoute('/work/book/$bookId/chapter/$chapterId/view')({
|
||||
component: RouteComponent
|
||||
|
|
@ -13,53 +12,35 @@ export const Route = createFileRoute('/work/book/$bookId/chapter/$chapterId/view
|
|||
function RouteComponent() {
|
||||
const { bookId, chapterId } = Route.useParams();
|
||||
|
||||
return (
|
||||
<Stack justifyContent="center" alignItems="center">
|
||||
<BookChapterView bookId={bookId} chapterId={chapterId} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function BookChapterView({
|
||||
bookId,
|
||||
chapterId
|
||||
}: {
|
||||
readonly bookId: string;
|
||||
readonly chapterId: string;
|
||||
}) {
|
||||
const { data } = useQuery<BookChapterViewQuery>(bookChapterQuery, {
|
||||
variables: { id: chapterId }
|
||||
variables: { id: chapterId },
|
||||
fetchPolicy: 'cache-and-network'
|
||||
});
|
||||
|
||||
const { data: navigationData } = useQuery<BookChapterViewNavigationQuery>(
|
||||
bookChapterNavigationQuery,
|
||||
{
|
||||
variables: { id: chapterId }
|
||||
variables: { id: chapterId },
|
||||
fetchPolicy: 'no-cache'
|
||||
}
|
||||
);
|
||||
|
||||
if (data == null || navigationData == null) return <Skeleton />;
|
||||
if (data == null) return <Skeleton />;
|
||||
|
||||
return (
|
||||
<Stack justifyContent="center" alignItems="center">
|
||||
<BookChapterView
|
||||
bookId={bookId}
|
||||
chapter={{ ...data.bookChapterOne, ...navigationData.bookChapterOne }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface BookChapterViewChapter {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
readonly foreword?: string | null;
|
||||
readonly footnote?: string | null;
|
||||
readonly content: Descendant[];
|
||||
readonly sequenceNumber: number;
|
||||
readonly reviewsTotal: number;
|
||||
readonly previousChapterId?: string | null;
|
||||
readonly nextChapterId?: string | null;
|
||||
readonly book: {
|
||||
readonly title: string;
|
||||
};
|
||||
}
|
||||
|
||||
function BookChapterView({
|
||||
bookId,
|
||||
chapter
|
||||
}: {
|
||||
readonly bookId: string;
|
||||
readonly chapter: BookChapterViewChapter;
|
||||
}) {
|
||||
return (
|
||||
<Stack
|
||||
maxWidth="840px"
|
||||
|
|
@ -70,18 +51,38 @@ function BookChapterView({
|
|||
>
|
||||
<Heading as="div" maxWidth="100%">
|
||||
<Text textAlign="center" minWidth={0}>
|
||||
{chapter.book.title}
|
||||
{data.bookChapterOne.book.title}
|
||||
</Text>
|
||||
<Text textAlign="center">-</Text>
|
||||
<Text textAlign="center" minWidth={0}>
|
||||
{chapter.title}
|
||||
{data.bookChapterOne.title}
|
||||
</Text>
|
||||
</Heading>
|
||||
<EditorSlate initialValue={chapter.content} readOnly />
|
||||
{data.bookChapterOne.foreword && (
|
||||
<>
|
||||
<Stack width="91%" justifyContent="left" alignItems="start" paddingY={2}>
|
||||
<Text fontWeight="bold">Foreword:</Text>
|
||||
<Text color="fg.muted">{data.bookChapterOne.foreword}</Text>
|
||||
</Stack>
|
||||
<Separator width="91%" />
|
||||
</>
|
||||
)}
|
||||
<EditorSlate initialValue={data.bookChapterOne.content} readOnly />
|
||||
{data.bookChapterOne.footnote && (
|
||||
<>
|
||||
<Separator width="91%" />
|
||||
<Stack width="91%" justifyContent="left" alignItems="start" paddingY={2}>
|
||||
<Text fontWeight="bold">Footnote:</Text>
|
||||
<Text color="fg.muted">{data.bookChapterOne.foreword}</Text>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text>{data.bookChapterOne.footnote}</Text>
|
||||
<ChapterNavigation
|
||||
bookId={bookId}
|
||||
previousChapterId={chapter.previousChapterId}
|
||||
nextChapterId={chapter.nextChapterId}
|
||||
previousChapterId={navigationData?.bookChapterOne.previousChapterId}
|
||||
nextChapterId={navigationData?.bookChapterOne.nextChapterId}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
|
@ -108,7 +109,7 @@ function ChapterNavigation({
|
|||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link to={`/work/book/${bookId}/overview`}>
|
||||
<Link to={`/work/book/${bookId}/overview/contents`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
borderLeftRadius={previousChapterId ? 0 : undefined}
|
||||
|
|
@ -134,7 +135,7 @@ function ChapterNavigation({
|
|||
);
|
||||
}
|
||||
|
||||
const bookChapterQuery = gql`
|
||||
export const bookChapterQuery = gql`
|
||||
query BookChapterView($id: ID!) {
|
||||
bookChapterOne(id: $id) {
|
||||
id
|
||||
|
|
@ -149,7 +150,7 @@ const bookChapterQuery = gql`
|
|||
${bookChapterFragment}
|
||||
`;
|
||||
|
||||
export const bookChapterNavigationQuery = gql`
|
||||
const bookChapterNavigationQuery = gql`
|
||||
query BookChapterViewNavigation($id: ID!) {
|
||||
bookChapterOne(id: $id) {
|
||||
id
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@ import { useToastMutation } from '@app/client/hooks/useToastMutation';
|
|||
import { Skeleton } from '@chakra-ui/react';
|
||||
import { BookChapterNewCreateMutation, BookChapterNewQuery } from '@generated/types';
|
||||
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router';
|
||||
import { bookChapterListQuery } from '../overview';
|
||||
import { bookChapterNavigationQuery } from './$chapterId/view';
|
||||
import { bookChapterListQuery } from '../overview/contents';
|
||||
|
||||
export const Route = createFileRoute('/work/book/$bookId/chapter/new')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -28,12 +27,12 @@ function RouteComponent() {
|
|||
const { update: chapterCreate } = useToastMutation<BookChapterNewCreateMutation>({
|
||||
mutation,
|
||||
successMsg: 'Chapter created successfully',
|
||||
refetchQueries: [bookChapterListQuery, bookChapterNavigationQuery]
|
||||
refetchQueries: [bookChapterListQuery]
|
||||
});
|
||||
|
||||
if (data == null) return <Skeleton />;
|
||||
if (identity?.data.user.id !== data.bookOne.author.id)
|
||||
throw redirect({ to: `/work/book/${id}/overview` });
|
||||
throw redirect({ to: `/work/book/${id}/overview/contents` });
|
||||
|
||||
return (
|
||||
<BookChapterForm
|
||||
|
|
@ -48,7 +47,7 @@ function RouteComponent() {
|
|||
}
|
||||
});
|
||||
|
||||
navigate({ to: `/work/book/${id}/overview` });
|
||||
navigate({ to: `/work/book/${id}/overview/contents` });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,13 @@
|
|||
import { gql, useQuery } from '@apollo/client';
|
||||
import { BookChapterCard } from '@app/client/components/BookChapterCard';
|
||||
import { PaginatedListSkeleton } from '@app/client/components/PaginatedListSkeleton';
|
||||
import { Pagination } from '@app/client/components/Pagination';
|
||||
import { Button } from '@app/client/components/ui/button';
|
||||
import { WorkingProgressStatusBadge } from '@app/client/components/WorkingProgressStatusBadge';
|
||||
import { WorkingRatingBadge } from '@app/client/components/WorkingRatingBadge';
|
||||
import { WorkingReviewsBadge } from '@app/client/components/WorkingReviewsTotalBadge';
|
||||
import { bookChapterFragment, bookFragment } from '@app/client/fragments';
|
||||
import { bookFragment } from '@app/client/fragments';
|
||||
import { toFormattedDate } from '@app/client/helpers';
|
||||
import { IdentityContext, useIdentity } from '@app/client/hooks/identity';
|
||||
import { usePaginatedQuery } from '@app/client/hooks/usePaginatedQuery';
|
||||
import { BookOne } from '@app/client/interfaces';
|
||||
import { Box, Heading, HStack, Icon, Skeleton, SkeletonText, Stack, Text } from '@chakra-ui/react';
|
||||
import { BookChapterListQuery, BookOverviewQuery } from '@generated/types';
|
||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { Heading, HStack, Skeleton, SkeletonText, Stack, Text } from '@chakra-ui/react';
|
||||
import { BookOverviewQuery } from '@generated/types';
|
||||
import { createFileRoute, Link, Outlet } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/work/book/$bookId/overview')({
|
||||
component: RouteComponent
|
||||
|
|
@ -26,28 +19,16 @@ function RouteComponent() {
|
|||
variables: { id }
|
||||
});
|
||||
|
||||
const identity = useIdentity();
|
||||
|
||||
return (
|
||||
<Stack width="100%" justifyContent="center" alignItems="center" padding={3}>
|
||||
{bookData != null ? (
|
||||
<BookOverview identity={identity} book={bookData.bookOne} />
|
||||
) : (
|
||||
<BookOverviewSkeleton />
|
||||
)}
|
||||
{bookData != null ? <BookOverview book={bookData.bookOne} /> : <BookOverviewSkeleton />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function BookOverview({
|
||||
identity,
|
||||
book
|
||||
}: {
|
||||
readonly identity: IdentityContext | null;
|
||||
readonly book: BookOne;
|
||||
}) {
|
||||
function BookOverview({ book }: { readonly book: BookOne }) {
|
||||
return (
|
||||
<Stack width="80%" maxWidth="840px" gap={3}>
|
||||
<Stack width={{ base: '100%', md: '80%' }} maxWidth="840px" gap={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Heading color="colorPalette.400" size="2xl" overflow="auto">
|
||||
{book.title}
|
||||
|
|
@ -101,119 +82,14 @@ function BookOverview({
|
|||
|
||||
<Text>{book.description}</Text>
|
||||
|
||||
<BookChapterList
|
||||
id={book.id}
|
||||
identity={identity}
|
||||
isAuthor={identity?.data.id === book.author.id}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function BookChapterList({
|
||||
id,
|
||||
identity,
|
||||
isAuthor
|
||||
}: {
|
||||
readonly id: string;
|
||||
readonly identity: IdentityContext | null;
|
||||
readonly isAuthor: boolean;
|
||||
}) {
|
||||
const [chaptersListPage, setChaptersListPage] = useState<number>(1);
|
||||
const [chaptersListPageSize, setChaptersListPageSize] = useState<number>(10);
|
||||
const startIndex = (chaptersListPage - 1) * chaptersListPageSize;
|
||||
|
||||
const { data } = usePaginatedQuery<BookChapterListQuery>({
|
||||
query: bookChapterListQuery,
|
||||
input: {
|
||||
bookId: id,
|
||||
filter: {
|
||||
offset: startIndex,
|
||||
size: chaptersListPageSize
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{data != null ? (
|
||||
<>
|
||||
<Stack>
|
||||
{data.bookChapterMany.items.length > 0 ? (
|
||||
<>
|
||||
{data.bookChapterMany.items.map(item => (
|
||||
<Stack key={`bookChapterList-${item.id}`}>
|
||||
<BookChapterCard
|
||||
bookId={id}
|
||||
chapterId={item.id}
|
||||
title={item.title}
|
||||
isAuthor={isAuthor}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Stack justifyContent="center" alignItems="center" pt={8}>
|
||||
<Text>No chapters here yet!</Text>
|
||||
{isAuthor && (
|
||||
<Link to={`/work/book/${id}/chapter/new`}>
|
||||
<Button variant="outline" size="sm">
|
||||
Create new chapter
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
{data.bookChapterMany.total > chaptersListPageSize && (
|
||||
<Stack justifyContent="center" alignItems="center">
|
||||
<Pagination
|
||||
total={data.bookChapterMany.total}
|
||||
page={chaptersListPage}
|
||||
setPage={setChaptersListPage}
|
||||
pageSize={chaptersListPageSize}
|
||||
setPageSize={setChaptersListPageSize}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack direction="row">
|
||||
<>
|
||||
{isAuthor && data.bookChapterMany.items.length !== 0 && (
|
||||
<Box>
|
||||
<Link to={`/work/book/${id}/chapter/new`}>
|
||||
<Button size="sm">Create new chapter</Button>
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
|
||||
{identity != null && !isAuthor && (
|
||||
<>
|
||||
<Button size="sm" backgroundColor="green.500" disabled>
|
||||
<Icon>
|
||||
<i className="nf nf-oct-thumbsup" />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Button size="sm" backgroundColor="red.500">
|
||||
<Icon>
|
||||
<i className="nf nf-oct-thumbsdown" />
|
||||
</Icon>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
) : (
|
||||
<PaginatedListSkeleton disableSearch />
|
||||
)}
|
||||
<Outlet />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function BookOverviewSkeleton() {
|
||||
return (
|
||||
<Stack width="80%" maxWidth="840px">
|
||||
<Stack width={{ base: '100%', md: '80%' }} maxWidth="840px">
|
||||
<Stack gap="6" maxWidth="100%">
|
||||
<HStack width="full">
|
||||
<SkeletonText noOfLines={5} />
|
||||
|
|
@ -224,7 +100,7 @@ function BookOverviewSkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
const bookQuery = gql`
|
||||
export const bookQuery = gql`
|
||||
query BookOverview($id: ID!) {
|
||||
bookOne(id: $id) {
|
||||
...Book
|
||||
|
|
@ -232,15 +108,3 @@ const bookQuery = gql`
|
|||
}
|
||||
${bookFragment}
|
||||
`;
|
||||
|
||||
export const bookChapterListQuery = gql`
|
||||
query BookChapterList($input: BookChapterManyInput!) {
|
||||
bookChapterMany(input: $input) {
|
||||
items {
|
||||
...BookChapter
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
${bookChapterFragment}
|
||||
`;
|
||||
|
|
|
|||
177
app/client/pages/work/book/$bookId/overview/contents.tsx
Normal file
177
app/client/pages/work/book/$bookId/overview/contents.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { gql, useMutation, useQuery } from '@apollo/client';
|
||||
import { BookChapterCard } from '@app/client/components/BookChapterCard';
|
||||
import { BookOverviewTabs } from '@app/client/components/BookOverviewTabs';
|
||||
import { PaginatedListSkeleton } from '@app/client/components/PaginatedListSkeleton';
|
||||
import { Pagination } from '@app/client/components/Pagination';
|
||||
import { RatingPanel } from '@app/client/components/RatingPanel';
|
||||
import { Button } from '@app/client/components/ui/button';
|
||||
import { bookChapterFragment } from '@app/client/fragments';
|
||||
import { useIdentity } from '@app/client/hooks/identity';
|
||||
import { usePaginatedQuery } from '@app/client/hooks/usePaginatedQuery';
|
||||
import { Box, Skeleton, Stack, Text } from '@chakra-ui/react';
|
||||
import { BookChapterListQuery, BookOverviewRatingQuery } from '@generated/types';
|
||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const Route = createFileRoute('/work/book/$bookId/overview/contents')({
|
||||
component: RouteComponent
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { bookId: id } = Route.useParams();
|
||||
|
||||
const identity = useIdentity();
|
||||
|
||||
const { data } = useQuery<BookOverviewRatingQuery>(bookRatingQuery, {
|
||||
variables: { id }
|
||||
});
|
||||
|
||||
const [ratingUpdate] = useMutation<BookOverviewRatingQuery>(bookRatingMutation, {
|
||||
refetchQueries: [bookRatingQuery]
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BookOverviewTabs bookId={id} currentTab="contents" />
|
||||
|
||||
{data != null ? (
|
||||
<>
|
||||
<BookChapterList
|
||||
id={data.bookOne.id}
|
||||
isAuthor={identity?.data.id === data.bookOne.author.id}
|
||||
/>
|
||||
|
||||
{identity != null &&
|
||||
identity?.data.id !== data.bookOne.author.id &&
|
||||
data.bookOne.chaptersTotal > 0 && (
|
||||
<RatingPanel
|
||||
personalRating={data.bookOne.personalRating}
|
||||
onRatingSet={async ratingType => {
|
||||
await ratingUpdate({
|
||||
variables: {
|
||||
input: {
|
||||
bookId: data.bookOne.id,
|
||||
ratingType
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Skeleton height="200px" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BookChapterList({ id, isAuthor }: { readonly id: string; readonly isAuthor: boolean }) {
|
||||
const [chaptersListPage, setChaptersListPage] = useState<number>(1);
|
||||
const [chaptersListPageSize, setChaptersListPageSize] = useState<number>(10);
|
||||
const startIndex = (chaptersListPage - 1) * chaptersListPageSize;
|
||||
|
||||
const { data } = usePaginatedQuery<BookChapterListQuery>({
|
||||
query: bookChapterListQuery,
|
||||
input: {
|
||||
bookId: id,
|
||||
filter: {
|
||||
offset: startIndex,
|
||||
size: chaptersListPageSize
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{data != null ? (
|
||||
<>
|
||||
<Stack>
|
||||
{data.bookChapterMany.items.length > 0 ? (
|
||||
<>
|
||||
{data.bookChapterMany.items.map(item => (
|
||||
<Stack key={`bookChapterList-${item.id}`}>
|
||||
<BookChapterCard
|
||||
bookId={id}
|
||||
chapterId={item.id}
|
||||
title={item.title}
|
||||
isAuthor={isAuthor}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Stack justifyContent="center" alignItems="center" pt={8}>
|
||||
<Text>No chapters here yet!</Text>
|
||||
{isAuthor && (
|
||||
<Link to={`/work/book/${id}/chapter/new`}>
|
||||
<Button variant="outline" size="sm">
|
||||
Create new chapter
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
{data.bookChapterMany.total > chaptersListPageSize && (
|
||||
<Stack justifyContent="center" alignItems="center">
|
||||
<Pagination
|
||||
total={data.bookChapterMany.total}
|
||||
page={chaptersListPage}
|
||||
setPage={setChaptersListPage}
|
||||
pageSize={chaptersListPageSize}
|
||||
setPageSize={setChaptersListPageSize}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack direction="row">
|
||||
<>
|
||||
{isAuthor && data.bookChapterMany.items.length !== 0 && (
|
||||
<Box>
|
||||
<Link to={`/work/book/${id}/chapter/new`}>
|
||||
<Button size="sm">Create new chapter</Button>
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
</Stack>
|
||||
</>
|
||||
) : (
|
||||
<PaginatedListSkeleton disableSearch />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export const bookChapterListQuery = gql`
|
||||
query BookChapterList($input: BookChapterManyInput!) {
|
||||
bookChapterMany(input: $input) {
|
||||
items {
|
||||
...BookChapter
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
${bookChapterFragment}
|
||||
`;
|
||||
|
||||
const bookRatingQuery = gql`
|
||||
query BookOverviewRating($id: ID!) {
|
||||
bookOne(id: $id) {
|
||||
id
|
||||
ratingTotal
|
||||
personalRating
|
||||
chaptersTotal
|
||||
author {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const bookRatingMutation = gql`
|
||||
mutation BookOverviewRatingUpdate($input: RatingSetInput!) {
|
||||
ratingSet(input: $input)
|
||||
}
|
||||
`;
|
||||
119
app/client/pages/work/book/$bookId/overview/reviews.tsx
Normal file
119
app/client/pages/work/book/$bookId/overview/reviews.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { gql } from '@apollo/client';
|
||||
import { BookOverviewTabs } from '@app/client/components/BookOverviewTabs';
|
||||
import { Pagination } from '@app/client/components/Pagination';
|
||||
import { reviewFragment } from '@app/client/fragments';
|
||||
import { useIdentity } from '@app/client/hooks/identity';
|
||||
import { usePaginatedQuery } from '@app/client/hooks/usePaginatedQuery';
|
||||
import { useToastMutation } from '@app/client/hooks/useToastMutation';
|
||||
import { Skeleton, Stack, Text } from '@chakra-ui/react';
|
||||
import { BookReviewCreateMutation, BookReviewsQuery } from '@generated/types';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { bookQuery } from '../overview';
|
||||
import { ReviewInput } from '@app/client/components/ReviewInput';
|
||||
import { ReviewCard } from '@app/client/components/ReviewCard';
|
||||
|
||||
export const Route = createFileRoute('/work/book/$bookId/overview/reviews')({
|
||||
component: RouteComponent
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { bookId: id } = Route.useParams();
|
||||
const identity = useIdentity();
|
||||
|
||||
const [reviewsPage, setChaptersListPage] = useState<number>(1);
|
||||
const [reviewsPageSize, setChaptersListPageSize] = useState<number>(10);
|
||||
const startIndex = (reviewsPage - 1) * reviewsPageSize;
|
||||
|
||||
const { data } = usePaginatedQuery<BookReviewsQuery>({
|
||||
query: bookReviewsQuery,
|
||||
input: {
|
||||
bookId: id,
|
||||
filter: {
|
||||
offset: startIndex,
|
||||
size: reviewsPageSize
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { update: reviewCreate } = useToastMutation<BookReviewCreateMutation>({
|
||||
mutation: bookReviewCreateMutation,
|
||||
successMsg: 'Review posted!',
|
||||
refetchQueries: [bookReviewsQuery, bookQuery]
|
||||
});
|
||||
|
||||
if (data == null) return <Skeleton />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BookOverviewTabs bookId={id} currentTab="reviews" />
|
||||
|
||||
{identity != null && reviewsPage === 1 && (
|
||||
<ReviewInput
|
||||
username={identity?.data.user.username}
|
||||
onReviewSubmit={async text => {
|
||||
await reviewCreate({
|
||||
variables: {
|
||||
input: {
|
||||
bookId: id,
|
||||
text
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.reviewMany.items.length === 0 ? (
|
||||
<Stack justifyContent="center" alignItems="center" pt={8}>
|
||||
<Text>No reviews here yet!</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
data.reviewMany.items.map(item => (
|
||||
<ReviewCard
|
||||
key={`bookReview#${item.id}`}
|
||||
review={{
|
||||
id: item.id,
|
||||
username: item.author.username,
|
||||
text: item.text,
|
||||
createdAt: item.createdAt
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{data.reviewMany.total > reviewsPageSize && (
|
||||
<Stack justifyContent="center" alignItems="center">
|
||||
<Pagination
|
||||
total={data.reviewMany.total}
|
||||
page={reviewsPage}
|
||||
setPage={setChaptersListPage}
|
||||
pageSize={reviewsPageSize}
|
||||
setPageSize={setChaptersListPageSize}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const bookReviewsQuery = gql`
|
||||
query BookReviews($input: ReviewManyInput!) {
|
||||
reviewMany(input: $input) {
|
||||
items {
|
||||
...Review
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
${reviewFragment}
|
||||
`;
|
||||
|
||||
const bookReviewCreateMutation = gql`
|
||||
mutation BookReviewCreate($input: ReviewCreateInput!) {
|
||||
reviewCreate(input: $input) {
|
||||
...Review
|
||||
}
|
||||
}
|
||||
${reviewFragment}
|
||||
`;
|
||||
|
|
@ -19,6 +19,8 @@ import { Route as IndexImport } from './pages/index'
|
|||
import { Route as ProfileUsernameImport } from './pages/profile/$username'
|
||||
import { Route as NewBookImport } from './pages/new/book'
|
||||
import { Route as WorkBookBookIdOverviewImport } from './pages/work/book/$bookId/overview'
|
||||
import { Route as WorkBookBookIdOverviewReviewsImport } from './pages/work/book/$bookId/overview/reviews'
|
||||
import { Route as WorkBookBookIdOverviewContentsImport } from './pages/work/book/$bookId/overview/contents'
|
||||
import { Route as WorkBookBookIdChapterNewImport } from './pages/work/book/$bookId/chapter/new'
|
||||
import { Route as WorkBookBookIdChapterChapterIdViewImport } from './pages/work/book/$bookId/chapter/$chapterId/view'
|
||||
import { Route as WorkBookBookIdChapterChapterIdEditImport } from './pages/work/book/$bookId/chapter/$chapterId/edit'
|
||||
|
|
@ -73,6 +75,20 @@ const WorkBookBookIdOverviewRoute = WorkBookBookIdOverviewImport.update({
|
|||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const WorkBookBookIdOverviewReviewsRoute =
|
||||
WorkBookBookIdOverviewReviewsImport.update({
|
||||
id: '/reviews',
|
||||
path: '/reviews',
|
||||
getParentRoute: () => WorkBookBookIdOverviewRoute,
|
||||
} as any)
|
||||
|
||||
const WorkBookBookIdOverviewContentsRoute =
|
||||
WorkBookBookIdOverviewContentsImport.update({
|
||||
id: '/contents',
|
||||
path: '/contents',
|
||||
getParentRoute: () => WorkBookBookIdOverviewRoute,
|
||||
} as any)
|
||||
|
||||
const WorkBookBookIdChapterNewRoute = WorkBookBookIdChapterNewImport.update({
|
||||
id: '/work/book/$bookId/chapter/new',
|
||||
path: '/work/book/$bookId/chapter/new',
|
||||
|
|
@ -160,6 +176,20 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof WorkBookBookIdChapterNewImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/work/book/$bookId/overview/contents': {
|
||||
id: '/work/book/$bookId/overview/contents'
|
||||
path: '/contents'
|
||||
fullPath: '/work/book/$bookId/overview/contents'
|
||||
preLoaderRoute: typeof WorkBookBookIdOverviewContentsImport
|
||||
parentRoute: typeof WorkBookBookIdOverviewImport
|
||||
}
|
||||
'/work/book/$bookId/overview/reviews': {
|
||||
id: '/work/book/$bookId/overview/reviews'
|
||||
path: '/reviews'
|
||||
fullPath: '/work/book/$bookId/overview/reviews'
|
||||
preLoaderRoute: typeof WorkBookBookIdOverviewReviewsImport
|
||||
parentRoute: typeof WorkBookBookIdOverviewImport
|
||||
}
|
||||
'/work/book/$bookId/chapter/$chapterId/edit': {
|
||||
id: '/work/book/$bookId/chapter/$chapterId/edit'
|
||||
path: '/work/book/$bookId/chapter/$chapterId/edit'
|
||||
|
|
@ -179,6 +209,22 @@ declare module '@tanstack/react-router' {
|
|||
|
||||
// Create and export the route tree
|
||||
|
||||
interface WorkBookBookIdOverviewRouteChildren {
|
||||
WorkBookBookIdOverviewContentsRoute: typeof WorkBookBookIdOverviewContentsRoute
|
||||
WorkBookBookIdOverviewReviewsRoute: typeof WorkBookBookIdOverviewReviewsRoute
|
||||
}
|
||||
|
||||
const WorkBookBookIdOverviewRouteChildren: WorkBookBookIdOverviewRouteChildren =
|
||||
{
|
||||
WorkBookBookIdOverviewContentsRoute: WorkBookBookIdOverviewContentsRoute,
|
||||
WorkBookBookIdOverviewReviewsRoute: WorkBookBookIdOverviewReviewsRoute,
|
||||
}
|
||||
|
||||
const WorkBookBookIdOverviewRouteWithChildren =
|
||||
WorkBookBookIdOverviewRoute._addFileChildren(
|
||||
WorkBookBookIdOverviewRouteChildren,
|
||||
)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
|
|
@ -187,8 +233,10 @@ export interface FileRoutesByFullPath {
|
|||
'/register': typeof RegisterRoute
|
||||
'/new/book': typeof NewBookRoute
|
||||
'/profile/$username': typeof ProfileUsernameRoute
|
||||
'/work/book/$bookId/overview': typeof WorkBookBookIdOverviewRoute
|
||||
'/work/book/$bookId/overview': typeof WorkBookBookIdOverviewRouteWithChildren
|
||||
'/work/book/$bookId/chapter/new': typeof WorkBookBookIdChapterNewRoute
|
||||
'/work/book/$bookId/overview/contents': typeof WorkBookBookIdOverviewContentsRoute
|
||||
'/work/book/$bookId/overview/reviews': typeof WorkBookBookIdOverviewReviewsRoute
|
||||
'/work/book/$bookId/chapter/$chapterId/edit': typeof WorkBookBookIdChapterChapterIdEditRoute
|
||||
'/work/book/$bookId/chapter/$chapterId/view': typeof WorkBookBookIdChapterChapterIdViewRoute
|
||||
}
|
||||
|
|
@ -201,8 +249,10 @@ export interface FileRoutesByTo {
|
|||
'/register': typeof RegisterRoute
|
||||
'/new/book': typeof NewBookRoute
|
||||
'/profile/$username': typeof ProfileUsernameRoute
|
||||
'/work/book/$bookId/overview': typeof WorkBookBookIdOverviewRoute
|
||||
'/work/book/$bookId/overview': typeof WorkBookBookIdOverviewRouteWithChildren
|
||||
'/work/book/$bookId/chapter/new': typeof WorkBookBookIdChapterNewRoute
|
||||
'/work/book/$bookId/overview/contents': typeof WorkBookBookIdOverviewContentsRoute
|
||||
'/work/book/$bookId/overview/reviews': typeof WorkBookBookIdOverviewReviewsRoute
|
||||
'/work/book/$bookId/chapter/$chapterId/edit': typeof WorkBookBookIdChapterChapterIdEditRoute
|
||||
'/work/book/$bookId/chapter/$chapterId/view': typeof WorkBookBookIdChapterChapterIdViewRoute
|
||||
}
|
||||
|
|
@ -216,8 +266,10 @@ export interface FileRoutesById {
|
|||
'/register': typeof RegisterRoute
|
||||
'/new/book': typeof NewBookRoute
|
||||
'/profile/$username': typeof ProfileUsernameRoute
|
||||
'/work/book/$bookId/overview': typeof WorkBookBookIdOverviewRoute
|
||||
'/work/book/$bookId/overview': typeof WorkBookBookIdOverviewRouteWithChildren
|
||||
'/work/book/$bookId/chapter/new': typeof WorkBookBookIdChapterNewRoute
|
||||
'/work/book/$bookId/overview/contents': typeof WorkBookBookIdOverviewContentsRoute
|
||||
'/work/book/$bookId/overview/reviews': typeof WorkBookBookIdOverviewReviewsRoute
|
||||
'/work/book/$bookId/chapter/$chapterId/edit': typeof WorkBookBookIdChapterChapterIdEditRoute
|
||||
'/work/book/$bookId/chapter/$chapterId/view': typeof WorkBookBookIdChapterChapterIdViewRoute
|
||||
}
|
||||
|
|
@ -234,6 +286,8 @@ export interface FileRouteTypes {
|
|||
| '/profile/$username'
|
||||
| '/work/book/$bookId/overview'
|
||||
| '/work/book/$bookId/chapter/new'
|
||||
| '/work/book/$bookId/overview/contents'
|
||||
| '/work/book/$bookId/overview/reviews'
|
||||
| '/work/book/$bookId/chapter/$chapterId/edit'
|
||||
| '/work/book/$bookId/chapter/$chapterId/view'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
|
|
@ -247,6 +301,8 @@ export interface FileRouteTypes {
|
|||
| '/profile/$username'
|
||||
| '/work/book/$bookId/overview'
|
||||
| '/work/book/$bookId/chapter/new'
|
||||
| '/work/book/$bookId/overview/contents'
|
||||
| '/work/book/$bookId/overview/reviews'
|
||||
| '/work/book/$bookId/chapter/$chapterId/edit'
|
||||
| '/work/book/$bookId/chapter/$chapterId/view'
|
||||
id:
|
||||
|
|
@ -260,6 +316,8 @@ export interface FileRouteTypes {
|
|||
| '/profile/$username'
|
||||
| '/work/book/$bookId/overview'
|
||||
| '/work/book/$bookId/chapter/new'
|
||||
| '/work/book/$bookId/overview/contents'
|
||||
| '/work/book/$bookId/overview/reviews'
|
||||
| '/work/book/$bookId/chapter/$chapterId/edit'
|
||||
| '/work/book/$bookId/chapter/$chapterId/view'
|
||||
fileRoutesById: FileRoutesById
|
||||
|
|
@ -273,7 +331,7 @@ export interface RootRouteChildren {
|
|||
RegisterRoute: typeof RegisterRoute
|
||||
NewBookRoute: typeof NewBookRoute
|
||||
ProfileUsernameRoute: typeof ProfileUsernameRoute
|
||||
WorkBookBookIdOverviewRoute: typeof WorkBookBookIdOverviewRoute
|
||||
WorkBookBookIdOverviewRoute: typeof WorkBookBookIdOverviewRouteWithChildren
|
||||
WorkBookBookIdChapterNewRoute: typeof WorkBookBookIdChapterNewRoute
|
||||
WorkBookBookIdChapterChapterIdEditRoute: typeof WorkBookBookIdChapterChapterIdEditRoute
|
||||
WorkBookBookIdChapterChapterIdViewRoute: typeof WorkBookBookIdChapterChapterIdViewRoute
|
||||
|
|
@ -287,7 +345,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||
RegisterRoute: RegisterRoute,
|
||||
NewBookRoute: NewBookRoute,
|
||||
ProfileUsernameRoute: ProfileUsernameRoute,
|
||||
WorkBookBookIdOverviewRoute: WorkBookBookIdOverviewRoute,
|
||||
WorkBookBookIdOverviewRoute: WorkBookBookIdOverviewRouteWithChildren,
|
||||
WorkBookBookIdChapterNewRoute: WorkBookBookIdChapterNewRoute,
|
||||
WorkBookBookIdChapterChapterIdEditRoute:
|
||||
WorkBookBookIdChapterChapterIdEditRoute,
|
||||
|
|
@ -340,11 +398,23 @@ export const routeTree = rootRoute
|
|||
"filePath": "profile/$username.tsx"
|
||||
},
|
||||
"/work/book/$bookId/overview": {
|
||||
"filePath": "work/book/$bookId/overview.tsx"
|
||||
"filePath": "work/book/$bookId/overview.tsx",
|
||||
"children": [
|
||||
"/work/book/$bookId/overview/contents",
|
||||
"/work/book/$bookId/overview/reviews"
|
||||
]
|
||||
},
|
||||
"/work/book/$bookId/chapter/new": {
|
||||
"filePath": "work/book/$bookId/chapter/new.tsx"
|
||||
},
|
||||
"/work/book/$bookId/overview/contents": {
|
||||
"filePath": "work/book/$bookId/overview/contents.tsx",
|
||||
"parent": "/work/book/$bookId/overview"
|
||||
},
|
||||
"/work/book/$bookId/overview/reviews": {
|
||||
"filePath": "work/book/$bookId/overview/reviews.tsx",
|
||||
"parent": "/work/book/$bookId/overview"
|
||||
},
|
||||
"/work/book/$bookId/chapter/$chapterId/edit": {
|
||||
"filePath": "work/book/$bookId/chapter/$chapterId/edit.tsx"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -29,12 +29,17 @@ export function toAppConfig(): AppConfig {
|
|||
const host = process.env.VITE_HOST || 'localhost';
|
||||
const port = parseInt(process.env.VITE_PORT || '3000');
|
||||
|
||||
const hostRemote = process.env.VITE_HOST_REMOTE || 'localhost';
|
||||
const domainName = process.env.VITE_DOMAIN_NAME || 'localhost';
|
||||
|
||||
const urlIp = `http://${hostRemote}:${port}`;
|
||||
const urlDomain = `http://${domainName}:${port}`;
|
||||
const urlRemote = `https://${host}:${port}`;
|
||||
const urlLocal = host !== 'localhost' ? `https://localhost:${port}` : undefined;
|
||||
|
||||
const limit = { limit: '50mb' };
|
||||
|
||||
const origin = urlLocal != null ? [urlLocal, urlRemote] : [urlRemote];
|
||||
const origin = urlLocal != null ? [urlLocal, urlRemote, urlIp, urlDomain] : [urlRemote];
|
||||
const corsOptions: cors.CorsOptions = {
|
||||
origin,
|
||||
credentials: true
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { BookChapterOneResolver } from './resolvers/bookChapterOneResolver';
|
|||
import { BookManyResolver } from './resolvers/bookManyResolver';
|
||||
import { BookOneResolver } from './resolvers/bookOneResolver';
|
||||
import { IdentityResolver } from './resolvers/identityResolver';
|
||||
import { ReviewManyResolver } from './resolvers/reviewManyResolver';
|
||||
import { UserOneResolver } from './resolvers/userOneResolver';
|
||||
|
||||
/* Mutation */
|
||||
|
|
@ -17,6 +18,8 @@ import { BookChapterCreateResolver } from './resolvers/bookChapterCreateResolver
|
|||
import { BookChapterDeleteResolver } from './resolvers/bookChapterDelete';
|
||||
import { BookChapterUpdateResolver } from './resolvers/bookChapterUpdateResolver';
|
||||
import { BookCreateResolver } from './resolvers/bookCreateResolver';
|
||||
import { RatingSetResolver } from './resolvers/ratingSetResolver';
|
||||
import { ReviewCreateResolver } from './resolvers/reviewCreateResolver';
|
||||
|
||||
export const Query: QueryResolvers = {
|
||||
bookOne: BookOneResolver,
|
||||
|
|
@ -24,6 +27,7 @@ export const Query: QueryResolvers = {
|
|||
bookChapterOne: BookChapterOneResolver,
|
||||
bookChapterMany: BookChapterManyResolver,
|
||||
identity: IdentityResolver,
|
||||
reviewMany: ReviewManyResolver,
|
||||
userOne: UserOneResolver
|
||||
};
|
||||
|
||||
|
|
@ -34,7 +38,9 @@ export const Mutation: MutationResolvers = {
|
|||
bookCreate: BookCreateResolver,
|
||||
bookChapterCreate: BookChapterCreateResolver,
|
||||
bookChapterDelete: BookChapterDeleteResolver,
|
||||
bookChapterUpdate: BookChapterUpdateResolver
|
||||
bookChapterUpdate: BookChapterUpdateResolver,
|
||||
ratingSet: RatingSetResolver,
|
||||
reviewCreate: ReviewCreateResolver
|
||||
};
|
||||
|
||||
export const Subscription = {};
|
||||
|
|
|
|||
|
|
@ -13,12 +13,29 @@ export async function BookChapterDeleteResolver(
|
|||
ensureIdentity(identity);
|
||||
const model = await prisma.bookChapter.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: { book: { select: { id: true, authorId: true } } }
|
||||
select: {
|
||||
sequenceNumber: true,
|
||||
book: { select: { id: true, authorId: true } }
|
||||
}
|
||||
});
|
||||
if (identity.id !== model.book.authorId) throw new Error('Operation is not permitted.');
|
||||
|
||||
await prisma.bookChapter.delete({ where: { id } });
|
||||
|
||||
await prisma.bookChapter.updateMany({
|
||||
where: {
|
||||
bookId: model.book.id,
|
||||
sequenceNumber: {
|
||||
gt: model.sequenceNumber
|
||||
}
|
||||
},
|
||||
data: {
|
||||
sequenceNumber: {
|
||||
decrement: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await updateBookStats(model.book.id);
|
||||
const book = await prisma.book.findUniqueOrThrow({ where: { id: model.book.id } });
|
||||
return toBookSchema(book, { req, res, identity });
|
||||
|
|
|
|||
|
|
@ -12,13 +12,26 @@ export async function BookChapterUpdateResolver(
|
|||
): Promise<BookChapter> {
|
||||
ensureIdentity(identity);
|
||||
|
||||
const model = await prisma.bookChapter.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
select: {
|
||||
book: {
|
||||
select: {
|
||||
authorId: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (identity.id !== model.book.authorId) throw new Error('Operation is not permitted.');
|
||||
|
||||
if (input.title.length > 60) throw new Error('Title is too long.');
|
||||
if (input.foreword != null && input.foreword.length > 500)
|
||||
throw new Error('Foreword is too long.');
|
||||
if (input.footnote != null && input.footnote.length > 500)
|
||||
throw new Error('Footnote is too long.');
|
||||
|
||||
const model = await prisma.bookChapter.update({
|
||||
const modelUpdated = await prisma.bookChapter.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
title: input.title,
|
||||
|
|
@ -29,6 +42,6 @@ export async function BookChapterUpdateResolver(
|
|||
}
|
||||
});
|
||||
|
||||
await updateBookStats(model.bookId);
|
||||
return toBookChapterSchema(model, { req, res, identity });
|
||||
await updateBookStats(modelUpdated.bookId);
|
||||
return toBookChapterSchema(modelUpdated, { req, res, identity });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import { IdentityContext } from '@app/server/identity';
|
|||
import { prisma } from '@app/server/prisma/client';
|
||||
import {
|
||||
Book as BookSchema,
|
||||
RatingType,
|
||||
RatingType as RatingTypeSchema,
|
||||
WorkingProgressStatus as WorkingProgressStatusSchema
|
||||
} from '@generated/types';
|
||||
import { Book, WorkingProgressStatus } from '@prisma/client';
|
||||
import { Book, RatingType, WorkingProgressStatus } from '@prisma/client';
|
||||
import { toUserSchema } from './userMapper';
|
||||
|
||||
export async function toBookSchema(
|
||||
|
|
@ -38,14 +38,22 @@ export async function toBookSchema(
|
|||
});
|
||||
return chaptersCount;
|
||||
}),
|
||||
// todo: ratings implementation
|
||||
ratingTotal: await call(async () => {
|
||||
const ratingsCount = await prisma.rating.count({
|
||||
const ratingsUpCount = await prisma.rating.count({
|
||||
where: {
|
||||
bookId: model.id
|
||||
bookId: model.id,
|
||||
type: RatingType.up
|
||||
}
|
||||
});
|
||||
return ratingsCount;
|
||||
|
||||
const ratingsDownCount = await prisma.rating.count({
|
||||
where: {
|
||||
bookId: model.id,
|
||||
type: RatingType.down
|
||||
}
|
||||
});
|
||||
|
||||
return ratingsUpCount - ratingsDownCount;
|
||||
}),
|
||||
reviewsTotal: await call(async () => {
|
||||
const reviewsCount = await prisma.review.count({
|
||||
|
|
@ -56,16 +64,16 @@ export async function toBookSchema(
|
|||
return reviewsCount;
|
||||
}),
|
||||
personalRating: await call(async () => {
|
||||
if (identity == null) return RatingType.None;
|
||||
if (identity == null) return RatingTypeSchema.None;
|
||||
const rating = await prisma.rating.findUnique({
|
||||
where: {
|
||||
userId_bookChapterId: {
|
||||
userId_bookId: {
|
||||
userId: identity.id,
|
||||
bookChapterId: model.id
|
||||
bookId: model.id
|
||||
}
|
||||
}
|
||||
});
|
||||
if (rating == null) return RatingType.None;
|
||||
if (rating == null) return RatingTypeSchema.None;
|
||||
ensureRatingType(rating.type);
|
||||
return rating.type;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export async function toReviewSchema(model: Review): Promise<ReviewSchema> {
|
|||
return toUserSchema(userModel);
|
||||
}),
|
||||
text: model.text,
|
||||
createdAt: model.createdAt.toISOString(),
|
||||
updatedAt: model.updatedAt.toISOString(),
|
||||
// todo: ratings implementation
|
||||
ratingTotal: await call(async () => {
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@ import { IdentityContext } from '@app/server/identity';
|
|||
import { prisma, uuid } from '@app/server/prisma/client';
|
||||
import { MutationRatingSetArgs, RatingType } from '@generated/types';
|
||||
|
||||
export async function BookCreateResolver(
|
||||
export async function RatingSetResolver(
|
||||
_: any,
|
||||
{ input }: MutationRatingSetArgs,
|
||||
{ identity }: IdentityContext
|
||||
): Promise<void> {
|
||||
ensureIdentity(identity);
|
||||
|
||||
if (input.bookId != null) {
|
||||
const rating = await prisma.rating.findUnique({
|
||||
where: {
|
||||
|
|
|
|||
26
app/server/graphql/resolvers/reviewCreateResolver.ts
Normal file
26
app/server/graphql/resolvers/reviewCreateResolver.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { ensureIdentity } from '@app/server/helpers';
|
||||
import { IdentityContext } from '@app/server/identity';
|
||||
import { prisma, uuid } from '@app/server/prisma/client';
|
||||
import { MutationReviewCreateArgs, Review } from '@generated/types';
|
||||
import { toReviewSchema } from './mappers/reviewMapper';
|
||||
|
||||
export async function ReviewCreateResolver(
|
||||
_: any,
|
||||
{ input }: MutationReviewCreateArgs,
|
||||
{ identity }: IdentityContext
|
||||
): Promise<Review> {
|
||||
ensureIdentity(identity);
|
||||
|
||||
if (input.text.length > 700) throw new Error('Review is too long.');
|
||||
|
||||
const model = await prisma.review.create({
|
||||
data: {
|
||||
id: uuid(),
|
||||
text: input.text,
|
||||
bookId: input.bookId,
|
||||
userId: identity.id
|
||||
}
|
||||
});
|
||||
|
||||
return toReviewSchema(model);
|
||||
}
|
||||
0
app/server/graphql/resolvers/reviewDeleteResolver.ts
Normal file
0
app/server/graphql/resolvers/reviewDeleteResolver.ts
Normal file
26
app/server/graphql/resolvers/reviewManyResolver.ts
Normal file
26
app/server/graphql/resolvers/reviewManyResolver.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { IdentityContext } from '@app/server/identity';
|
||||
import { prisma } from '@app/server/prisma/client';
|
||||
import { QueryReviewManyArgs, ReviewManyOutput } from '@generated/types';
|
||||
import { toReviewSchema } from './mappers/reviewMapper';
|
||||
|
||||
export async function ReviewManyResolver(
|
||||
_: any,
|
||||
{ input }: QueryReviewManyArgs,
|
||||
{ req, res, identity }: IdentityContext
|
||||
): Promise<ReviewManyOutput> {
|
||||
const { bookId, filter } = input;
|
||||
|
||||
const items = await prisma.review.findMany({
|
||||
where: { bookId },
|
||||
skip: filter.offset ?? 0,
|
||||
take: filter.size ?? 10,
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
items: await Promise.all(items.map(value => toReviewSchema(value))),
|
||||
total: await prisma.review.count({ where: { bookId } })
|
||||
};
|
||||
}
|
||||
0
app/server/graphql/resolvers/reviewUpdateResolver.ts
Normal file
0
app/server/graphql/resolvers/reviewUpdateResolver.ts
Normal file
|
|
@ -97,6 +97,13 @@ type Book {
|
|||
personalRating: RatingType!
|
||||
}
|
||||
|
||||
input BookUpdate {
|
||||
id: ID!
|
||||
title: String
|
||||
description: String
|
||||
progressStatus: WorkingProgressStatus
|
||||
}
|
||||
|
||||
type BookChapter {
|
||||
id: ID!
|
||||
title: String!
|
||||
|
|
@ -144,6 +151,8 @@ type Review {
|
|||
author: User!
|
||||
text: String!
|
||||
ratingTotal: Int!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
}
|
||||
|
||||
input ReviewCreateInput {
|
||||
|
|
|
|||
|
|
@ -9,5 +9,8 @@ export default defineConfig({
|
|||
build: {
|
||||
sourcemap: true
|
||||
},
|
||||
plugins: [TanStackRouterVite(), viteReact(), tsConfigPaths()]
|
||||
plugins: [TanStackRouterVite(), viteReact(), tsConfigPaths()],
|
||||
server: {
|
||||
allowedHosts: ['n0q.dev']
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue