Post Create/Edit
This commit is contained in:
parent
74fd55084f
commit
08731f8559
@ -29,6 +29,8 @@ class Posts extends Api
|
|||||||
// Fetch all required inputs.
|
// Fetch all required inputs.
|
||||||
// Throw 400 error if a required one is missing.
|
// Throw 400 error if a required one is missing.
|
||||||
$content = Input::post("content");
|
$content = Input::post("content");
|
||||||
|
// This one is optional
|
||||||
|
$limit = constrain(0, 30, intval(Input::post("l", 10)));
|
||||||
if (empty($content)) throw ApiError::missingField(["content"]);
|
if (empty($content)) throw ApiError::missingField(["content"]);
|
||||||
|
|
||||||
// Get logged in user
|
// Get logged in user
|
||||||
@ -36,7 +38,7 @@ class Posts extends Api
|
|||||||
|
|
||||||
// Try to create a new post for logged in user.
|
// Try to create a new post for logged in user.
|
||||||
try {
|
try {
|
||||||
Response::json(Post::create($self, $content));
|
Response::json(Post::create($self, $content, $limit));
|
||||||
} catch (Exception $err) {
|
} catch (Exception $err) {
|
||||||
switch ($err->getMessage()) {
|
switch ($err->getMessage()) {
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -690,6 +690,13 @@ components:
|
|||||||
type: number
|
type: number
|
||||||
timezone:
|
timezone:
|
||||||
type: string
|
type: string
|
||||||
|
PostCreateResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
pages:
|
||||||
|
type: number
|
||||||
|
post:
|
||||||
|
$ref: "#/components/schemas/PostResponse"
|
||||||
PostListResponse:
|
PostListResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@ -711,6 +718,8 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
content:
|
content:
|
||||||
type: string
|
type: string
|
||||||
|
limit:
|
||||||
|
type: number
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
BasicAuth:
|
BasicAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -58,7 +58,7 @@ class Post implements JsonSerializable
|
|||||||
return new Post($data["id"], $user, null, null, $data["beitrag"], $data["zeitstempel"]);
|
return new Post($data["id"], $user, null, null, $data["beitrag"], $data["zeitstempel"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function create(User $user, string $content): Post
|
public static function create(User $user, string $content, int $limit): array
|
||||||
{
|
{
|
||||||
$content = substr(trim($content), 0, 250);
|
$content = substr(trim($content), 0, 250);
|
||||||
|
|
||||||
@ -73,8 +73,21 @@ class Post implements JsonSerializable
|
|||||||
$stmt->bindValue(":CON", nl2br(htmlspecialchars($content)));
|
$stmt->bindValue(":CON", nl2br(htmlspecialchars($content)));
|
||||||
|
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
|
$lastId = $db->lastInsertId();
|
||||||
|
|
||||||
return Post::getByID($db->lastInsertId());
|
$stmt = $db->prepare(
|
||||||
|
"SELECT
|
||||||
|
COUNT(*)
|
||||||
|
FROM
|
||||||
|
egb_gaestebuch"
|
||||||
|
);
|
||||||
|
$stmt->execute();
|
||||||
|
$count = $stmt->fetch(PDO::FETCH_COLUMN, 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
"pages" => intdiv($count, $limit + 1) + 1,
|
||||||
|
"post" => Post::getByID($lastId)
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function list(int $page, int $limit, bool $authed = false): array
|
public static function list(int $page, int $limit, bool $authed = false): array
|
||||||
|
|||||||
1
exam/dist/assets/index-BhgwoArk.js
vendored
1
exam/dist/assets/index-BhgwoArk.js
vendored
File diff suppressed because one or more lines are too long
1
exam/dist/assets/index-CG0WySTu.js
vendored
Normal file
1
exam/dist/assets/index-CG0WySTu.js
vendored
Normal file
File diff suppressed because one or more lines are too long
125
exam/dist/assets/mui-CsmJ6if2.js
vendored
125
exam/dist/assets/mui-CsmJ6if2.js
vendored
File diff suppressed because one or more lines are too long
178
exam/dist/assets/mui-v3E5hT34.js
vendored
Normal file
178
exam/dist/assets/mui-v3E5hT34.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
6
exam/dist/index.html
vendored
6
exam/dist/index.html
vendored
@ -5,10 +5,10 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/phpCourse/exam/dist/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/phpCourse/exam/dist/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>Vite + React + TS</title>
|
||||||
<script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-BhgwoArk.js"></script>
|
<script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-CG0WySTu.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/react-DXd9vB-a.js">
|
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/react-DXd9vB-a.js">
|
||||||
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/mui-CsmJ6if2.js">
|
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/mui-v3E5hT34.js">
|
||||||
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/tanstack-DXVkKUs1.js">
|
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/tanstack-yMrSYl0u.js">
|
||||||
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/i18n-DJgSTqOl.js">
|
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/i18n-DJgSTqOl.js">
|
||||||
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/mui-CKDNpdid.css">
|
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/mui-CKDNpdid.css">
|
||||||
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/index-D83Ey19k.css">
|
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/index-D83Ey19k.css">
|
||||||
|
|||||||
14
exam/dist/locales/de/translation.json
vendored
14
exam/dist/locales/de/translation.json
vendored
@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"Unauthorized": "Keine Berechtigung",
|
"Unauthorized": "Keine Berechtigung",
|
||||||
|
|
||||||
"NotFound_user:login": "Benutzer existiert nicht",
|
|
||||||
"Unauthorized_login": "Ungültige E-Mail oder Passwort",
|
"Unauthorized_login": "Ungültige E-Mail oder Passwort",
|
||||||
|
"NotFound_user:login": "Benutzer existiert nicht",
|
||||||
|
"MissingField_email:login": "E-Mail darf nicht leer sein",
|
||||||
|
"MissingField_password:login": "Passwort darf nicht leer sein",
|
||||||
|
|
||||||
"Unauthorized_deletePost": "Keine Berechtigung",
|
"Unauthorized_deletePost": "Keine Berechtigung",
|
||||||
"NotFound_post:deletePost": "Post nicht gefunden",
|
"NotFound_post:deletePost": "Post nicht gefunden",
|
||||||
@ -12,6 +14,12 @@
|
|||||||
"FailedUpdate_Duplicate:username:userUpdate": "Ein Benutzer mit diesem Benutzernamen existiert schon",
|
"FailedUpdate_Duplicate:username:userUpdate": "Ein Benutzer mit diesem Benutzernamen existiert schon",
|
||||||
"FailedUpdate_Duplicate:email:userUpdate": "Ein Benutzer mit dieser E-Mail existiert schon",
|
"FailedUpdate_Duplicate:email:userUpdate": "Ein Benutzer mit dieser E-Mail existiert schon",
|
||||||
|
|
||||||
|
"Unauthorized_newPost": "Keine Berechtigung",
|
||||||
|
"MissingField_content:newPost": "Beitrag darf nicht leer sein",
|
||||||
|
|
||||||
|
"Unauthorized_postUpdate": "Keine Berechtigung",
|
||||||
|
"NotFound_post:postUpdate": "Post nicht gefunden",
|
||||||
|
|
||||||
"username": "Benutzername",
|
"username": "Benutzername",
|
||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
"password": "Passwort",
|
"password": "Passwort",
|
||||||
@ -50,5 +58,7 @@
|
|||||||
"Deleting": "Löscht...",
|
"Deleting": "Löscht...",
|
||||||
"Edit data": "Daten ändern",
|
"Edit data": "Daten ändern",
|
||||||
|
|
||||||
"Recent posts": "Letzten Posts"
|
"Recent posts": "Letzte Posts",
|
||||||
|
|
||||||
|
"Post comment": "Beitrag posten"
|
||||||
}
|
}
|
||||||
|
|||||||
14
exam/dist/locales/en/translation.json
vendored
14
exam/dist/locales/en/translation.json
vendored
@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"Unauthorized": "Unauthorized",
|
"Unauthorized": "Unauthorized",
|
||||||
|
|
||||||
"NotFound_user:login": "User does not exist",
|
|
||||||
"Unauthorized_login": "Invalid email or password",
|
"Unauthorized_login": "Invalid email or password",
|
||||||
|
"NotFound_user:login": "User does not exist",
|
||||||
|
"MissingField_email:login": "E-Mail required",
|
||||||
|
"MissingField_password:login": "Password required",
|
||||||
|
|
||||||
"Unauthorized_deletPost": "Unauthorized",
|
"Unauthorized_deletPost": "Unauthorized",
|
||||||
"NotFound_post:deletePost": "Post not found",
|
"NotFound_post:deletePost": "Post not found",
|
||||||
@ -12,6 +14,12 @@
|
|||||||
"FailedUpdate_Duplicate:userUpdate": "A user with this username already exists",
|
"FailedUpdate_Duplicate:userUpdate": "A user with this username already exists",
|
||||||
"FailedUpdate_Duplicate:email:userUpdate": "A user with this email already exists",
|
"FailedUpdate_Duplicate:email:userUpdate": "A user with this email already exists",
|
||||||
|
|
||||||
|
"Unauthorized_newPost": "Unauthorized",
|
||||||
|
"MissingField_content:newPost": "Content required",
|
||||||
|
|
||||||
|
"Unauthorized_postUpdate": "Unauthorized",
|
||||||
|
"NotFound_post:postUpdate": "Post not found",
|
||||||
|
|
||||||
"username": "username",
|
"username": "username",
|
||||||
"email": "email",
|
"email": "email",
|
||||||
"password": "password",
|
"password": "password",
|
||||||
@ -51,5 +59,7 @@
|
|||||||
|
|
||||||
"Edit data": "Edit date",
|
"Edit data": "Edit date",
|
||||||
|
|
||||||
"Recent posts": "Recent posts"
|
"Recent posts": "Last posts",
|
||||||
|
|
||||||
|
"Post omment": "Post comment"
|
||||||
}
|
}
|
||||||
|
|||||||
2
exam/dist/stats.html
vendored
2
exam/dist/stats.html
vendored
File diff suppressed because one or more lines are too long
@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"Unauthorized": "Keine Berechtigung",
|
"Unauthorized": "Keine Berechtigung",
|
||||||
|
|
||||||
"NotFound_user:login": "Benutzer existiert nicht",
|
|
||||||
"Unauthorized_login": "Ungültige E-Mail oder Passwort",
|
"Unauthorized_login": "Ungültige E-Mail oder Passwort",
|
||||||
|
"NotFound_user:login": "Benutzer existiert nicht",
|
||||||
|
"MissingField_email:login": "E-Mail darf nicht leer sein",
|
||||||
|
"MissingField_password:login": "Passwort darf nicht leer sein",
|
||||||
|
|
||||||
"Unauthorized_deletePost": "Keine Berechtigung",
|
"Unauthorized_deletePost": "Keine Berechtigung",
|
||||||
"NotFound_post:deletePost": "Post nicht gefunden",
|
"NotFound_post:deletePost": "Post nicht gefunden",
|
||||||
@ -12,6 +14,12 @@
|
|||||||
"FailedUpdate_Duplicate:username:userUpdate": "Ein Benutzer mit diesem Benutzernamen existiert schon",
|
"FailedUpdate_Duplicate:username:userUpdate": "Ein Benutzer mit diesem Benutzernamen existiert schon",
|
||||||
"FailedUpdate_Duplicate:email:userUpdate": "Ein Benutzer mit dieser E-Mail existiert schon",
|
"FailedUpdate_Duplicate:email:userUpdate": "Ein Benutzer mit dieser E-Mail existiert schon",
|
||||||
|
|
||||||
|
"Unauthorized_newPost": "Keine Berechtigung",
|
||||||
|
"MissingField_content:newPost": "Beitrag darf nicht leer sein",
|
||||||
|
|
||||||
|
"Unauthorized_postUpdate": "Keine Berechtigung",
|
||||||
|
"NotFound_post:postUpdate": "Post nicht gefunden",
|
||||||
|
|
||||||
"username": "Benutzername",
|
"username": "Benutzername",
|
||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
"password": "Passwort",
|
"password": "Passwort",
|
||||||
@ -50,5 +58,7 @@
|
|||||||
"Deleting": "Löscht...",
|
"Deleting": "Löscht...",
|
||||||
"Edit data": "Daten ändern",
|
"Edit data": "Daten ändern",
|
||||||
|
|
||||||
"Recent posts": "Letzten Posts"
|
"Recent posts": "Letzte Posts",
|
||||||
|
|
||||||
|
"Post comment": "Beitrag posten"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"Unauthorized": "Unauthorized",
|
"Unauthorized": "Unauthorized",
|
||||||
|
|
||||||
"NotFound_user:login": "User does not exist",
|
|
||||||
"Unauthorized_login": "Invalid email or password",
|
"Unauthorized_login": "Invalid email or password",
|
||||||
|
"NotFound_user:login": "User does not exist",
|
||||||
|
"MissingField_email:login": "E-Mail required",
|
||||||
|
"MissingField_password:login": "Password required",
|
||||||
|
|
||||||
"Unauthorized_deletPost": "Unauthorized",
|
"Unauthorized_deletPost": "Unauthorized",
|
||||||
"NotFound_post:deletePost": "Post not found",
|
"NotFound_post:deletePost": "Post not found",
|
||||||
@ -12,6 +14,12 @@
|
|||||||
"FailedUpdate_Duplicate:userUpdate": "A user with this username already exists",
|
"FailedUpdate_Duplicate:userUpdate": "A user with this username already exists",
|
||||||
"FailedUpdate_Duplicate:email:userUpdate": "A user with this email already exists",
|
"FailedUpdate_Duplicate:email:userUpdate": "A user with this email already exists",
|
||||||
|
|
||||||
|
"Unauthorized_newPost": "Unauthorized",
|
||||||
|
"MissingField_content:newPost": "Content required",
|
||||||
|
|
||||||
|
"Unauthorized_postUpdate": "Unauthorized",
|
||||||
|
"NotFound_post:postUpdate": "Post not found",
|
||||||
|
|
||||||
"username": "username",
|
"username": "username",
|
||||||
"email": "email",
|
"email": "email",
|
||||||
"password": "password",
|
"password": "password",
|
||||||
@ -51,5 +59,7 @@
|
|||||||
|
|
||||||
"Edit data": "Edit date",
|
"Edit data": "Edit date",
|
||||||
|
|
||||||
"Recent posts": "Recent posts"
|
"Recent posts": "Last posts",
|
||||||
|
|
||||||
|
"Post omment": "Post comment"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { PostAuth, PostListAuth, PostListNonAuth } from '../types/Post';
|
import { POST_LIMIT } from '../constanst';
|
||||||
|
import { PostAuth, PostListAuth, PostListNonAuth, PostUpdate } from '../types/Post';
|
||||||
import { User, UserUpdate } from '../types/User';
|
import { User, UserUpdate } from '../types/User';
|
||||||
|
|
||||||
const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/';
|
const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/';
|
||||||
@ -43,7 +44,7 @@ class ApiImpl {
|
|||||||
};
|
};
|
||||||
|
|
||||||
public posts = async (page?: number): Promise<PostListNonAuth | PostListAuth> => {
|
public posts = async (page?: number): Promise<PostListNonAuth | PostListAuth> => {
|
||||||
const url = `posts?p=${page ?? 0}&l=9`;
|
const url = `posts?p=${page ?? 0}&l=${POST_LIMIT}`;
|
||||||
|
|
||||||
if (this.token) return await (await this.getAuth(url)).json();
|
if (this.token) return await (await this.getAuth(url)).json();
|
||||||
|
|
||||||
@ -62,6 +63,14 @@ class ApiImpl {
|
|||||||
return await (await this.patch(`users/${id ?? 'self'}`, data as Record<string, unknown>)).json();
|
return await (await this.patch(`users/${id ?? 'self'}`, data as Record<string, unknown>)).json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public newPost = async (data: PostUpdate): Promise<PostAuth> => {
|
||||||
|
return await (await this.postAuth(`posts`, data as Record<string, unknown>)).json();
|
||||||
|
};
|
||||||
|
|
||||||
|
public updatePost = async (data: PostUpdate, id: number): Promise<PostAuth> => {
|
||||||
|
return await (await this.patch(`posts/${id}`, data as Record<string, unknown>)).json();
|
||||||
|
};
|
||||||
|
|
||||||
/* Internal */
|
/* Internal */
|
||||||
|
|
||||||
private post = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
|
private post = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
|
||||||
|
|||||||
175
exam/react/src/components/Dialogs/PostEdit/PostEditDialog.tsx
Normal file
175
exam/react/src/components/Dialogs/PostEdit/PostEditDialog.tsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
LinearProgress,
|
||||||
|
TextField,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useForm } from '@tanstack/react-form';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { t } from 'i18next';
|
||||||
|
import { FC, FormEvent, useState } from 'react';
|
||||||
|
import Api from '../../../api/Api';
|
||||||
|
import { PostAuth, PostUpdate } from '../../../types/Post';
|
||||||
|
import ErrorComponent from '../../Error/ErrorComponent';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
post: PostAuth;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PostEditDialog: FC<Props> = ({ post, open, onClose }) => {
|
||||||
|
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const [error, setError] = useState<any>();
|
||||||
|
const [characterCount, setCharacterCount] = useState(post.content.length);
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ data, id }: { data: PostUpdate; id: number }) => {
|
||||||
|
return Api.updatePost(data, id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<PostUpdate>({
|
||||||
|
defaultValues: {
|
||||||
|
content: post.content.replaceAll('<br />', ''),
|
||||||
|
},
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
try {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ data: value, id: post.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
handleClose();
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||||
|
},
|
||||||
|
onError: setError,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (error: any) {
|
||||||
|
setError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.only('xs'), { noSsr: true });
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
form.reset();
|
||||||
|
setError(undefined);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
fullWidth
|
||||||
|
fullScreen={fullScreen}
|
||||||
|
PaperProps={{
|
||||||
|
component: 'form',
|
||||||
|
onSubmit: (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
form.handleSubmit();
|
||||||
|
},
|
||||||
|
onKeyDown: (event: React.KeyboardEvent<HTMLFormElement>) => {
|
||||||
|
if (event.key === 'Tab') {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
noValidate: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle>{t('Edit post')}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<form.Field
|
||||||
|
name="content"
|
||||||
|
validators={{
|
||||||
|
onChange: ({ value }) => (!value ? t('Content required') : undefined),
|
||||||
|
onChangeAsyncDebounceMs: 250,
|
||||||
|
onChangeAsync: async ({ value }) => {
|
||||||
|
return !value && t('Content required');
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
children={(field) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
variant="outlined"
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value.length <= 250) {
|
||||||
|
setCharacterCount(e.target.value.length);
|
||||||
|
field.handleChange(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
label={t('Comment')}
|
||||||
|
required
|
||||||
|
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||||
|
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
|
||||||
|
autoComplete="off"
|
||||||
|
margin="dense"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={(characterCount / 250) * 100}
|
||||||
|
color={characterCount === 250 ? 'error' : characterCount >= 200 ? 'warning' : 'primary'}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<form.Subscribe
|
||||||
|
selector={(state) => [state.canSubmit, state.isSubmitting]}
|
||||||
|
children={([canSubmit]) => (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => {
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit || updateMutation.isPending}
|
||||||
|
autoFocus
|
||||||
|
variant="contained"
|
||||||
|
endIcon={updateMutation.isPending && <CircularProgress color="inherit" size="20px" />}
|
||||||
|
>
|
||||||
|
{t('Save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</DialogActions>
|
||||||
|
{error && (
|
||||||
|
<DialogContent>
|
||||||
|
<ErrorComponent error={error} context="postUpdate" />
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PostEditDialog;
|
||||||
@ -66,6 +66,7 @@ const UserEditDialog: FC<Props> = ({ user, open, onClose }) => {
|
|||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setError(undefined);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -13,6 +13,8 @@ interface Props {
|
|||||||
const ErrorComponent: FC<Props> = ({ error, context, color = 'error.main' }) => {
|
const ErrorComponent: FC<Props> = ({ error, context, color = 'error.main' }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
console.log(error, context);
|
||||||
|
|
||||||
if (!error) return null;
|
if (!error) return null;
|
||||||
|
|
||||||
if (error.code) {
|
if (error.code) {
|
||||||
@ -21,12 +23,18 @@ const ErrorComponent: FC<Props> = ({ error, context, color = 'error.main' }) =>
|
|||||||
return <Typography color={color}>{t(error.code, { context: `${error.entity}:${context}` })}</Typography>;
|
return <Typography color={color}>{t(error.code, { context: `${error.entity}:${context}` })}</Typography>;
|
||||||
case ERRORS.UNAUTHORIZED:
|
case ERRORS.UNAUTHORIZED:
|
||||||
return <Typography color={color}>{t(error.code, { context })}</Typography>;
|
return <Typography color={color}>{t(error.code, { context })}</Typography>;
|
||||||
case ERRORS.FAILEDUPDATE:
|
case ERRORS.FAILED_UPDATE:
|
||||||
return error.fields.map((field: string, index: number) => (
|
return error.fields.map((field: string, index: number) => (
|
||||||
<Typography key={`error_${field}`} color={color}>
|
<Typography key={`error_${field}`} color={color}>
|
||||||
{t(error.code, { context: `${error.reasons[index]}:${field}:${context}` })}
|
{t(error.code, { context: `${error.reasons[index]}:${field}:${context}` })}
|
||||||
</Typography>
|
</Typography>
|
||||||
));
|
));
|
||||||
|
case ERRORS.MISSING_FIELD:
|
||||||
|
return error.fields.map((field: string) => (
|
||||||
|
<Typography key={`error_${field}`} color={color}>
|
||||||
|
{t(error.code, { context: `${field}:${context}` })}
|
||||||
|
</Typography>
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export enum ERRORS {
|
export enum ERRORS {
|
||||||
NOT_FOUND = 'NotFound',
|
NOT_FOUND = 'NotFound',
|
||||||
UNAUTHORIZED = 'Unauthorized',
|
UNAUTHORIZED = 'Unauthorized',
|
||||||
FAILEDUPDATE = 'FailedUpdate',
|
FAILED_UPDATE = 'FailedUpdate',
|
||||||
|
MISSING_FIELD = 'MissingField',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useRouter } from '@tanstack/react-router';
|
|||||||
import { FC, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Api from '../../../api/Api';
|
import Api from '../../../api/Api';
|
||||||
|
import { Login } from '../../../types/User';
|
||||||
import ErrorComponent from '../../Error/ErrorComponent';
|
import ErrorComponent from '../../Error/ErrorComponent';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -16,7 +17,7 @@ const LoginForm: FC<Props> = ({ handleClose }) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm<Login>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
@ -74,6 +75,7 @@ const LoginForm: FC<Props> = ({ handleClose }) => {
|
|||||||
type="email"
|
type="email"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -104,6 +106,7 @@ const LoginForm: FC<Props> = ({ handleClose }) => {
|
|||||||
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
|
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="password"
|
autoComplete="password"
|
||||||
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
125
exam/react/src/components/Forms/Post/PostForm.tsx
Normal file
125
exam/react/src/components/Forms/Post/PostForm.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { Box, Button, CircularProgress, LinearProgress, TextField } from '@mui/material';
|
||||||
|
import { useForm } from '@tanstack/react-form';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { FC, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import Api from '../../../api/Api';
|
||||||
|
import { PostUpdate } from '../../../types/Post';
|
||||||
|
import ErrorComponent from '../../Error/ErrorComponent';
|
||||||
|
|
||||||
|
const PostForm: FC = () => {
|
||||||
|
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const [error, setError] = useState<any>();
|
||||||
|
const [characterCount, setCharacterCount] = useState(0);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const newMutation = useMutation({
|
||||||
|
mutationFn: ({ data }: { data: PostUpdate }) => {
|
||||||
|
return Api.newPost(data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<PostUpdate>({
|
||||||
|
defaultValues: {
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
try {
|
||||||
|
newMutation.mutate(
|
||||||
|
{ data: value },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||||
|
},
|
||||||
|
onError: setError,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (error: any) {
|
||||||
|
setError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Tab') {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'grid', gap: 2, padding: 1 }}>
|
||||||
|
<form.Field
|
||||||
|
name="content"
|
||||||
|
validators={{
|
||||||
|
onChange: ({ value }) => (!value ? t('Content required') : undefined),
|
||||||
|
onChangeAsyncDebounceMs: 250,
|
||||||
|
onChangeAsync: async ({ value }) => {
|
||||||
|
return !value && t('Content required');
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
children={(field) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
variant="outlined"
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value.length <= 250) {
|
||||||
|
setCharacterCount(e.target.value.length);
|
||||||
|
field.handleChange(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
label={t('Comment')}
|
||||||
|
required
|
||||||
|
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||||
|
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
|
||||||
|
autoComplete="off"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={(characterCount / 250) * 100}
|
||||||
|
color={characterCount === 250 ? 'error' : characterCount >= 200 ? 'warning' : 'primary'}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<form.Subscribe
|
||||||
|
selector={(state) => [state.canSubmit, state.isSubmitting]}
|
||||||
|
children={([canSubmit]) => (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit || newMutation.isPending}
|
||||||
|
variant="contained"
|
||||||
|
endIcon={newMutation.isPending && <CircularProgress color="inherit" size="20px" />}
|
||||||
|
>
|
||||||
|
{t('Post comment')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{error && <ErrorComponent error={error} context="newPost" />}
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PostForm;
|
||||||
@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import Api from '../../api/Api';
|
import Api from '../../api/Api';
|
||||||
import { PostAuth, PostNonAuth } from '../../types/Post';
|
import { PostAuth, PostNonAuth } from '../../types/Post';
|
||||||
import convertDate from '../../utils/date';
|
import convertDate from '../../utils/date';
|
||||||
|
import PostEditDialog from '../Dialogs/PostEdit/PostEditDialog';
|
||||||
import ErrorComponent from '../Error/ErrorComponent';
|
import ErrorComponent from '../Error/ErrorComponent';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -30,7 +31,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Post: FC<Props> = ({ post }) => {
|
const Post: FC<Props> = ({ post }) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const [error, setError] = useState<any>();
|
const [error, setError] = useState<any>();
|
||||||
|
|
||||||
@ -86,22 +88,32 @@ const Post: FC<Props> = ({ post }) => {
|
|||||||
subheader={convertDate(post.postedAt)}
|
subheader={convertDate(post.postedAt)}
|
||||||
/>
|
/>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography>{post.content}</Typography>
|
<Typography>
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: post.content }} />
|
||||||
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<CardActions>
|
<CardActions>
|
||||||
|
{(Api.isAdmin() || ('id' in post.user && post.user.id === Api.getAuthenticatedUser()?.id)) && (
|
||||||
|
<>
|
||||||
|
<Button size="small" onClick={() => setEditOpen(true)}>
|
||||||
|
{t('Edit')}
|
||||||
|
</Button>
|
||||||
|
<PostEditDialog post={post as PostAuth} open={editOpen} onClose={() => setEditOpen(false)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{Api.isAdmin() && (
|
{Api.isAdmin() && (
|
||||||
<>
|
<>
|
||||||
<Button size="small" color="error" onClick={() => setOpen(true)}>
|
<Button size="small" color="error" onClick={() => setDeleteOpen(true)}>
|
||||||
{t('Delete')}
|
{t('Delete')}
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog open={open} onClose={() => setOpen(false)}>
|
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}>
|
||||||
<DialogTitle>{t('Confirm post delete title')}</DialogTitle>
|
<DialogTitle>{t('Confirm post delete title')}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>{t('Confirm post delete body', { name: post.user.username })}</DialogContentText>
|
<DialogContentText>{t('Confirm post delete body', { name: post.user.username })}</DialogContentText>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setOpen(false)} autoFocus variant="contained">
|
<Button onClick={() => setDeleteOpen(false)} autoFocus variant="contained">
|
||||||
{t('No')}
|
{t('No')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@ -116,7 +128,7 @@ const Post: FC<Props> = ({ post }) => {
|
|||||||
},
|
},
|
||||||
onError: setError,
|
onError: setError,
|
||||||
});
|
});
|
||||||
setOpen(false);
|
setDeleteOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('Yes')}
|
{t('Yes')}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { FC, useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { User } from '../../types/User';
|
import { User } from '../../types/User';
|
||||||
import convertDate from '../../utils/date';
|
import convertDate from '../../utils/date';
|
||||||
import UserEditDialog from '../Forms/UserEdit/UserEditDialog';
|
import UserEditDialog from '../Dialogs/UserEdit/UserEditDialog';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
|
|||||||
1
exam/react/src/constanst.ts
Normal file
1
exam/react/src/constanst.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const POST_LIMIT = 15;
|
||||||
@ -1,7 +1,9 @@
|
|||||||
import { Grid, Pagination, PaginationItem, Snackbar } from '@mui/material';
|
import { Divider, Grid, Pagination, PaginationItem, Snackbar } from '@mui/material';
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import Api from '../api/Api';
|
||||||
|
import PostForm from '../components/Forms/Post/PostForm';
|
||||||
import Post from '../components/Post/Post';
|
import Post from '../components/Post/Post';
|
||||||
import { postsQueryOptions } from '../queries/postsQuery';
|
import { postsQueryOptions } from '../queries/postsQuery';
|
||||||
import { ROUTES } from '../types/Routes';
|
import { ROUTES } from '../types/Routes';
|
||||||
@ -20,6 +22,14 @@ const Home = () => {
|
|||||||
<Post post={post} />
|
<Post post={post} />
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Divider variant="middle" />
|
||||||
|
</Grid>
|
||||||
|
{Api.hasAuth() && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<PostForm />
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
<Pagination
|
<Pagination
|
||||||
page={(page ?? 0) + 1}
|
page={(page ?? 0) + 1}
|
||||||
|
|||||||
@ -27,3 +27,7 @@ export interface PostListAuth {
|
|||||||
pages: number;
|
pages: number;
|
||||||
data: PostNonAuth[];
|
data: PostNonAuth[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PostUpdate {
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -16,3 +16,8 @@ export interface UserUpdate {
|
|||||||
email?: string;
|
email?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Login {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "ES2020",
|
"target": "ES2021",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user