diff --git a/api/db/migrations/20241004073828_intoroduce_posts_schema/migration.sql b/api/db/migrations/20241004073828_intoroduce_posts_schema/migration.sql new file mode 100644 index 0000000..fc0c6a3 --- /dev/null +++ b/api/db/migrations/20241004073828_intoroduce_posts_schema/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "Post" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); diff --git a/api/db/schema.prisma b/api/db/schema.prisma index f0f0f1d..664ddf4 100644 --- a/api/db/schema.prisma +++ b/api/db/schema.prisma @@ -52,3 +52,11 @@ model Identity { @@unique([provider, uid]) @@index(userId) } + +model Post { + id Int @id @default(autoincrement()) + title String + body String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/api/src/graphql/posts.sdl.ts b/api/src/graphql/posts.sdl.ts new file mode 100644 index 0000000..e492b3b --- /dev/null +++ b/api/src/graphql/posts.sdl.ts @@ -0,0 +1,30 @@ +export const schema = gql` + type Post { + id: Int! + title: String! + body: String! + createdAt: DateTime! + updatedAt: DateTime! + } + + type Query { + posts: [Post!]! @requireAuth + post(id: Int!): Post @requireAuth + } + + input CreatePostInput { + title: String! + body: String! + } + + input UpdatePostInput { + title: String + body: String + } + + type Mutation { + createPost(input: CreatePostInput!): Post! @requireAuth + updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth + deletePost(id: Int!): Post! @requireAuth + } +` diff --git a/api/src/services/posts/posts.scenarios.ts b/api/src/services/posts/posts.scenarios.ts new file mode 100644 index 0000000..f065ccc --- /dev/null +++ b/api/src/services/posts/posts.scenarios.ts @@ -0,0 +1,23 @@ +import type { Prisma, Post } from '@prisma/client' +import type { ScenarioData } from '@redwoodjs/testing/api' + +export const standard = defineScenario({ + post: { + one: { + data: { + title: 'String', + body: 'String', + updatedAt: '2024-10-04T07:38:59.006Z', + }, + }, + two: { + data: { + title: 'String', + body: 'String', + updatedAt: '2024-10-04T07:38:59.006Z', + }, + }, + }, +}) + +export type StandardScenario = ScenarioData diff --git a/api/src/services/posts/posts.test.ts b/api/src/services/posts/posts.test.ts new file mode 100644 index 0000000..6c58323 --- /dev/null +++ b/api/src/services/posts/posts.test.ts @@ -0,0 +1,55 @@ +import type { Post } from '@prisma/client' + +import { posts, post, createPost, updatePost, deletePost } from './posts' +import type { StandardScenario } from './posts.scenarios' + +// Generated boilerplate tests do not account for all circumstances +// and can fail without adjustments, e.g. Float. +// Please refer to the RedwoodJS Testing Docs: +// https://redwoodjs.com/docs/testing#testing-services +// https://redwoodjs.com/docs/testing#jest-expect-type-considerations + +describe('posts', () => { + scenario('returns all posts', async (scenario: StandardScenario) => { + const result = await posts() + + expect(result.length).toEqual(Object.keys(scenario.post).length) + }) + + scenario('returns a single post', async (scenario: StandardScenario) => { + const result = await post({ id: scenario.post.one.id }) + + expect(result).toEqual(scenario.post.one) + }) + + scenario('creates a post', async () => { + const result = await createPost({ + input: { + title: 'String', + body: 'String', + updatedAt: '2024-10-04T07:38:58.985Z', + }, + }) + + expect(result.title).toEqual('String') + expect(result.body).toEqual('String') + expect(result.updatedAt).toEqual(new Date('2024-10-04T07:38:58.985Z')) + }) + + scenario('updates a post', async (scenario: StandardScenario) => { + const original = (await post({ id: scenario.post.one.id })) as Post + const result = await updatePost({ + id: original.id, + input: { title: 'String2' }, + }) + + expect(result.title).toEqual('String2') + }) + + scenario('deletes a post', async (scenario: StandardScenario) => { + const original = (await deletePost({ id: scenario.post.one.id })) as Post + const result = await post({ id: original.id }) + + expect(result).toEqual(null) + }) +}) diff --git a/api/src/services/posts/posts.ts b/api/src/services/posts/posts.ts new file mode 100644 index 0000000..877d9fa --- /dev/null +++ b/api/src/services/posts/posts.ts @@ -0,0 +1,32 @@ +import type { QueryResolvers, MutationResolvers } from 'types/graphql' + +import { db } from 'src/lib/db' + +export const posts: QueryResolvers['posts'] = () => { + return db.post.findMany() +} + +export const post: QueryResolvers['post'] = ({ id }) => { + return db.post.findUnique({ + where: { id }, + }) +} + +export const createPost: MutationResolvers['createPost'] = ({ input }) => { + return db.post.create({ + data: input, + }) +} + +export const updatePost: MutationResolvers['updatePost'] = ({ id, input }) => { + return db.post.update({ + data: input, + where: { id }, + }) +} + +export const deletePost: MutationResolvers['deletePost'] = ({ id }) => { + return db.post.delete({ + where: { id }, + }) +} diff --git a/web/package.json b/web/package.json index 20334f4..4ae15b5 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "@redwoodjs/router": "8.3.0", "@redwoodjs/web": "8.3.0", "@redwoodjs/web-server": "8.3.0", + "humanize-string": "2.1.0", "react": "18.3.1", "react-dom": "18.3.1" }, diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx index dedc6a9..1424f33 100644 --- a/web/src/Routes.tsx +++ b/web/src/Routes.tsx @@ -7,13 +7,21 @@ // 'src/pages/HomePage/HomePage.js' -> HomePage // 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage -import { Router, Route } from '@redwoodjs/router' +import { Set, Router, Route, PrivateSet } from '@redwoodjs/router' + +import ScaffoldLayout from 'src/layouts/ScaffoldLayout' import { useAuth } from './auth' const Routes = () => { return ( + + + + + + diff --git a/web/src/components/Post/EditPostCell/EditPostCell.tsx b/web/src/components/Post/EditPostCell/EditPostCell.tsx new file mode 100644 index 0000000..003f9a2 --- /dev/null +++ b/web/src/components/Post/EditPostCell/EditPostCell.tsx @@ -0,0 +1,78 @@ +import type { + EditPostById, + UpdatePostInput, + UpdatePostMutationVariables, +} from 'types/graphql' + +import { navigate, routes } from '@redwoodjs/router' +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' +import { useMutation } from '@redwoodjs/web' +import { toast } from '@redwoodjs/web/toast' + +import PostForm from 'src/components/Post/PostForm' + +export const QUERY: TypedDocumentNode = gql` + query EditPostById($id: Int!) { + post: post(id: $id) { + id + title + body + createdAt + updatedAt + } + } +` + +const UPDATE_POST_MUTATION: TypedDocumentNode< + EditPostById, + UpdatePostMutationVariables +> = gql` + mutation UpdatePostMutation($id: Int!, $input: UpdatePostInput!) { + updatePost(id: $id, input: $input) { + id + title + body + createdAt + updatedAt + } + } +` + +export const Loading = () =>
Loading...
+ +export const Failure = ({ error }: CellFailureProps) => ( +
{error?.message}
+) + +export const Success = ({ post }: CellSuccessProps) => { + const [updatePost, { loading, error }] = useMutation(UPDATE_POST_MUTATION, { + onCompleted: () => { + toast.success('Post updated') + navigate(routes.posts()) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + const onSave = (input: UpdatePostInput, id: EditPostById['post']['id']) => { + updatePost({ variables: { id, input } }) + } + + return ( +
+
+

+ Edit Post {post?.id} +

+
+
+ +
+
+ ) +} diff --git a/web/src/components/Post/NewPost/NewPost.tsx b/web/src/components/Post/NewPost/NewPost.tsx new file mode 100644 index 0000000..3809b3b --- /dev/null +++ b/web/src/components/Post/NewPost/NewPost.tsx @@ -0,0 +1,52 @@ +import type { + CreatePostMutation, + CreatePostInput, + CreatePostMutationVariables, +} from 'types/graphql' + +import { navigate, routes } from '@redwoodjs/router' +import { useMutation } from '@redwoodjs/web' +import type { TypedDocumentNode } from '@redwoodjs/web' +import { toast } from '@redwoodjs/web/toast' + +import PostForm from 'src/components/Post/PostForm' + +const CREATE_POST_MUTATION: TypedDocumentNode< + CreatePostMutation, + CreatePostMutationVariables +> = gql` + mutation CreatePostMutation($input: CreatePostInput!) { + createPost(input: $input) { + id + } + } +` + +const NewPost = () => { + const [createPost, { loading, error }] = useMutation(CREATE_POST_MUTATION, { + onCompleted: () => { + toast.success('Post created') + navigate(routes.posts()) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + const onSave = (input: CreatePostInput) => { + createPost({ variables: { input } }) + } + + return ( +
+
+

New Post

+
+
+ +
+
+ ) +} + +export default NewPost diff --git a/web/src/components/Post/Post/Post.tsx b/web/src/components/Post/Post/Post.tsx new file mode 100644 index 0000000..ccc1cb2 --- /dev/null +++ b/web/src/components/Post/Post/Post.tsx @@ -0,0 +1,98 @@ +import type { + DeletePostMutation, + DeletePostMutationVariables, + FindPostById, +} from 'types/graphql' + +import { Link, routes, navigate } from '@redwoodjs/router' +import { useMutation } from '@redwoodjs/web' +import type { TypedDocumentNode } from '@redwoodjs/web' +import { toast } from '@redwoodjs/web/toast' + +import { timeTag } from 'src/lib/formatters' + +const DELETE_POST_MUTATION: TypedDocumentNode< + DeletePostMutation, + DeletePostMutationVariables +> = gql` + mutation DeletePostMutation($id: Int!) { + deletePost(id: $id) { + id + } + } +` + +interface Props { + post: NonNullable +} + +const Post = ({ post }: Props) => { + const [deletePost] = useMutation(DELETE_POST_MUTATION, { + onCompleted: () => { + toast.success('Post deleted') + navigate(routes.posts()) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + const onDeleteClick = (id: DeletePostMutationVariables['id']) => { + if (confirm('Are you sure you want to delete post ' + id + '?')) { + deletePost({ variables: { id } }) + } + } + + return ( + <> +
+
+

+ Post {post.id} Detail +

+
+ + + + + + + + + + + + + + + + + + + + + + + +
Id{post.id}
Title{post.title}
Body{post.body}
Created at{timeTag(post.createdAt)}
Updated at{timeTag(post.updatedAt)}
+
+ + + ) +} + +export default Post diff --git a/web/src/components/Post/PostCell/PostCell.tsx b/web/src/components/Post/PostCell/PostCell.tsx new file mode 100644 index 0000000..54c8987 --- /dev/null +++ b/web/src/components/Post/PostCell/PostCell.tsx @@ -0,0 +1,36 @@ +import type { FindPostById, FindPostByIdVariables } from 'types/graphql' + +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' + +import Post from 'src/components/Post/Post' + +export const QUERY: TypedDocumentNode = + gql` + query FindPostById($id: Int!) { + post: post(id: $id) { + id + title + body + createdAt + updatedAt + } + } + ` + +export const Loading = () =>
Loading...
+ +export const Empty = () =>
Post not found
+ +export const Failure = ({ error }: CellFailureProps) => ( +
{error?.message}
+) + +export const Success = ({ + post, +}: CellSuccessProps) => { + return +} diff --git a/web/src/components/Post/PostForm/PostForm.tsx b/web/src/components/Post/PostForm/PostForm.tsx new file mode 100644 index 0000000..0127e2d --- /dev/null +++ b/web/src/components/Post/PostForm/PostForm.tsx @@ -0,0 +1,83 @@ +import type { EditPostById, UpdatePostInput } from 'types/graphql' + +import type { RWGqlError } from '@redwoodjs/forms' +import { + Form, + FormError, + FieldError, + Label, + TextField, + Submit, +} from '@redwoodjs/forms' + +type FormPost = NonNullable + +interface PostFormProps { + post?: EditPostById['post'] + onSave: (data: UpdatePostInput, id?: FormPost['id']) => void + error: RWGqlError + loading: boolean +} + +const PostForm = (props: PostFormProps) => { + const onSubmit = (data: FormPost) => { + props.onSave(data, props?.post?.id) + } + + return ( +
+ onSubmit={onSubmit} error={props.error}> + + + + + + + + + + + + + + +
+ + Save + +
+ +
+ ) +} + +export default PostForm diff --git a/web/src/components/Post/Posts/Posts.tsx b/web/src/components/Post/Posts/Posts.tsx new file mode 100644 index 0000000..7f7fb3c --- /dev/null +++ b/web/src/components/Post/Posts/Posts.tsx @@ -0,0 +1,102 @@ +import type { + DeletePostMutation, + DeletePostMutationVariables, + FindPosts, +} from 'types/graphql' + +import { Link, routes } from '@redwoodjs/router' +import { useMutation } from '@redwoodjs/web' +import type { TypedDocumentNode } from '@redwoodjs/web' +import { toast } from '@redwoodjs/web/toast' + +import { QUERY } from 'src/components/Post/PostsCell' +import { timeTag, truncate } from 'src/lib/formatters' + +const DELETE_POST_MUTATION: TypedDocumentNode< + DeletePostMutation, + DeletePostMutationVariables +> = gql` + mutation DeletePostMutation($id: Int!) { + deletePost(id: $id) { + id + } + } +` + +const PostsList = ({ posts }: FindPosts) => { + const [deletePost] = useMutation(DELETE_POST_MUTATION, { + onCompleted: () => { + toast.success('Post deleted') + }, + onError: (error) => { + toast.error(error.message) + }, + // This refetches the query on the list page. Read more about other ways to + // update the cache over here: + // https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates + refetchQueries: [{ query: QUERY }], + awaitRefetchQueries: true, + }) + + const onDeleteClick = (id: DeletePostMutationVariables['id']) => { + if (confirm('Are you sure you want to delete post ' + id + '?')) { + deletePost({ variables: { id } }) + } + } + + return ( +
+ + + + + + + + + + + + + {posts.map((post) => ( + + + + + + + + + ))} + +
IdTitleBodyCreated atUpdated at 
{truncate(post.id)}{truncate(post.title)}{truncate(post.body)}{timeTag(post.createdAt)}{timeTag(post.updatedAt)} + +
+
+ ) +} + +export default PostsList diff --git a/web/src/components/Post/PostsCell/PostsCell.tsx b/web/src/components/Post/PostsCell/PostsCell.tsx new file mode 100644 index 0000000..5a02e5b --- /dev/null +++ b/web/src/components/Post/PostsCell/PostsCell.tsx @@ -0,0 +1,45 @@ +import type { FindPosts, FindPostsVariables } from 'types/graphql' + +import { Link, routes } from '@redwoodjs/router' +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' + +import Posts from 'src/components/Post/Posts' + +export const QUERY: TypedDocumentNode = gql` + query FindPosts { + posts { + id + title + body + createdAt + updatedAt + } + } +` + +export const Loading = () =>
Loading...
+ +export const Empty = () => { + return ( +
+ No posts yet.{' '} + + Create one? + +
+ ) +} + +export const Failure = ({ error }: CellFailureProps) => ( +
{error?.message}
+) + +export const Success = ({ + posts, +}: CellSuccessProps) => { + return +} diff --git a/web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx b/web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx new file mode 100644 index 0000000..f4daba6 --- /dev/null +++ b/web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx @@ -0,0 +1,37 @@ +import { Link, routes } from '@redwoodjs/router' +import { Toaster } from '@redwoodjs/web/toast' + +type LayoutProps = { + title: string + titleTo: keyof typeof routes + buttonLabel: string + buttonTo: keyof typeof routes + children: React.ReactNode +} + +const ScaffoldLayout = ({ + title, + titleTo, + buttonLabel, + buttonTo, + children, +}: LayoutProps) => { + return ( +
+ +
+

+ + {title} + +

+ +
+
{buttonLabel} + +
+
{children}
+
+ ) +} + +export default ScaffoldLayout diff --git a/web/src/lib/formatters.test.tsx b/web/src/lib/formatters.test.tsx new file mode 100644 index 0000000..5659338 --- /dev/null +++ b/web/src/lib/formatters.test.tsx @@ -0,0 +1,192 @@ +import { render, waitFor, screen } from '@redwoodjs/testing/web' + +import { + formatEnum, + jsonTruncate, + truncate, + timeTag, + jsonDisplay, + checkboxInputTag, +} from './formatters' + +describe('formatEnum', () => { + it('handles nullish values', () => { + expect(formatEnum(null)).toEqual('') + expect(formatEnum('')).toEqual('') + expect(formatEnum(undefined)).toEqual('') + }) + + it('formats a list of values', () => { + expect( + formatEnum(['RED', 'ORANGE', 'YELLOW', 'GREEN', 'BLUE', 'VIOLET']) + ).toEqual('Red, Orange, Yellow, Green, Blue, Violet') + }) + + it('formats a single value', () => { + expect(formatEnum('DARK_BLUE')).toEqual('Dark blue') + }) + + it('returns an empty string for values of the wrong type (for JS projects)', () => { + // @ts-expect-error - Testing JS scenario + expect(formatEnum(5)).toEqual('') + }) +}) + +describe('truncate', () => { + it('truncates really long strings', () => { + expect(truncate('na '.repeat(1000) + 'batman').length).toBeLessThan(1000) + expect(truncate('na '.repeat(1000) + 'batman')).not.toMatch(/batman/) + }) + + it('does not modify short strings', () => { + expect(truncate('Short strinG')).toEqual('Short strinG') + }) + + it('adds ... to the end of truncated strings', () => { + expect(truncate('repeat'.repeat(1000))).toMatch(/\w\.\.\.$/) + }) + + it('accepts numbers', () => { + expect(truncate(123)).toEqual('123') + expect(truncate(0)).toEqual('0') + expect(truncate(0o000)).toEqual('0') + }) + + it('handles arguments of invalid type', () => { + // @ts-expect-error - Testing JS scenario + expect(truncate(false)).toEqual('false') + + expect(truncate(undefined)).toEqual('') + expect(truncate(null)).toEqual('') + }) +}) + +describe('jsonTruncate', () => { + it('truncates large json structures', () => { + expect( + jsonTruncate({ + foo: 'foo', + bar: 'bar', + baz: 'baz', + kittens: 'kittens meow', + bazinga: 'Sheldon', + nested: { + foobar: 'I have no imagination', + two: 'Second nested item', + }, + five: 5, + bool: false, + }) + ).toMatch(/.+\n.+\w\.\.\.$/s) + }) +}) + +describe('timeTag', () => { + it('renders a date', async () => { + render(
{timeTag(new Date('1970-08-20').toUTCString())}
) + + await waitFor(() => screen.getByText(/1970.*00:00:00/)) + }) + + it('can take an empty input string', async () => { + expect(timeTag('')).toEqual('') + }) +}) + +describe('jsonDisplay', () => { + it('produces the correct output', () => { + expect( + jsonDisplay({ + title: 'TOML Example (but in JSON)', + database: { + data: [['delta', 'phi'], [3.14]], + enabled: true, + ports: [8000, 8001, 8002], + temp_targets: { + case: 72.0, + cpu: 79.5, + }, + }, + owner: { + dob: '1979-05-27T07:32:00-08:00', + name: 'Tom Preston-Werner', + }, + servers: { + alpha: { + ip: '10.0.0.1', + role: 'frontend', + }, + beta: { + ip: '10.0.0.2', + role: 'backend', + }, + }, + }) + ).toMatchInlineSnapshot(` +
+        
+          {
+        "title": "TOML Example (but in JSON)",
+        "database": {
+          "data": [
+            [
+              "delta",
+              "phi"
+            ],
+            [
+              3.14
+            ]
+          ],
+          "enabled": true,
+          "ports": [
+            8000,
+            8001,
+            8002
+          ],
+          "temp_targets": {
+            "case": 72,
+            "cpu": 79.5
+          }
+        },
+        "owner": {
+          "dob": "1979-05-27T07:32:00-08:00",
+          "name": "Tom Preston-Werner"
+        },
+        "servers": {
+          "alpha": {
+            "ip": "10.0.0.1",
+            "role": "frontend"
+          },
+          "beta": {
+            "ip": "10.0.0.2",
+            "role": "backend"
+          }
+        }
+      }
+        
+      
+ `) + }) +}) + +describe('checkboxInputTag', () => { + it('can be checked', () => { + render(checkboxInputTag(true)) + expect(screen.getByRole('checkbox')).toBeChecked() + }) + + it('can be unchecked', () => { + render(checkboxInputTag(false)) + expect(screen.getByRole('checkbox')).not.toBeChecked() + }) + + it('is disabled when checked', () => { + render(checkboxInputTag(true)) + expect(screen.getByRole('checkbox')).toBeDisabled() + }) + + it('is disabled when unchecked', () => { + render(checkboxInputTag(false)) + expect(screen.getByRole('checkbox')).toBeDisabled() + }) +}) diff --git a/web/src/lib/formatters.tsx b/web/src/lib/formatters.tsx new file mode 100644 index 0000000..8ab9e80 --- /dev/null +++ b/web/src/lib/formatters.tsx @@ -0,0 +1,58 @@ +import React from 'react' + +import humanize from 'humanize-string' + +const MAX_STRING_LENGTH = 150 + +export const formatEnum = (values: string | string[] | null | undefined) => { + let output = '' + + if (Array.isArray(values)) { + const humanizedValues = values.map((value) => humanize(value)) + output = humanizedValues.join(', ') + } else if (typeof values === 'string') { + output = humanize(values) + } + + return output +} + +export const jsonDisplay = (obj: unknown) => { + return ( +
+      {JSON.stringify(obj, null, 2)}
+    
+ ) +} + +export const truncate = (value: string | number) => { + let output = value?.toString() ?? '' + + if (output.length > MAX_STRING_LENGTH) { + output = output.substring(0, MAX_STRING_LENGTH) + '...' + } + + return output +} + +export const jsonTruncate = (obj: unknown) => { + return truncate(JSON.stringify(obj, null, 2)) +} + +export const timeTag = (dateTime?: string) => { + let output: string | JSX.Element = '' + + if (dateTime) { + output = ( + + ) + } + + return output +} + +export const checkboxInputTag = (checked: boolean) => { + return +} diff --git a/web/src/pages/Post/EditPostPage/EditPostPage.tsx b/web/src/pages/Post/EditPostPage/EditPostPage.tsx new file mode 100644 index 0000000..f3f8c7b --- /dev/null +++ b/web/src/pages/Post/EditPostPage/EditPostPage.tsx @@ -0,0 +1,11 @@ +import EditPostCell from 'src/components/Post/EditPostCell' + +type PostPageProps = { + id: number +} + +const EditPostPage = ({ id }: PostPageProps) => { + return +} + +export default EditPostPage diff --git a/web/src/pages/Post/NewPostPage/NewPostPage.tsx b/web/src/pages/Post/NewPostPage/NewPostPage.tsx new file mode 100644 index 0000000..0b3c453 --- /dev/null +++ b/web/src/pages/Post/NewPostPage/NewPostPage.tsx @@ -0,0 +1,7 @@ +import NewPost from 'src/components/Post/NewPost' + +const NewPostPage = () => { + return +} + +export default NewPostPage diff --git a/web/src/pages/Post/PostPage/PostPage.tsx b/web/src/pages/Post/PostPage/PostPage.tsx new file mode 100644 index 0000000..ca40487 --- /dev/null +++ b/web/src/pages/Post/PostPage/PostPage.tsx @@ -0,0 +1,11 @@ +import PostCell from 'src/components/Post/PostCell' + +type PostPageProps = { + id: number +} + +const PostPage = ({ id }: PostPageProps) => { + return +} + +export default PostPage diff --git a/web/src/pages/Post/PostsPage/PostsPage.tsx b/web/src/pages/Post/PostsPage/PostsPage.tsx new file mode 100644 index 0000000..f5b3668 --- /dev/null +++ b/web/src/pages/Post/PostsPage/PostsPage.tsx @@ -0,0 +1,7 @@ +import PostsCell from 'src/components/Post/PostsCell' + +const PostsPage = () => { + return +} + +export default PostsPage diff --git a/yarn.lock b/yarn.lock index 4f202a0..3ec4137 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17108,6 +17108,7 @@ __metadata: "@redwoodjs/web-server": "npm:8.3.0" "@types/react": "npm:^18.2.55" "@types/react-dom": "npm:^18.2.19" + humanize-string: "npm:2.1.0" react: "npm:18.3.1" react-dom: "npm:18.3.1" languageName: unknown