Skip to content

Commit

Permalink
Connect post creation page to API (#148)
Browse files Browse the repository at this point in the history
* Create form directory

* Set up community create page

* Add sublabel to name field

* Add decsription and checkboxes

* Create required fields error state

* Limit name characters

* Connect to API

* Update placeholder test

* Rename signup form

* Add file inputs to community form

* Clean up submission fn

* Prevent unnecessary state updates during logout

* Update user context object

* Remove console log

* Create basic community page header

* Add bottom padding

* Add todo markers

* Ignore MT error

* Create post form

* Retrieve communities

* Show community selector

* Add select styles

* Create selector component

* Show alt field on media post

* Use same disabled prop

* Allow undefined placeholder value

* Add URL info
  • Loading branch information
kgilles authored May 14, 2024
1 parent 9f965f9 commit 15af7e3
Show file tree
Hide file tree
Showing 6 changed files with 321 additions and 22 deletions.
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

0 comments on commit 15af7e3

Please sign in to comment.