Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Connect post creation page to API #148

Merged
merged 29 commits into from
May 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5340782
Create form directory
kgilles Apr 2, 2024
c86abab
Set up community create page
kgilles Apr 2, 2024
ded773c
Add sublabel to name field
kgilles Apr 2, 2024
7565f08
Add decsription and checkboxes
kgilles Apr 2, 2024
9bf0a3a
Create required fields error state
kgilles Apr 2, 2024
bc7a660
Limit name characters
kgilles Apr 2, 2024
3083a8b
Connect to API
kgilles Apr 2, 2024
9454912
Update placeholder test
kgilles Apr 2, 2024
2cc243e
Rename signup form
kgilles Apr 23, 2024
e61ca1e
Add file inputs to community form
kgilles Apr 23, 2024
270275d
Clean up submission fn
kgilles Apr 23, 2024
51b0723
Prevent unnecessary state updates during logout
kgilles Apr 23, 2024
90719b3
Update user context object
kgilles Apr 23, 2024
d36e455
Remove console log
kgilles Apr 23, 2024
a4af422
Create basic community page header
kgilles Apr 23, 2024
824364b
Add bottom padding
kgilles Apr 23, 2024
6518f65
Add todo markers
kgilles Apr 24, 2024
dded679
Ignore MT error
kgilles Apr 29, 2024
69ec906
Create post form
kgilles May 6, 2024
8e76f7a
Retrieve communities
kgilles May 6, 2024
7ff8589
Show community selector
kgilles May 6, 2024
929b07b
Add select styles
kgilles May 6, 2024
13a9e27
Create selector component
kgilles May 6, 2024
6ecbe6b
Show alt field on media post
kgilles May 6, 2024
8ac47c2
Use same disabled prop
kgilles May 6, 2024
4e2b3e4
Allow undefined placeholder value
kgilles May 6, 2024
8dc28cc
Add URL info
kgilles May 6, 2024
816c3e0
Merge branch 'main' into 14-connect-post-create-page-to-api
kgilles May 6, 2024
77aa1b0
Merge branch 'main' into 14-connect-post-create-page-to-api
kgilles May 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 32 additions & 19 deletions src/app/p/page.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,41 @@
import React from 'react';

import MainCard from '@/components/main-card';
import { BodyTitleInverse, H1 } from '@/components/text';
import { Checkbox, InputField, MarkdownTextarea } from '@/components/input';
import Button from '@/components/button';
import SublinksApi from '@/utils/api-client/server';
import { ErrorText, H1 } from '@/components/text';
import PostForm from '@/components/form/post';
import logger from '@/utils/logger';

const PostCreate = () => {
const Header = <H1>Create Post</H1>;
const getCommunities = async () => {
try {
const communities = await SublinksApi.Instance().Client().listCommunities();

return communities;
} catch (e) {
logger.error('Failed to retrieve communities', e);
return undefined;
}
};

const PostCreate = async () => {
const communityList = await getCommunities();

if (!communityList) {
return (
<div className="flex justify-center mt-24">
<ErrorText>Something went wrong. Please reload the page to try again.</ErrorText>
</div>
);
}

return (
<MainCard Header={Header}>
<form className="mt-24 max-w-500">
<div className="flex flex-col gap-16">
<InputField type="text" label="Title" name="title" id="title" placeholder="Post Title" showBorderPlaceholder />
<InputField type="text" label="URL" name="url" id="url" placeholder="URL" showBorderPlaceholder />
<InputField type="file" label="Media" name="media" id="media" placeholder="Media" showBorderPlaceholder inputClassName="mt-8 file:text-gray-200 file:dark:text-black file:px-8 file:py-4 file:bg-brand dark:file:bg-brand-dark file:border file:rounded-md rounded" />
<MarkdownTextarea label="Body" id="body" />
<Checkbox label="NSFW" id="nsfw" name="nsfw" />
<Button type="submit" id="create-post">
<BodyTitleInverse>Create</BodyTitleInverse>
</Button>
<div className="flex flex-col items-center p-24 md:p-56 w-full">
<div className="w-full md:w-500 overflow-x-hidden">
<H1>Create a New Post</H1>
<div className="mt-32 pb-24">
<PostForm communities={communityList.communities} />
</div>
</form>
</MainCard>
</div>
</div>
);
};

Expand Down
2 changes: 1 addition & 1 deletion src/components/comment-feed/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const CommentFeed = ({ data: comments }: CommentFeedProps) => (
<div key={commentData.comment.ap_id} className="mb-8">
<CommentCard comment={commentData} />
</div>
)) : (<H1 className="text-center">No comments available!</H1>)}
)) : (<H1 className="text-center">No comments available</H1>)}
</div>
);

Expand Down
232 changes: 232 additions & 0 deletions src/components/form/post.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
'use client';

import React, {
FormEvent, useContext, useEffect, useState
} from 'react';
import { useRouter } from 'next/navigation';
import { CommunityView } from 'sublinks-js-client';
import { Spinner } from '@material-tailwind/react';

