Skip to content

Commit

Permalink
Merge pull request #48 from cp-20/feat/article-actions
Browse files Browse the repository at this point in the history
記事の削除などをできるボタンを追加
  • Loading branch information
cp-20 authored Feb 16, 2024
2 parents c9089a4 + 7119966 commit ef25b7c
Show file tree
Hide file tree
Showing 14 changed files with 717 additions and 524 deletions.
28 changes: 28 additions & 0 deletions apps/server/src/handlers/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from '@read-stack/database';
import { fetchArticle, parseIntWithDefaultValue } from '@read-stack/lib';
import {
archiveMyInboxItemRoute,
deleteMyApiKeyRoute,
deleteMyClipRoute,
deleteMyInboxItemRoute,
Expand Down Expand Up @@ -478,4 +479,31 @@ export const registerUsersHandlers = (app: WithSupabaseClient) => {

return c.json({ item }, 200);
});

app.openapi(archiveMyInboxItemRoute, async (c) => {
const user = await getUser(c);
if (user === null) return c.json({}, 401);

const itemIdStr = c.req.param('itemId');
const itemId = parseIntWithDefaultValue(itemIdStr, null);

if (itemId === null) {
return c.json({ error: 'itemId is not configured and valid' }, 400);
}

const item = await findInboxItemById(itemId);
if (item === undefined || item.userId !== user.id) {
return c.json({ error: 'item not found' }, 404);
}

await deleteInboxItemByIdAndUserId(itemId, user.id);
const { clip } = await saveClip(item.articleId, user.id, () => ({
articleId: item.articleId,
userId: user.id,
progress: 100,
status: 2,
}));

return c.json({ clip }, 200);
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ export const AddNewClipForm: FC = () => {
if (prev === undefined) return [];

const newResult: FetchArticleResult<UnreadClipAdditionalProps> = {
articles: [clip.article],
clips: [clip],
articles: [{ ...clip.article, clip }],
finished: false,
};

Expand Down
16 changes: 8 additions & 8 deletions apps/web/src/client/Home/_components/Article/AnimatedList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AnimatedListItemProps } from '@/client/Home/_components/Article/AnimatedListItem';
import { AnimatedListItem } from '@/client/Home/_components/Article/AnimatedListItem';
import type { Article } from '@read-stack/openapi';
import { type FC, useState, useEffect, Fragment } from 'react';
import { useState, useEffect, Fragment } from 'react';

// FIXME: 入れ替えとかで壊れるかも
// 和集合を順序をなるべく考慮して取る
Expand Down Expand Up @@ -51,21 +51,21 @@ const mergeArray = <T extends { id: unknown }>(prev: T[], next: T[]) => {
return result;
};

interface AnimatedListProps {
articles: Article[];
interface AnimatedListProps<T> {
articles: (Article & T)[];
duration: number;
itemProps?: Omit<AnimatedListItemProps, 'article' | 'isRemoved'>;
itemProps?: Omit<AnimatedListItemProps<T>, 'article' | 'isRemoved'>;
}

const isSubset = <T,>(a: T[], b: T[]) => a.every((v) => b.includes(v));

export const AnimatedList: FC<AnimatedListProps> = ({
export const AnimatedList = <T,>({
articles,
duration,
itemProps,
}) => {
}: AnimatedListProps<T>) => {
const [displayedArticles, setDisplayedArticles] =
useState<Article[]>(articles);
useState<(Article & T)[]>(articles);
const [removingArticleIds, setRemovingArticleIds] = useState<number[]>([]);

const articleIds = articles.map((a) => a.id);
Expand Down Expand Up @@ -110,7 +110,7 @@ export const AnimatedList: FC<AnimatedListProps> = ({
return (
<>
{displayedArticles.map((a) => (
<AnimatedListItem
<AnimatedListItem<T>
article={a}
isRemoved={removingArticleIds.includes(a.id)}
key={a.id}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import type { ArticleListItemProps } from '@/client/Home/_components/Article/ArticleListItem';
import { ArticleListItem } from '@/client/Home/_components/Article/ArticleListItem';
import { css } from '@emotion/react';
import { memo, type FC } from 'react';
import { memo } from 'react';

export type AnimatedListItemProps = {
export type AnimatedListItemProps<T> = {
isRemoved: boolean;
} & ArticleListItemProps;
} & ArticleListItemProps<T>;

const UnmemorizedAnimatedListItem: FC<AnimatedListItemProps> = ({
const UnmemorizedAnimatedListItem = <T,>({
isRemoved,
...props
}) => {
}: AnimatedListItemProps<T>) => {
return (
<ArticleListItem
css={css`
min-height: 0;
max-height: ${isRemoved ? '0' : '300px'};
animation: ${isRemoved
? 'disappear 0.2s forwards'
: 'appear 0.2s forwards'};
animation: ${
isRemoved ? 'disappear 0.2s forwards' : 'appear 0.2s forwards'
};
opacity: ${isRemoved ? 0 : 1};
transform: ${isRemoved ? 'scale(0.9)' : 'none'};
transition:
Expand Down Expand Up @@ -85,4 +85,4 @@ export const AnimatedListItem = memo(
prev.article.id === next.article.id,
].every(Boolean);
},
);
) as typeof UnmemorizedAnimatedListItem;
24 changes: 8 additions & 16 deletions apps/web/src/client/Home/_components/Article/ArticleList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AnimatedList } from '@/client/Home/_components/Article/AnimatedList';
import type { ArticleListItemProps } from '@/client/Home/_components/Article/ArticleListItem';
import type {
AdditionalProps,
MutatorKey,
Expand All @@ -13,24 +14,20 @@ import { useRef, type ReactNode, useEffect, useCallback } from 'react';
import type { KeyedMutator } from 'swr';
import useSWRInfinite from 'swr/infinite';

export type FetchArticleResult<T> = {
articles: Article[];
export interface FetchArticleResult<T> {
articles: (Article & T)[];
finished: boolean;
} & T;
}

export interface ArticleListProps<T extends MutatorKey> {
export type ArticleListProps<T extends MutatorKey> = {
stateKey: T;
keyConstructor: (
size: number,
prev?: FetchArticleResult<AdditionalProps[T]>,
) => string | null;
fetcher: (url: string) => Promise<FetchArticleResult<AdditionalProps[T]>>;
renderActions?: (
article: Article,
results: FetchArticleResult<AdditionalProps[T]>[],
) => ReactNode;
noContentComponent: ReactNode;
}
} & Omit<ArticleListItemProps<AdditionalProps[T]>, 'article'>;

const useLoaderIntersection = (loadNext: () => void, isLoading: boolean) => {
const containerRef = useRef<HTMLDivElement>(null);
Expand All @@ -54,8 +51,8 @@ export const ArticleList = <T extends MutatorKey>({
stateKey,
keyConstructor,
fetcher,
renderActions,
noContentComponent,
...itemProps
}: ArticleListProps<T>): ReactNode => {
const { setMutator, removeMutator } = useSetMutator();
const { data, setSize, isLoading, mutate } = useSWRInfinite(
Expand Down Expand Up @@ -110,12 +107,7 @@ export const ArticleList = <T extends MutatorKey>({
<AnimatedList
articles={articles}
duration={2000}
itemProps={{
renderActions: (a) => {
if (!data) return;
return renderActions?.(a, data);
},
}}
itemProps={itemProps}
/>
</div>

Expand Down
150 changes: 94 additions & 56 deletions apps/web/src/client/Home/_components/Article/ArticleListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,93 +1,131 @@
import { css } from '@emotion/react';
import { Text, useMantineTheme } from '@mantine/core';
import { ActionIcon, Text, useMantineTheme } from '@mantine/core';
import type { Article } from '@read-stack/openapi';
import type { ComponentProps, FC, ReactNode } from 'react';
import { IconArchive, IconTrash } from '@tabler/icons-react';
import type { ComponentProps, ReactNode } from 'react';

export interface ArticleListItemProps {
article: Article;
renderActions?: (article: Article) => ReactNode;
export interface ArticleListItemProps<T> {
article: Article & T;
renderActions?: (article: Article & T) => ReactNode;
onDelete?: (article: Article & T) => void;
onArchive?: (article: Article & T) => void;
}

export const ArticleListItem: FC<ArticleListItemProps & ComponentProps<'div'>> =
({ article, renderActions, ...props }) => {
const theme = useMantineTheme();
return (
<div {...props}>
<div
css={css`
export const ArticleListItem = <T,>({
article,
renderActions,
onDelete,
onArchive,
...props
}: ArticleListItemProps<T> & ComponentProps<'div'>) => {
const theme = useMantineTheme();
return (
<div {...props}>
<div
css={css`
padding: 1rem;
border: 1px solid ${theme.colors.gray[2]};
border-radius: ${theme.radius.md};
`}
>
<div
css={css`
>
<div
css={css`
display: grid;
gap: 1rem;
grid-template-columns: 1fr max(30%, 100px);
`}
>
<div
css={css`
>
<div
css={css`
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.5rem;
`}
>
<Text fw="bold" lineClamp={2}>
<a
css={css`
>
<Text fw="bold" lineClamp={2}>
<a
css={css`
color: ${theme.colors[theme.primaryColor][7]};
text-decoration: none;
&:hover {
text-decoration: underline;
}
`}
href={article.url}
rel="noopener noreferrer"
target="_blank"
>
{article.title}
</a>
</Text>
<Text color="dimmed" fz="sm" lineClamp={3}>
{article.body}
</Text>
<Text
color="dimmed"
css={css`
href={article.url}
rel="noopener noreferrer"
target="_blank"
>
{article.title}
</a>
</Text>
<Text color="dimmed" fz="sm" lineClamp={3}>
{article.body}
</Text>
<Text
color="dimmed"
css={css`
display: flex;
gap: 0.5rem;
`}
fz="sm"
>
<span>{new URL(article.url).host}</span>
</Text>
fz="sm"
>
<span>{new URL(article.url).host}</span>
</Text>
</div>
<div>
<div
css={css`
display: flex;
justify-content: flex-end;
border-radius: ${theme.radius.md};
margin-bottom: 0.5rem;
background-color: ${theme.white};
color: ${theme.white};
gap: 0.1rem;
`}
>
{onArchive ? (
<ActionIcon
onClick={() => {
onArchive(article);
}}
>
<IconArchive />
</ActionIcon>
) : null}
{onDelete ? (
<ActionIcon
onClick={() => {
onDelete(article);
}}
>
<IconTrash />
</ActionIcon>
) : null}
</div>
<div>
{article.ogImageUrl ? (
<img
alt=""
css={css`
{article.ogImageUrl ? (
<img
alt=""
css={css`
max-height: 120px;
object-fit: cover;
`}
src={article.ogImageUrl}
width="100%"
/>
) : null}
</div>
src={article.ogImageUrl}
width="100%"
/>
) : null}
</div>
<div
css={css`
</div>
<div
css={css`
margin-top: 1rem;
`}
>
{renderActions ? renderActions(article) : null}
</div>
>
{renderActions ? renderActions(article) : null}
</div>
</div>
);
};
</div>
);
};
Loading

0 comments on commit ef25b7c

Please sign in to comment.