diff --git a/apps/demo/app/article/[id]/page.tsx b/apps/demo/app/article/[id]/page.tsx index 0f3b42878..b1d87b3f3 100644 --- a/apps/demo/app/article/[id]/page.tsx +++ b/apps/demo/app/article/[id]/page.tsx @@ -1,21 +1,22 @@ +'use client'; + import CommentList from '../components/comment-list'; import Link from 'next/link'; import ArticleActions from '../components/article-actions'; import { marked } from 'marked'; +import { useArticle } from '../hooks/article.hook'; +import { dateFormatter } from '../../utils/date.utils'; -export default async function Page({ params }: { params: { id: string } }) { - const article = await fetch(`https://api.realworld.io/api/articles/${params.id}`) - .then(res => res.json()) - .then(res => res.article); +export default function Page({ params }: { params: { id: string } }) { + const { article } = useArticle(params.id); if (!article) { return
Loading...
; } if (article) { - console.log(article.body); const markup = { - __html: marked(article.body.replace('\\n', '\n'), { + __html: marked(article.body, { sanitize: true, breaks: true, gfm: true, @@ -36,7 +37,7 @@ export default async function Page({ params }: { params: { id: string } }) { {article.author.username} - {new Date(article.createdAt).toDateString()} + {dateFormatter(article.createdAt)} @@ -59,7 +60,7 @@ export default async function Page({ params }: { params: { id: string } }) {
- + ); diff --git a/apps/demo/app/article/components/comment-list.tsx b/apps/demo/app/article/components/comment-list.tsx index f9d97f81e..27c88f28d 100644 --- a/apps/demo/app/article/components/comment-list.tsx +++ b/apps/demo/app/article/components/comment-list.tsx @@ -1,11 +1,33 @@ 'use client'; -import { useContext } from 'react'; +import React, { useContext } from 'react'; import { AuthContext } from '../../auth/auth.context'; import Link from 'next/link'; +import { useComments } from '../hooks/comments.hook'; +import { Comment } from '../../models/comment.model'; +import { createComment, deleteComment } from '../services/comment.service'; +import { dateFormatter } from '../../utils/date.utils'; -export default function CommentList() { +interface CommentListProps { + slug: string; +} + +export default function CommentList({ slug }: CommentListProps) { const { user } = useContext(AuthContext); + const { comments, mutate } = useComments(slug); + + async function create(event: React.FormEvent): Promise { + event.preventDefault(); + + const comment = (event.target as HTMLFormElement).comment.value; + const createdComment = await createComment(slug, comment); + mutate([...comments, createdComment], { populateCache: false }); + } + + async function deleteC(id: number): Promise { + await deleteComment(slug, id); + mutate([...comments.filter((comment: Comment) => comment.id !== id)], { populateCache: false }); + } if (!user) { return ( @@ -25,55 +47,48 @@ export default function CommentList() { return (
-
+
- +
- - + {`${user.username} +
-
-
-

- With supporting text below as a natural lead-in to additional content. -

-
-
- - - -   - - Jacob Schmidt - - Dec 29th -
-
- -
-
-

- With supporting text below as a natural lead-in to additional content. -

-
-
- - - -   - - Jacob Schmidt - - Dec 29th - - - - -
-
+ {comments && + comments.map((comment: Comment, key: number) => ( +
+
+

{comment.body}

+
+
+ + {`${comment.author.username} + +   + + {comment.author.username} + + {dateFormatter(comment.createdAt)} + + deleteC(comment.id)}> + +
+
+ ))}
); diff --git a/apps/demo/app/article/components/delete-comment-button.tsx b/apps/demo/app/article/components/delete-comment-button.tsx new file mode 100644 index 000000000..096bf2a96 --- /dev/null +++ b/apps/demo/app/article/components/delete-comment-button.tsx @@ -0,0 +1,27 @@ +'use client'; + +interface DeleteCommentButtonProps { + slug: string; + commentId: number; + onDelete: () => void; +} +export default function DeleteCommentButton({ + slug, + commentId, + onDelete, +}: DeleteCommentButtonProps) { + async function deleteComment(): Promise { + const response = await fetch( + `https://api.realworld.io/api/articles/${slug}/comments/${commentId}`, + { + method: 'DELETE', + }, + ); + } + + return ( + + + + ); +} diff --git a/apps/demo/app/article/hooks/article.hook.ts b/apps/demo/app/article/hooks/article.hook.ts new file mode 100644 index 000000000..8f1363224 --- /dev/null +++ b/apps/demo/app/article/hooks/article.hook.ts @@ -0,0 +1,19 @@ +import useSWR from 'swr'; + +export function useArticle(slug: string) { + const token = localStorage.getItem('token') as string; + const { data, error, isLoading } = useSWR(`/api/articles/${slug}`, url => + fetch('https://api.realworld.io' + url, { + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Token ${token}` } : {}), + }, + }).then(res => res.json()), + ); + + return { + article: data?.article, + isLoading, + isError: error, + }; +} diff --git a/apps/demo/app/article/hooks/comments.hook.ts b/apps/demo/app/article/hooks/comments.hook.ts new file mode 100644 index 000000000..2c49c65a6 --- /dev/null +++ b/apps/demo/app/article/hooks/comments.hook.ts @@ -0,0 +1,19 @@ +import useSWR from 'swr'; + +export function useComments(slug: string) { + const { data, error, isLoading, mutate } = useSWR(`/api/articles/${slug}/comments`, url => + fetch('https://api.realworld.io' + url, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Token ${localStorage.getItem('token')}`, + }, + }).then(res => res.json()), + ); + console.log({ data }); + return { + comments: data?.comments, + isLoading, + isError: error, + mutate, + }; +} diff --git a/apps/demo/app/article/services/comment.service.ts b/apps/demo/app/article/services/comment.service.ts new file mode 100644 index 000000000..49c8c157c --- /dev/null +++ b/apps/demo/app/article/services/comment.service.ts @@ -0,0 +1,24 @@ +export async function createComment(slug: string, body: string): Promise { + const token = localStorage.getItem('token') as string; + return fetch(`https://api.realworld.io/api/articles/${slug}/comments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Token ${token}`, + }, + body: JSON.stringify({ comment: { body } }), + }) + .then(res => res.json()) + .then(data => data.comment); +} + +export async function deleteComment(slug: string, id: number): Promise { + const token = localStorage.getItem('token') as string; + return fetch(`https://api.realworld.io/api/articles/${slug}/comments/${id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Token ${token}`, + }, + }); +} diff --git a/apps/demo/app/auth/auth.context.tsx b/apps/demo/app/auth/auth.context.tsx index e5159cd64..ad862a312 100644 --- a/apps/demo/app/auth/auth.context.tsx +++ b/apps/demo/app/auth/auth.context.tsx @@ -5,6 +5,7 @@ import { PropsWithChildren } from 'react'; import { AuthContextType } from './auth.context.model'; import { User } from '../models/user.model'; import { getCurrentUser } from '../services/auth.service'; +import { mutate } from 'swr'; export const AuthContext = createContext(null as unknown as AuthContextType); @@ -14,12 +15,14 @@ const AuthProvider = ({ children }: PropsWithChildren) => { useEffect(() => { const token = localStorage.getItem('token'); - console.log(token); - const setCurrentUser = async () => { const response = await getCurrentUser(); const data = await response.json(); setUser(data.user); + + mutate(key => typeof key === 'string' && key.startsWith('/api/'), undefined, { + revalidate: true, + }); }; if (token) { diff --git a/apps/demo/app/components/article-list.tsx b/apps/demo/app/components/article-list.tsx index 31b9beb78..2bbd10cb6 100644 --- a/apps/demo/app/components/article-list.tsx +++ b/apps/demo/app/components/article-list.tsx @@ -5,11 +5,15 @@ import ArticlePreview from './article-preview'; import { useArticles } from '../services/article.service'; import { Pagination } from './pagination'; -export default function ArticleList() { - const { articles, articlesCount, isLoading, isError } = useArticles(); +interface ArticleListProps { + limit?: number; +} + +export default function ArticleList({ limit = 10 }: ArticleListProps = {}) { + const { articles, articlesCount, isLoading, isError } = useArticles({ limit }); return ( <> - {isLoading &&
Loading...
} + {isLoading &&
Loading articles...
} {isError &&
Error
} {articles && articles.map((article: Article, index: number) => ( diff --git a/apps/demo/app/components/article-preview.tsx b/apps/demo/app/components/article-preview.tsx index 6716fa8b0..c15af517a 100644 --- a/apps/demo/app/components/article-preview.tsx +++ b/apps/demo/app/components/article-preview.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import FavoriteButton from './favorite-button'; import { Article } from '../models/article.model'; +import { dateFormatter } from '../utils/date.utils'; interface ArticlePreviewProps { article: Article; @@ -17,7 +18,7 @@ export default function ArticlePreview({ article }: ArticlePreviewProps) { {article.author.username} - {new Date(article.createdAt).toDateString()} + {dateFormatter(article.createdAt)} diff --git a/apps/demo/app/components/favorite-button.tsx b/apps/demo/app/components/favorite-button.tsx index 884eae022..bf6204afc 100644 --- a/apps/demo/app/components/favorite-button.tsx +++ b/apps/demo/app/components/favorite-button.tsx @@ -4,6 +4,10 @@ import { AuthContext } from '../auth/auth.context'; import { useRouter } from 'next/navigation'; import { favoriteArticle, unfavoriteArticle } from '../services/article.service'; import { Article } from '../models/article.model'; +import { mutate } from 'swr'; + +const FAVORITED_CLASS = 'btn btn-sm btn-primary'; +const NOT_FAVORITED_CLASS = 'btn btn-sm btn-outline-primary'; interface FavoriteButtonProps { article: Article; @@ -11,6 +15,7 @@ interface FavoriteButtonProps { } export default function FavoriteButton({ article, isExtended }: FavoriteButtonProps) { + console.log('article', article.favorited); const { user } = useContext(AuthContext); const router = useRouter(); const [count, setCount] = useState(article.favoritesCount); @@ -28,13 +33,21 @@ export default function FavoriteButton({ article, isExtended }: FavoriteButtonPr if (article.favorited) { setCount(count - 1); setIsFavorited(false); - await favoriteArticle(article.slug); - // TODO: update article + mutate(`/api/articles/${article.slug}`, { + ...article, + favorited: false, + favoritesCount: count - 1, + }); + await unfavoriteArticle(article.slug); } else { setCount(count + 1); setIsFavorited(true); - await unfavoriteArticle(article.slug); - // TODO: update article + mutate(`/api/articles/${article.slug}`, { + ...article, + favorited: true, + favoritesCount: count + 1, + }); + await favoriteArticle(article.slug); } setIsLoading(false); @@ -42,14 +55,22 @@ export default function FavoriteButton({ article, isExtended }: FavoriteButtonPr if (isExtended) { return ( - ); } else { return ( - diff --git a/apps/demo/app/components/follow-button.tsx b/apps/demo/app/components/follow-button.tsx index 1f51dd636..149270805 100644 --- a/apps/demo/app/components/follow-button.tsx +++ b/apps/demo/app/components/follow-button.tsx @@ -5,6 +5,9 @@ import { followUser, unfollowUser } from '../services/profile.service'; import { useRouter } from 'next/navigation'; import { AuthContext } from '../auth/auth.context'; +const FOLLOWING_CLASS = 'btn btn-sm action-btn btn-secondary'; +const NOT_FOLLOWING_CLASS = 'btn btn-sm action-btn btn-outline-secondary'; + interface FollowButtonProps { username: string; following: boolean; @@ -31,7 +34,7 @@ export default function FollowButton({ username, following }: FollowButtonProps) } return ( - - -

How to build webapps that scale

-

This is the description for the post.

- Read more... -
- - - - - - - - ); +export default async function Page() { + return ; } diff --git a/apps/demo/app/services/article.service.ts b/apps/demo/app/services/article.service.ts index d06a7e4f9..9d96b6e70 100644 --- a/apps/demo/app/services/article.service.ts +++ b/apps/demo/app/services/article.service.ts @@ -1,16 +1,19 @@ 'use client'; import { SearchParams } from '../models/search-params.model'; +import { Article } from '../models/article.model'; import useSWR from 'swr'; -export async function getArticles( - { offset, author, tag, favorited }: SearchParams = { - offset: 0, - }, -): Promise { +export async function getArticles({ + offset, + author, + tag, + favorited, + limit, +}: SearchParams): Promise<{ data: Article[] }> { const params = new URLSearchParams({ - limit: '10', - offset: offset.toString(), + limit: (limit || '10').toString(), + offset: (offset || '0').toString(), ...(author ? { author } : {}), ...(tag ? { tag } : {}), ...(favorited ? { favorited } : {}), @@ -19,21 +22,23 @@ export async function getArticles( return await res.json(); } -export function useArticles( - { offset, author, tag, favorited }: SearchParams = { - offset: 0, - }, -) { +export function useArticles({ offset, author, tag, favorited, limit }: SearchParams) { + const token = localStorage.getItem('token') as string; const params = new URLSearchParams({ - limit: '10', - offset: offset.toString(), + limit: (limit || '10').toString(), + offset: (offset || '0').toString(), ...(author ? { author } : {}), ...(tag ? { tag } : {}), ...(favorited ? { favorited } : {}), }); const { data, error, isLoading } = useSWR('/api/articles' + '?' + params, url => - fetch('https://api.realworld.io' + url).then(res => res.json()), + fetch('https://api.realworld.io' + url, { + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Token ${encodeURIComponent(token)}` } : {}), + }, + }).then(res => res.json()), ); return { @@ -58,7 +63,7 @@ export function useTags() { }; } -export async function favoriteArticle(slug: string): Promise { +export async function favoriteArticle(slug: string): Promise
{ const token = localStorage.getItem('token') as string; const res = await fetch(`https://api.realworld.io/api/articles/${slug}/favorite`, { method: 'POST', @@ -71,7 +76,7 @@ export async function favoriteArticle(slug: string): Promise { return data.article; } -export async function unfavoriteArticle(slug: string): Promise { +export async function unfavoriteArticle(slug: string): Promise
{ const token = localStorage.getItem('token') as string; const res = await fetch(`https://api.realworld.io/api/articles/${slug}/favorite`, { method: 'DELETE',