import { Checkbox, InputField, MarkdownTextarea } from '@/components/input';
import Button from '@/components/button';
import { Selector } from '@/components/input/select';
import { BodyTitleInverse, ErrorText, PaleBodyText } from '@/components/text';
import SublinksApi from '@/utils/api-client/client';
import logger from '@/utils/logger';
import { UserContext } from '@/context/user';
import { isImage } from '@/utils/links';
import { getCommunitySlugFromUrl } from '@/utils/communities';

interface PostFormProps {
communities: CommunityView[];
}

const INPUT_IDS = {
COMMUNITY: 'community',
TITLE: 'title',
URL: 'url',
MEDIA: 'media',
ALT: 'alt',
BODY: 'body',
NSFW: 'nsfw'
};

const REQUIRED_FIELDS = [
INPUT_IDS.COMMUNITY,
INPUT_IDS.TITLE
];

const PostForm = ({ communities }: PostFormProps) => {
const router = useRouter();
const { userData } = useContext(UserContext);
const [erroneousFields, setErroneousFields] = useState<string[]>([]);
const [errorMessage, setErrorMessage] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isMediaPost, setIsMediaPost] = useState(false);

useEffect(() => {
if (userData.auth === false) {
router.push('/login');
}
}, [userData]); // eslint-disable-line react-hooks/exhaustive-deps

const validateRequiredFields = (fieldValues: Record<string, string | number | File>) => {
const emptyFields: string[] = [];

REQUIRED_FIELDS.forEach(fieldKey => {
const key = fieldKey as keyof typeof fieldValues;

if (!fieldValues[key]) {
emptyFields.push(key);
}
});

return emptyFields;
};

const uploadPostImage = async (postImage: File) => {
try {
const { url } = await SublinksApi.Instance().Client().uploadImage({
image: postImage
});
return url;
} catch (e) {
logger.error('Unable to upload image for post', postImage, e);
}

return undefined;
};

const handleCreationAttempt = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
setErrorMessage('');
setErroneousFields([]);

const formData = new FormData(event.currentTarget);
const fieldValues = {
community: parseInt(formData.get(INPUT_IDS.COMMUNITY) as string, 10),
title: formData.get(INPUT_IDS.TITLE) as string,
url: formData.get(INPUT_IDS.URL) as string,
media: formData.get(INPUT_IDS.MEDIA) as File,
alt: formData.get(INPUT_IDS.ALT) as string,
body: formData.get(INPUT_IDS.BODY) as string,
nsfw: formData.get(INPUT_IDS.NSFW) as string
};
let imageUrl;

const emptyFields = validateRequiredFields(fieldValues);
if (emptyFields.length > 0) {
setErroneousFields(emptyFields);
setErrorMessage('Please enter all required information');
setIsSubmitting(false);
return;
}

if (fieldValues.media && isImage(fieldValues.media.name)) {
imageUrl = await uploadPostImage(fieldValues.media);
}

try {
const { post_view: postView } = await SublinksApi.Instance().Client().createPost({
community_id: fieldValues.community,
name: fieldValues.title,
url: imageUrl || fieldValues.url,
body: fieldValues.body,
alt_text: fieldValues.alt,
nsfw: Boolean(fieldValues.nsfw)
});

const { community, post } = postView;
const { actor_id: nativeCommunityUrl } = community;
const { id } = post;
const comSlug = getCommunitySlugFromUrl(nativeCommunityUrl, true);

router.push(`/c/${comSlug}/${id}`);
} catch (e) {
logger.error('Post creation attempt failed', e);
setErrorMessage('Could not create post. Please try again.');
setIsSubmitting(false);
}
};

const handleFieldValueChange = async (event: FormEvent<HTMLFormElement>) => {
const field = event.target as HTMLInputElement;
const fieldKey = field.id;
const fieldIndexInErrors = erroneousFields.indexOf(fieldKey);

if (fieldIndexInErrors !== -1) {
const newErroneousFields = [...erroneousFields];
newErroneousFields.splice(fieldIndexInErrors, 1);
setErroneousFields(newErroneousFields);

if (newErroneousFields.length === 0) {
setErrorMessage('');
}
}
};

const communityOptions = communities.map(view => ({
value: view.community.id,
label: getCommunitySlugFromUrl(view.community.actor_id, false) || view.community.name
}));

