Ajout des fichiers de l'application

This commit is contained in:
Rampeur
2025-08-07 08:12:11 +02:00
parent 16fc6b69a6
commit 8da4b7cb28
193 changed files with 372196 additions and 0 deletions
@@ -0,0 +1,9 @@
.actions {
& * + * {
margin-left: 10px;
}
}
.action-btn + .action-btn {
margin-left: 5px;
}
@@ -0,0 +1,79 @@
import { Button } from "@/modules/common/components/button";
import { DeleteArticleButton } from "@/modules/features/article/components/deleteArticleButton";
import { FavoriteButton } from "@/modules/features/article/components/favoriteButton";
import { Tag } from "@/modules/features/article/components/tag";
import { fetchArticle } from "@/modules/features/article/fetch/fetchArticle";
import { convertMarkdownToHtml } from "@/modules/features/article/functions";
import { FollowButton } from "@/modules/features/profile/components/followButton";
import { Article, User } from "@/utils/types/models";
import Link from "next/link";
import { ReactNode } from "react";
import styles from "./articleArea.module.css";
import { showDeleteArticleButton, showEditArticleButton, showFollowButton } from "./functions";
import { getSession } from "@/utils/auth/session";
import { fetchCurrentUser } from "@/modules/features/auth/fetch/fetchCurrentUser";
const Actions = ({ article, currentUser }: { article: Article; currentUser?: User }) => {
const profile = article.author;
return (
<div className="article-meta">
<Link href={`/profile/${profile.username}`}>{profile.image && <img src={profile.image} alt="" />}</Link>
<div className="info">
<Link href={`/profile/${profile.username}`} className="author">
{profile.username}
</Link>
<span className="date">{article.createdAt.toDateString()}</span>
</div>
{showFollowButton(profile.username, currentUser) && (
<FollowButton {...profile} className={styles["action-btn"]} />
)}
<FavoriteButton {...article} showMessage={true} className={styles["action-btn"]} />
{showEditArticleButton(profile.username, currentUser) && (
<Button component="a" href={`/editor/${article.slug}`} color="secondary" className={styles["action-btn"]}>
<i className="ion-edit"></i> Edit Article
</Button>
)}
{showDeleteArticleButton(profile.username, currentUser) && (
<DeleteArticleButton slug={article.slug} className={styles["action-btn"]} />
)}
</div>
);
};
export const ArticleArea = async ({ slug, children }: { slug: string; children: ReactNode }) => {
const article = await fetchArticle(slug);
const body = await convertMarkdownToHtml(article.body);
const currentUser = (await getSession()) ? await fetchCurrentUser() : undefined;
return (
<div className="article-page">
<div className="banner">
<div className="container">
<h1>{article.title}</h1>
<Actions article={article} currentUser={currentUser} />
</div>
</div>
<div className="container page">
<div className="row article-content">
<div className="col-md-12">
<div dangerouslySetInnerHTML={{ __html: body }} />
<ul className="tag-list">
{article.tagList.map((tag, index) => (
<Tag component="li" variant="outline" key={index}>
{tag}
</Tag>
))}
</ul>
</div>
</div>
<hr />
<div className="article-actions">
<Actions article={article} currentUser={currentUser} />
</div>
{children}
</div>
</div>
);
};
@@ -0,0 +1,13 @@
import { User } from "@/utils/types/models";
export const showFollowButton = (authorUsername: string, currentUser: User | undefined) => {
return authorUsername !== currentUser?.username;
};
export const showEditArticleButton = (authorUsername: string, currentUser: User | undefined) => {
return authorUsername === currentUser?.username;
};
export const showDeleteArticleButton = (authorUsername: string, currentUser: User | undefined) => {
return authorUsername === currentUser?.username;
};
@@ -0,0 +1 @@
export * from "./articleArea";
@@ -0,0 +1,30 @@
"use server";
import { createApiClient } from "@/utils/api/apiClient";
import { getSession } from "@/utils/auth/session";
import { revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
import { Inputs } from "./types";
export const deleteCommentAction = async (_prevState: undefined, inputs: Inputs): Promise<undefined> => {
if ((await getSession()) == null) {
redirect("/login");
}
const apiClient = createApiClient({
path: "/articles/{slug}/comments/{id}",
method: "delete",
params: {
path: inputs,
},
});
const response = await apiClient.sendRequest();
if (response.result === "success") {
revalidateTag("");
return undefined;
}
throw new Error("api error");
};
@@ -0,0 +1,36 @@
"use client";
import { Comment, User } from "@/utils/types/models";
import { use, useActionState } from "react";
import { deleteCommentAction as serverAction } from "./action";
import { CommentCard as CommentCardPresentation } from "./presentation";
import { showDeleteCommentButton } from "./functions";
type Props = {
slug: string;
comment: Comment;
currentUserPromise?: Promise<User>;
};
export const CommentCard = ({ slug, comment, currentUserPromise }: Props) => {
const [_state, dispatch, isPending] = useActionState(serverAction, undefined);
const action = () => {
if (!confirm("Delete comment?")) {
return;
}
dispatch({ slug, id: comment.id });
};
return (
<CommentCardPresentation
comment={comment}
showDeleteCommentButton={showDeleteCommentButton(
comment.author.username,
currentUserPromise && use(currentUserPromise),
)}
deleteCommentAction={action}
isPending={isPending}
/>
);
};
@@ -0,0 +1,5 @@
import { User } from "@/utils/types/models";
export const showDeleteCommentButton = (commentAuthorUsername: string, currentUser?: User) => {
return commentAuthorUsername === currentUser?.username;
};
@@ -0,0 +1 @@
export * from "./container";
@@ -0,0 +1,13 @@
.form {
display: inline;
& button {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
}
}
@@ -0,0 +1,37 @@
import { Comment } from "@/utils/types/models";
import Link from "next/link";
import styles from "./presentation.module.css";
type Props = {
comment: Comment;
showDeleteCommentButton?: boolean;
deleteCommentAction?: () => void;
isPending?: boolean;
};
export const CommentCard = ({ comment, showDeleteCommentButton, deleteCommentAction }: Props) => {
return (
<div className="card">
<div className="card-block">
<p className="card-text">{comment.body}</p>
</div>
<div className="card-footer">
<Link href={`/profile/${comment.author.username}`} className="comment-author">
{comment.author.image && <img src={comment.author.image} className="comment-author-img" alt="" />}
</Link>
&nbsp;
<Link href={`/profile/${comment.author.username}`} className="comment-author">
{comment.author.username}
</Link>
<span className="date-posted">{comment.createdAt.toDateString()}</span>
{showDeleteCommentButton && (
<form action={deleteCommentAction} className={styles["form"]}>
<button className="mod-options" type="submit">
<i className="ion-trash-a" />
</button>
</form>
)}
</div>
</div>
);
};
@@ -0,0 +1,4 @@
export type Inputs = {
slug: string;
id: string;
};
@@ -0,0 +1,57 @@
"use server";
import { createApiClient } from "@/utils/api/apiClient";
import { getSession } from "@/utils/auth/session";
import { SubmissionResult } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
import { inputsSchema } from "./types";
export const postCommentAction = async (
_prevState: unknown,
formData: FormData,
): Promise<SubmissionResult<string[]>> => {
if ((await getSession()) == null) {
redirect("/login");
}
const submission = parseWithZod(formData, { schema: inputsSchema });
if (submission.status !== "success") {
return submission.reply();
}
const { slug, body } = submission.value;
const apiClient = createApiClient({
path: "/articles/{slug}/comments",
method: "post",
params: {
path: {
slug,
},
body: {
comment: {
body,
},
},
},
});
const response = await apiClient.sendRequest();
if (response.result === "success") {
revalidateTag("");
return submission.reply();
}
switch (response.statusCode) {
case 422:
return submission.reply({
formErrors: Object.values(response.error.errors).flat(),
});
default:
throw new Error("api error");
}
};
@@ -0,0 +1,26 @@
"use client";
import { User } from "@/utils/types/models";
import { use, useActionState } from "react";
import { postCommentAction } from "./action";
import { CommentForm as CommentFormPresentation } from "./presentation";
type Props = {
slug: string;
currentUserPromise?: Promise<User>;
};
export const CommentForm = ({ slug, currentUserPromise }: Props) => {
const currentUser = currentUserPromise && use(currentUserPromise);
const [state, action, isPending] = useActionState(postCommentAction, undefined);
return (
<CommentFormPresentation
slug={slug}
authorImage={currentUser?.image}
result={state}
postCommentAction={action}
isPending={isPending}
/>
);
};
@@ -0,0 +1 @@
export * from "./container";
@@ -0,0 +1,62 @@
import { Button } from "@/modules/common/components/button";
import { ErrorMessage } from "@/modules/common/components/errorMessage";
import { SubmissionResult, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { startTransition } from "react";
import { inputsSchema } from "./types";
type Props = {
slug?: string;
authorImage?: string;
result?: SubmissionResult<string[]>;
postCommentAction?: (formData: FormData) => void;
isPending?: boolean;
};
export const CommentForm = ({ slug, authorImage, result, postCommentAction, isPending }: Props) => {
const [form, fields] = useForm({
defaultValue: {
slug,
},
lastResult: result,
onValidate({ formData }) {
return parseWithZod(formData, { schema: inputsSchema });
},
// onSubmit is defined to avoid resetting form after successful submission
// see https://github.com/edmundhung/conform/discussions/606
onSubmit(event, { formData }) {
event.preventDefault();
startTransition(() => {
postCommentAction?.(formData);
});
},
shouldRevalidate: "onBlur",
});
const errors = Object.values(form.allErrors).flat();
return (
<>
<form id={form.id} action={postCommentAction} onSubmit={form.onSubmit} className="card comment-form">
<input type="hidden" key={fields.slug.key} name={fields.slug.name} defaultValue={fields.slug.initialValue} />
<div className="card-block">
<textarea
key={fields.body.key}
name={fields.body.name}
defaultValue={fields.body.initialValue}
placeholder="Write a comment..."
rows={3}
className="form-control"
></textarea>
<ErrorMessage messages={errors} />
</div>
<div className="card-footer">
{authorImage && <img src={authorImage} alt="" className="comment-author-img" />}
<Button component="button" type="submit" disabled={isPending}>
Post Comment
</Button>
</div>
</form>
</>
);
};
@@ -0,0 +1,8 @@
import { z } from "zod";
export const inputsSchema = z.object({
slug: z.string().max(200),
body: z.string().max(200),
});
export type Inputs = z.infer<typeof inputsSchema>;
@@ -0,0 +1,21 @@
import { fetchComments } from "@/modules/features/article/fetch/fetchComments";
import { fetchCurrentUser } from "@/modules/features/auth/fetch/fetchCurrentUser";
import { getSession } from "@/utils/auth/session";
import { CommentCard } from "../commentCard";
type Props = {
slug: string;
};
export const CommentList = async ({ slug }: Props) => {
const comments = await fetchComments(slug);
const currentUserPromise = (await getSession()) ? fetchCurrentUser() : undefined;
return (
<>
{comments.map((comment, index) => (
<CommentCard key={index} slug={slug} comment={comment} currentUserPromise={currentUserPromise} />
))}
</>
);
};
@@ -0,0 +1 @@
export * from "./commentList";
+30
View File
@@ -0,0 +1,30 @@
import { fetchCurrentUser } from "@/modules/features/auth/fetch/fetchCurrentUser";
import { getSession } from "@/utils/auth/session";
import { Suspense } from "react";
import { ArticleArea } from "./_components/articleArea";
import { CommentForm } from "./_components/commentForm";
import { CommentList } from "./_components/commentList";
type Params = Promise<{
slug: string;
}>;
const Page = async (props: { params: Params }) => {
const currentUserPromise = (await getSession()) ? fetchCurrentUser() : undefined;
const params = await props.params;
return (
<ArticleArea slug={params.slug}>
<div className="row">
<div className="col-xs-12 col-md-8 offset-md-2">
<CommentForm slug={params.slug} currentUserPromise={currentUserPromise} />
<Suspense fallback={<p>Loading comments...</p>}>
<CommentList slug={params.slug} />
</Suspense>
</div>
</div>
</ArticleArea>
);
};
export default Page;