return (
<form onSubmit={handleCreationAttempt} onChange={handleFieldValueChange} className="flex flex-col">
<div className="flex flex-col gap-16">
<Selector
id={INPUT_IDS.COMMUNITY}
label="Community selector"
options={communityOptions}
placeholder={{
value: undefined,
label: 'Select community'
}}
disabled={isSubmitting}
hasError={erroneousFields.includes(INPUT_IDS.COMMUNITY)}
/>
<InputField
type="text"
label="Title"
name={INPUT_IDS.TITLE}
id={INPUT_IDS.TITLE}
placeholder="Title"
showBorderPlaceholder
disabled={isSubmitting}
hasError={erroneousFields.includes(INPUT_IDS.TITLE)}
/>
<div>
<InputField
type="text"
label="URL"
name={INPUT_IDS.URL}
id={INPUT_IDS.URL}
placeholder="Url"
showBorderPlaceholder
disabled={isSubmitting || isMediaPost}
hasError={erroneousFields.includes(INPUT_IDS.URL)}
/>
<PaleBodyText className="text-sm">Will be overridden by the image URL if one is submitted.</PaleBodyText>
</div>
<InputField
type="file"
label="Image"
name={INPUT_IDS.MEDIA}
id={INPUT_IDS.MEDIA}
placeholder="Image"
showBorderPlaceholder
disabled={isSubmitting}
hasError={erroneousFields.includes(INPUT_IDS.MEDIA)}
onChange={e => setIsMediaPost(Boolean(e.currentTarget.value))}
/>
{isMediaPost && (
<div>
<InputField
type="text"
label="Image Description"
name={INPUT_IDS.ALT}
id={INPUT_IDS.ALT}
placeholder="Image Description"
showBorderPlaceholder
disabled={isSubmitting}
hasError={erroneousFields.includes(INPUT_IDS.ALT)}
/>
<PaleBodyText className="text-sm">Used by screen readers to inform what the image depicts.</PaleBodyText>
</div>
)}
<MarkdownTextarea id={INPUT_IDS.BODY} label="Content" initialValue="**Content**" />
<Checkbox label="Is NSFW" name={INPUT_IDS.NSFW} id={INPUT_IDS.NSFW} />
</div>
<div aria-live="polite" className="h-32">
{errorMessage && <ErrorText className="text-sm">{errorMessage}</ErrorText>}
</div>
<Button type="submit" disabled={isSubmitting} className="flex justify-center">
{/*
// @ts-expect-error MT isn't up to date with their React types as of 2.1.9 */}
{isSubmitting ? <Spinner className="h-24 w-24" /> : <BodyTitleInverse>Create Post</BodyTitleInverse>}
</Button>
</form>
);
};

export default PostForm;
5 changes: 4 additions & 1 deletion src/components/input/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ interface InputFieldProps {
showBorderPlaceholder?: boolean;
borderPlaceholderClassName?: string;
inputPattern?: string;
onChange?: (e: React.FormEvent<HTMLInputElement>) => void;
}

const InputField = ({
Expand All @@ -51,7 +52,8 @@ const InputField = ({
iconClassName,
showBorderPlaceholder,
borderPlaceholderClassName,
inputPattern
inputPattern,
onChange
}: InputFieldProps) => (
<div className={cx('bg-primary dark:bg-gray-800 rounded-md', className)}>
<label htmlFor={name} className="sr-only">
Expand All @@ -71,6 +73,7 @@ const InputField = ({
placeholder={placeholder}
disabled={disabled}
pattern={inputPattern}
onChange={onChange}
/>
{showBorderPlaceholder && <PaleBodyText className={cx('absolute text-xs bg-primary dark:bg-gray-800 px-4 -top-12 peer-placeholder-shown:top-0 opacity-100 peer-placeholder-shown:opacity-0 rounded-t-md border-t-2 border-x-2 dark:dark:border-gray-900 transition-all', borderPlaceholderClassName)}>{placeholder}</PaleBodyText>}
</div>
Expand Down
50 changes: 50 additions & 0 deletions src/components/input/select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import cx from 'classnames';

import Icon, { ICON_SIZE } from '../icon';

interface SelectorProps {
id: string;
label: string;
options: {
value: string | number;
label: string;
}[];
placeholder?: {
value: string | number | undefined;
label: string;
};
disabled?: boolean;
hasError?: boolean;
}

const Selector = ({
id, label, options, placeholder, disabled, hasError
}: SelectorProps) => (

<div className="relative bg-primary dark:bg-gray-800 rounded-md">
<label htmlFor={id} className="sr-only">
{label}
</label>
<select
name={id}
id={id}
className={cx('w-full bg-primary dark:bg-gray-800 text-gray-900 dark:text-white flex h-40 items-center border-2 rounded-md px-16 appearance-none', hasError ? 'border-red-700 dark:border-red-400' : 'border-gray-300 dark:border-gray-900')}
disabled={disabled}
>
{placeholder && <option value={placeholder.value}>{placeholder.label}</option>}
{placeholder && <option disabled>---</option>}
{options.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<div className="absolute right-16 top-8"><Icon IconType={ChevronDownIcon} size={ICON_SIZE.SMALL} /></div>
</div>
);

export {
Selector
};
3 changes: 2 additions & 1 deletion src/components/input/textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ const MarkdownTextarea = ({
autoFocus={false}
preview={showPreview ? 'preview' : 'edit'}
textareaProps={{
autoCapitalize: 'none'
autoCapitalize: 'none',
name: id
}}
defaultTabEnable
visibleDragbar={false}
Expand Down
Loading