diff --git a/api/db/migrations/20220414233534_create_post/migration.sql b/api/db/migrations/20220414233534_create_post/migration.sql
new file mode 100644
index 0000000..26e7ce5
--- /dev/null
+++ b/api/db/migrations/20220414233534_create_post/migration.sql
@@ -0,0 +1,7 @@
+-- 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
+);
diff --git a/api/db/migrations/migration_lock.toml b/api/db/migrations/migration_lock.toml
new file mode 100644
index 0000000..e5e5c47
--- /dev/null
+++ b/api/db/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (i.e. Git)
+provider = "sqlite"
\ No newline at end of file
diff --git a/api/db/schema.prisma b/api/db/schema.prisma
index 3dea71a..3e7e700 100644
--- a/api/db/schema.prisma
+++ b/api/db/schema.prisma
@@ -8,11 +8,9 @@ generator client {
binaryTargets = "native"
}
-// Define your own datamodels here and run `yarn redwood prisma migrate dev`
-// to create migrations for them and apply to your dev DB.
-// TODO: Please remove the following example:
-model UserExample {
- id Int @id @default(autoincrement())
- email String @unique
- name String?
+model Post {
+ id Int @id @default(autoincrement())
+ title String
+ body String
+ createdAt DateTime @default(now())
}
diff --git a/api/src/graphql/posts.sdl.js b/api/src/graphql/posts.sdl.js
new file mode 100644
index 0000000..52a44a7
--- /dev/null
+++ b/api/src/graphql/posts.sdl.js
@@ -0,0 +1,29 @@
+export const schema = gql`
+ type Post {
+ id: Int!
+ title: String!
+ body: String!
+ createdAt: 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.js b/api/src/services/posts/posts.js
new file mode 100644
index 0000000..3d680c9
--- /dev/null
+++ b/api/src/services/posts/posts.js
@@ -0,0 +1,30 @@
+import { db } from 'src/lib/db'
+
+export const posts = () => {
+ return db.post.findMany()
+}
+
+export const post = ({ id }) => {
+ return db.post.findUnique({
+ where: { id },
+ })
+}
+
+export const createPost = ({ input }) => {
+ return db.post.create({
+ data: input,
+ })
+}
+
+export const updatePost = ({ id, input }) => {
+ return db.post.update({
+ data: input,
+ where: { id },
+ })
+}
+
+export const deletePost = ({ id }) => {
+ return db.post.delete({
+ where: { id },
+ })
+}
diff --git a/api/src/services/posts/posts.scenarios.js b/api/src/services/posts/posts.scenarios.js
new file mode 100644
index 0000000..8126153
--- /dev/null
+++ b/api/src/services/posts/posts.scenarios.js
@@ -0,0 +1,6 @@
+export const standard = defineScenario({
+ post: {
+ one: { data: { title: 'String', body: 'String' } },
+ two: { data: { title: 'String', body: 'String' } },
+ },
+})
diff --git a/api/src/services/posts/posts.test.js b/api/src/services/posts/posts.test.js
new file mode 100644
index 0000000..12eb27e
--- /dev/null
+++ b/api/src/services/posts/posts.test.js
@@ -0,0 +1,47 @@
+import { posts, post, createPost, updatePost, deletePost } from './posts'
+
+// Generated boilerplate tests do not account for all circumstances
+// and can fail without adjustments, e.g. Float and DateTime types.
+// 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) => {
+ const result = await posts()
+
+ expect(result.length).toEqual(Object.keys(scenario.post).length)
+ })
+
+ scenario('returns a single post', async (scenario) => {
+ 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' },
+ })
+
+ expect(result.title).toEqual('String')
+ expect(result.body).toEqual('String')
+ })
+
+ scenario('updates a post', async (scenario) => {
+ const original = await post({ id: scenario.post.one.id })
+ const result = await updatePost({
+ id: original.id,
+ input: { title: 'String2' },
+ })
+
+ expect(result.title).toEqual('String2')
+ })
+
+ scenario('deletes a post', async (scenario) => {
+ const original = await deletePost({ id: scenario.post.one.id })
+ const result = await post({ id: original.id })
+
+ expect(result).toEqual(null)
+ })
+})
diff --git a/web/src/App.js b/web/src/App.js
index 97fb5e0..5e7beac 100644
--- a/web/src/App.js
+++ b/web/src/App.js
@@ -4,6 +4,7 @@ import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
import FatalErrorPage from 'src/pages/FatalErrorPage'
import Routes from 'src/Routes'
+import './scaffold.css'
import './index.css'
const App = () => (
diff --git a/web/src/Routes.js b/web/src/Routes.js
index 2c8f02a..d72ab0c 100644
--- a/web/src/Routes.js
+++ b/web/src/Routes.js
@@ -7,12 +7,24 @@
// 'src/pages/HomePage/HomePage.js' -> HomePage
// 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage
-import { Router, Route } from '@redwoodjs/router'
+import { Router, Route, Set } from '@redwoodjs/router'
+import PostsLayout from 'src/layouts/PostsLayout'
+import BlogLayout from './layouts/BlogLayout/BlogLayout'
const Routes = () => {
return (
-
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/web/src/components/ArticlesCell/ArticlesCell.js b/web/src/components/ArticlesCell/ArticlesCell.js
new file mode 100644
index 0000000..1437f5d
--- /dev/null
+++ b/web/src/components/ArticlesCell/ArticlesCell.js
@@ -0,0 +1,34 @@
+export const QUERY = gql`
+ query ArticlesQuery {
+ articles: posts {
+ id
+ title
+ body
+ createdAt
+ }
+ }
+`
+
+export const Loading = () =>
Loading...
+
+export const Empty = () => Empty
+
+export const Failure = ({ error }) => (
+ Error: {error.message}
+)
+
+export const Success = ({ articles }) => {
+ return (
+
+ )
+}
diff --git a/web/src/components/ArticlesCell/ArticlesCell.mock.js b/web/src/components/ArticlesCell/ArticlesCell.mock.js
new file mode 100644
index 0000000..b2b3f1b
--- /dev/null
+++ b/web/src/components/ArticlesCell/ArticlesCell.mock.js
@@ -0,0 +1,4 @@
+// Define your own mock data here:
+export const standard = () => ({
+ articles: [{ id: 42 }, { id: 43 }, { id: 44 }],
+})
diff --git a/web/src/components/ArticlesCell/ArticlesCell.stories.js b/web/src/components/ArticlesCell/ArticlesCell.stories.js
new file mode 100644
index 0000000..a3134d3
--- /dev/null
+++ b/web/src/components/ArticlesCell/ArticlesCell.stories.js
@@ -0,0 +1,20 @@
+import { Loading, Empty, Failure, Success } from './ArticlesCell'
+import { standard } from './ArticlesCell.mock'
+
+export const loading = () => {
+ return Loading ? : null
+}
+
+export const empty = () => {
+ return Empty ? : null
+}
+
+export const failure = () => {
+ return Failure ? : null
+}
+
+export const success = () => {
+ return Success ? : null
+}
+
+export default { title: 'Cells/ArticlesCell' }
diff --git a/web/src/components/ArticlesCell/ArticlesCell.test.js b/web/src/components/ArticlesCell/ArticlesCell.test.js
new file mode 100644
index 0000000..5514f36
--- /dev/null
+++ b/web/src/components/ArticlesCell/ArticlesCell.test.js
@@ -0,0 +1,41 @@
+import { render } from '@redwoodjs/testing/web'
+import { Loading, Empty, Failure, Success } from './ArticlesCell'
+import { standard } from './ArticlesCell.mock'
+
+// Generated boilerplate tests do not account for all circumstances
+// and can fail without adjustments, e.g. Float and DateTime types.
+// Please refer to the RedwoodJS Testing Docs:
+// https://redwoodjs.com/docs/testing#testing-cells
+// https://redwoodjs.com/docs/testing#jest-expect-type-considerations
+
+describe('ArticlesCell', () => {
+ it('renders Loading successfully', () => {
+ expect(() => {
+ render()
+ }).not.toThrow()
+ })
+
+ it('renders Empty successfully', async () => {
+ expect(() => {
+ render()
+ }).not.toThrow()
+ })
+
+ it('renders Failure successfully', async () => {
+ expect(() => {
+ render()
+ }).not.toThrow()
+ })
+
+ // When you're ready to test the actual output of your component render
+ // you could test that, for example, certain text is present:
+ //
+ // 1. import { screen } from '@redwoodjs/testing/web'
+ // 2. Add test: expect(screen.getByText('Hello, world')).toBeInTheDocument()
+
+ it('renders Success successfully', async () => {
+ expect(() => {
+ render()
+ }).not.toThrow()
+ })
+})
diff --git a/web/src/components/Post/EditPostCell/EditPostCell.js b/web/src/components/Post/EditPostCell/EditPostCell.js
new file mode 100644
index 0000000..663b593
--- /dev/null
+++ b/web/src/components/Post/EditPostCell/EditPostCell.js
@@ -0,0 +1,59 @@
+import { useMutation } from '@redwoodjs/web'
+import { toast } from '@redwoodjs/web/toast'
+import { navigate, routes } from '@redwoodjs/router'
+
+import PostForm from 'src/components/Post/PostForm'
+
+export const QUERY = gql`
+ query EditPostById($id: Int!) {
+ post: post(id: $id) {
+ id
+ title
+ body
+ createdAt
+ }
+ }
+`
+const UPDATE_POST_MUTATION = gql`
+ mutation UpdatePostMutation($id: Int!, $input: UpdatePostInput!) {
+ updatePost(id: $id, input: $input) {
+ id
+ title
+ body
+ createdAt
+ }
+ }
+`
+
+export const Loading = () => Loading...
+
+export const Failure = ({ error }) => (
+ {error.message}
+)
+
+export const Success = ({ post }) => {
+ 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, id) => {
+ updatePost({ variables: { id, input } })
+ }
+
+ return (
+
+ )
+}
diff --git a/web/src/components/Post/NewPost/NewPost.js b/web/src/components/Post/NewPost/NewPost.js
new file mode 100644
index 0000000..c31b349
--- /dev/null
+++ b/web/src/components/Post/NewPost/NewPost.js
@@ -0,0 +1,41 @@
+import { useMutation } from '@redwoodjs/web'
+import { toast } from '@redwoodjs/web/toast'
+import { navigate, routes } from '@redwoodjs/router'
+import PostForm from 'src/components/Post/PostForm'
+
+const CREATE_POST_MUTATION = 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) => {
+ createPost({ variables: { input } })
+ }
+
+ return (
+
+ )
+}
+
+export default NewPost
diff --git a/web/src/components/Post/Post/Post.js b/web/src/components/Post/Post/Post.js
new file mode 100644
index 0000000..b1bf1fc
--- /dev/null
+++ b/web/src/components/Post/Post/Post.js
@@ -0,0 +1,113 @@
+import humanize from 'humanize-string'
+
+import { useMutation } from '@redwoodjs/web'
+import { toast } from '@redwoodjs/web/toast'
+import { Link, routes, navigate } from '@redwoodjs/router'
+
+const DELETE_POST_MUTATION = gql`
+ mutation DeletePostMutation($id: Int!) {
+ deletePost(id: $id) {
+ id
+ }
+ }
+`
+
+const formatEnum = (values) => {
+ if (values) {
+ if (Array.isArray(values)) {
+ const humanizedValues = values.map((value) => humanize(value))
+ return humanizedValues.join(', ')
+ } else {
+ return humanize(values)
+ }
+ }
+}
+
+const jsonDisplay = (obj) => {
+ return (
+
+ {JSON.stringify(obj, null, 2)}
+
+ )
+}
+
+const timeTag = (datetime) => {
+ return (
+ datetime && (
+
+ )
+ )
+}
+
+const checkboxInputTag = (checked) => {
+ return
+}
+
+const Post = ({ post }) => {
+ const [deletePost] = useMutation(DELETE_POST_MUTATION, {
+ onCompleted: () => {
+ toast.success('Post deleted')
+ navigate(routes.posts())
+ },
+ onError: (error) => {
+ toast.error(error.message)
+ },
+ })
+
+ const onDeleteClick = (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)} |
+
+
+
+
+
+ >
+ )
+}
+
+export default Post
diff --git a/web/src/components/Post/PostCell/PostCell.js b/web/src/components/Post/PostCell/PostCell.js
new file mode 100644
index 0000000..39674aa
--- /dev/null
+++ b/web/src/components/Post/PostCell/PostCell.js
@@ -0,0 +1,24 @@
+import Post from 'src/components/Post/Post'
+
+export const QUERY = gql`
+ query FindPostById($id: Int!) {
+ post: post(id: $id) {
+ id
+ title
+ body
+ createdAt
+ }
+ }
+`
+
+export const Loading = () => Loading...
+
+export const Empty = () => Post not found
+
+export const Failure = ({ error }) => (
+ {error.message}
+)
+
+export const Success = ({ post }) => {
+ return
+}
diff --git a/web/src/components/Post/PostForm/PostForm.js b/web/src/components/Post/PostForm/PostForm.js
new file mode 100644
index 0000000..250a307
--- /dev/null
+++ b/web/src/components/Post/PostForm/PostForm.js
@@ -0,0 +1,71 @@
+import {
+ Form,
+ FormError,
+ FieldError,
+ Label,
+ TextField,
+ Submit,
+} from '@redwoodjs/forms'
+
+const PostForm = (props) => {
+ const onSubmit = (data) => {
+ props.onSave(data, props?.post?.id)
+ }
+
+ return (
+
+ )
+}
+
+export default PostForm
diff --git a/web/src/components/Post/Posts/Posts.js b/web/src/components/Post/Posts/Posts.js
new file mode 100644
index 0000000..c0e2be4
--- /dev/null
+++ b/web/src/components/Post/Posts/Posts.js
@@ -0,0 +1,130 @@
+import humanize from 'humanize-string'
+
+import { useMutation } from '@redwoodjs/web'
+import { toast } from '@redwoodjs/web/toast'
+import { Link, routes } from '@redwoodjs/router'
+
+import { QUERY } from 'src/components/Post/PostsCell'
+
+const DELETE_POST_MUTATION = gql`
+ mutation DeletePostMutation($id: Int!) {
+ deletePost(id: $id) {
+ id
+ }
+ }
+`
+
+const MAX_STRING_LENGTH = 150
+
+const formatEnum = (values) => {
+ if (values) {
+ if (Array.isArray(values)) {
+ const humanizedValues = values.map((value) => humanize(value))
+ return humanizedValues.join(', ')
+ } else {
+ return humanize(values)
+ }
+ }
+}
+
+const truncate = (text) => {
+ let output = text
+ if (text && text.length > MAX_STRING_LENGTH) {
+ output = output.substring(0, MAX_STRING_LENGTH) + '...'
+ }
+ return output
+}
+
+const jsonTruncate = (obj) => {
+ return truncate(JSON.stringify(obj, null, 2))
+}
+
+const timeTag = (datetime) => {
+ return (
+ datetime && (
+
+ )
+ )
+}
+
+const checkboxInputTag = (checked) => {
+ return
+}
+
+const PostsList = ({ posts }) => {
+ 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) => {
+ if (confirm('Are you sure you want to delete post ' + id + '?')) {
+ deletePost({ variables: { id } })
+ }
+ }
+
+ return (
+
+
+
+
+ | Id |
+ Title |
+ Body |
+ Created at |
+ |
+
+
+
+ {posts.map((post) => (
+
+ | {truncate(post.id)} |
+ {truncate(post.title)} |
+ {truncate(post.body)} |
+ {timeTag(post.createdAt)} |
+
+
+ |
+
+ ))}
+
+
+
+ )
+}
+
+export default PostsList
diff --git a/web/src/components/Post/PostsCell/PostsCell.js b/web/src/components/Post/PostsCell/PostsCell.js
new file mode 100644
index 0000000..ff350d3
--- /dev/null
+++ b/web/src/components/Post/PostsCell/PostsCell.js
@@ -0,0 +1,35 @@
+import { Link, routes } from '@redwoodjs/router'
+
+import Posts from 'src/components/Post/Posts'
+
+export const QUERY = gql`
+ query FindPosts {
+ posts {
+ id
+ title
+ body
+ createdAt
+ }
+ }
+`
+
+export const Loading = () => Loading...
+
+export const Empty = () => {
+ return (
+
+ {'No posts yet. '}
+
+ {'Create one?'}
+
+
+ )
+}
+
+export const Failure = ({ error }) => (
+ {error.message}
+)
+
+export const Success = ({ posts }) => {
+ return
+}
diff --git a/web/src/index.css b/web/src/index.css
index e69de29..cebc98a 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -0,0 +1,50 @@
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
+}
+ul {
+ list-style-type: none;
+ margin: 1rem 0;
+ padding: 0;
+}
+li {
+ display: inline-block;
+ margin: 0 1rem 0 0 ;
+}
+h1 > a {
+ text-decoration: none;
+ color: black;
+}
+button, input, label, textarea {
+ display: block;
+ outline: none;
+}
+label {
+ margin-top: 1rem;
+}
+.error {
+ color: red;
+}
+input.error, textarea.error {
+ border: 1px solid red;
+}
+.form-error {
+ color: red;
+ background-color: lavenderblush;
+ padding: 1rem;
+ display: inline-block;
+}
+.form-error ul {
+ list-style-type: disc;
+ margin: 1rem;
+ padding: 1rem;
+}
+.form-error li {
+ display: list-item;
+}
+.flex-between {
+ display: flex;
+ justify-content: space-between;
+}
+.flex-between button {
+ display: inline;
+}
\ No newline at end of file
diff --git a/web/src/layouts/BlogLayout/BlogLayout.js b/web/src/layouts/BlogLayout/BlogLayout.js
new file mode 100644
index 0000000..103a755
--- /dev/null
+++ b/web/src/layouts/BlogLayout/BlogLayout.js
@@ -0,0 +1,23 @@
+import { Link, routes } from '@redwoodjs/router';
+
+const BlogLayout = ({ children }) => {
+ return <>
+
+
+ Colin's Redwood Blog
+
+
+
+ {children}>
+}
+
+export default BlogLayout
diff --git a/web/src/layouts/BlogLayout/BlogLayout.stories.js b/web/src/layouts/BlogLayout/BlogLayout.stories.js
new file mode 100644
index 0000000..4e249ec
--- /dev/null
+++ b/web/src/layouts/BlogLayout/BlogLayout.stories.js
@@ -0,0 +1,7 @@
+import BlogLayout from './BlogLayout'
+
+export const generated = () => {
+ return
+}
+
+export default { title: 'Layouts/BlogLayout' }
diff --git a/web/src/layouts/BlogLayout/BlogLayout.test.js b/web/src/layouts/BlogLayout/BlogLayout.test.js
new file mode 100644
index 0000000..f1ebed5
--- /dev/null
+++ b/web/src/layouts/BlogLayout/BlogLayout.test.js
@@ -0,0 +1,14 @@
+import { render } from '@redwoodjs/testing/web'
+
+import BlogLayout from './BlogLayout'
+
+// Improve this test with help from the Redwood Testing Doc:
+// https://redwoodjs.com/docs/testing#testing-pages-layouts
+
+describe('BlogLayout', () => {
+ it('renders successfully', () => {
+ expect(() => {
+ render()
+ }).not.toThrow()
+ })
+})
diff --git a/web/src/layouts/PostsLayout/PostsLayout.js b/web/src/layouts/PostsLayout/PostsLayout.js
new file mode 100644
index 0000000..bad7cb7
--- /dev/null
+++ b/web/src/layouts/PostsLayout/PostsLayout.js
@@ -0,0 +1,23 @@
+import { Link, routes } from '@redwoodjs/router'
+import { Toaster } from '@redwoodjs/web/toast'
+
+const PostsLayout = ({ children }) => {
+ return (
+
+
+
+
+
+ Posts
+
+
+
+ +
New Post
+
+
+
{children}
+
+ )
+}
+
+export default PostsLayout
diff --git a/web/src/pages/AboutPage/AboutPage.js b/web/src/pages/AboutPage/AboutPage.js
new file mode 100644
index 0000000..50a6e5d
--- /dev/null
+++ b/web/src/pages/AboutPage/AboutPage.js
@@ -0,0 +1,14 @@
+import { MetaTags } from '@redwoodjs/web'
+
+const AboutPage = () => {
+ return (
+ <>
+
+
+ This site was created to demonstrate my mastery of Redwood: Look on my works, ye mighty, and despair!
+
+ >
+ )
+}
+
+export default AboutPage
diff --git a/web/src/pages/AboutPage/AboutPage.stories.js b/web/src/pages/AboutPage/AboutPage.stories.js
new file mode 100644
index 0000000..3ca853b
--- /dev/null
+++ b/web/src/pages/AboutPage/AboutPage.stories.js
@@ -0,0 +1,7 @@
+import AboutPage from './AboutPage'
+
+export const generated = () => {
+ return
+}
+
+export default { title: 'Pages/AboutPage' }
diff --git a/web/src/pages/AboutPage/AboutPage.test.js b/web/src/pages/AboutPage/AboutPage.test.js
new file mode 100644
index 0000000..571b85e
--- /dev/null
+++ b/web/src/pages/AboutPage/AboutPage.test.js
@@ -0,0 +1,14 @@
+import { render } from '@redwoodjs/testing/web'
+
+import AboutPage from './AboutPage'
+
+// Improve this test with help from the Redwood Testing Doc:
+// https://redwoodjs.com/docs/testing#testing-pages-layouts
+
+describe('AboutPage', () => {
+ it('renders successfully', () => {
+ expect(() => {
+ render()
+ }).not.toThrow()
+ })
+})
diff --git a/web/src/pages/HomePage/HomePage.js b/web/src/pages/HomePage/HomePage.js
new file mode 100644
index 0000000..418ec64
--- /dev/null
+++ b/web/src/pages/HomePage/HomePage.js
@@ -0,0 +1,13 @@
+import { MetaTags } from '@redwoodjs/web'
+import ArticlesCell from 'src/components/ArticlesCell'
+
+const HomePage = () => {
+ return (
+ <>
+
+
+ >
+ )
+}
+
+export default HomePage
diff --git a/web/src/pages/HomePage/HomePage.stories.js b/web/src/pages/HomePage/HomePage.stories.js
new file mode 100644
index 0000000..7a14a5c
--- /dev/null
+++ b/web/src/pages/HomePage/HomePage.stories.js
@@ -0,0 +1,7 @@
+import HomePage from './HomePage'
+
+export const generated = () => {
+ return
+}
+
+export default { title: 'Pages/HomePage' }
diff --git a/web/src/pages/HomePage/HomePage.test.js b/web/src/pages/HomePage/HomePage.test.js
new file mode 100644
index 0000000..c684c7a
--- /dev/null
+++ b/web/src/pages/HomePage/HomePage.test.js
@@ -0,0 +1,14 @@
+import { render } from '@redwoodjs/testing/web'
+
+import HomePage from './HomePage'
+
+// Improve this test with help from the Redwood Testing Doc:
+// https://redwoodjs.com/docs/testing#testing-pages-layouts
+
+describe('HomePage', () => {
+ it('renders successfully', () => {
+ expect(() => {
+ render()
+ }).not.toThrow()
+ })
+})
diff --git a/web/src/pages/Post/EditPostPage/EditPostPage.js b/web/src/pages/Post/EditPostPage/EditPostPage.js
new file mode 100644
index 0000000..5147fe8
--- /dev/null
+++ b/web/src/pages/Post/EditPostPage/EditPostPage.js
@@ -0,0 +1,7 @@
+import EditPostCell from 'src/components/Post/EditPostCell'
+
+const EditPostPage = ({ id }) => {
+ return
+}
+
+export default EditPostPage
diff --git a/web/src/pages/Post/NewPostPage/NewPostPage.js b/web/src/pages/Post/NewPostPage/NewPostPage.js
new file mode 100644
index 0000000..0b3c453
--- /dev/null
+++ b/web/src/pages/Post/NewPostPage/NewPostPage.js
@@ -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.js b/web/src/pages/Post/PostPage/PostPage.js
new file mode 100644
index 0000000..70070fc
--- /dev/null
+++ b/web/src/pages/Post/PostPage/PostPage.js
@@ -0,0 +1,7 @@
+import PostCell from 'src/components/Post/PostCell'
+
+const PostPage = ({ id }) => {
+ return
+}
+
+export default PostPage
diff --git a/web/src/pages/Post/PostsPage/PostsPage.js b/web/src/pages/Post/PostsPage/PostsPage.js
new file mode 100644
index 0000000..f5b3668
--- /dev/null
+++ b/web/src/pages/Post/PostsPage/PostsPage.js
@@ -0,0 +1,7 @@
+import PostsCell from 'src/components/Post/PostsCell'
+
+const PostsPage = () => {
+ return
+}
+
+export default PostsPage
diff --git a/web/src/scaffold.css b/web/src/scaffold.css
new file mode 100644
index 0000000..30928ab
--- /dev/null
+++ b/web/src/scaffold.css
@@ -0,0 +1,366 @@
+/*
+ normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css
+*/
+
+.rw-scaffold *,
+.rw-scaffold ::after,
+.rw-scaffold ::before {
+ box-sizing: inherit;
+}
+.rw-scaffold main {
+ color: #4a5568;
+ display: block;
+}
+.rw-scaffold h1,
+.rw-scaffold h2 {
+ margin: 0;
+}
+.rw-scaffold a {
+ background-color: transparent;
+}
+.rw-scaffold ul {
+ margin: 0;
+ padding: 0;
+}
+.rw-scaffold input {
+ font-family: inherit;
+ font-size: 100%;
+ overflow: visible;
+}
+.rw-scaffold input:-ms-input-placeholder {
+ color: #a0aec0;
+}
+.rw-scaffold input::-ms-input-placeholder {
+ color: #a0aec0;
+}
+.rw-scaffold input::placeholder {
+ color: #a0aec0;
+}
+.rw-scaffold table {
+ border-collapse: collapse;
+}
+
+/*
+ Style
+*/
+
+.rw-scaffold,
+.rw-toast {
+ background-color: #fff;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+ 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
+ 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
+}
+.rw-header {
+ display: flex;
+ justify-content: space-between;
+ padding: 1rem 2rem 1rem 2rem;
+}
+.rw-main {
+ margin-left: 1rem;
+ margin-right: 1rem;
+ padding-bottom: 1rem;
+}
+.rw-segment {
+ border-radius: 0.5rem;
+ overflow: hidden;
+ width: 100%;
+}
+.rw-segment-header {
+ background-color: #e2e8f0;
+ color: #4a5568;
+ padding: 0.75rem 1rem;
+}
+.rw-segment-main {
+ background-color: #f7fafc;
+ padding: 1rem;
+}
+.rw-link {
+ color: #4299e1;
+ text-decoration: underline;
+}
+.rw-link:hover {
+ color: #2b6cb0;
+}
+.rw-forgot-link {
+ font-size: 0.75rem;
+ color: #a0aec0;
+ text-align: right;
+ margin-top: 0.1rem;
+}
+.rw-forgot-link:hover {
+ font-size: 0.75rem;
+ color: #4299e1;
+}
+.rw-heading {
+ font-weight: 600;
+}
+.rw-heading.rw-heading-primary {
+ font-size: 1.25rem;
+}
+.rw-heading.rw-heading-secondary {
+ font-size: 0.875rem;
+}
+.rw-heading .rw-link {
+ color: #4a5568;
+ text-decoration: none;
+}
+.rw-heading .rw-link:hover {
+ color: #1a202c;
+ text-decoration: underline;
+}
+.rw-cell-error {
+ font-size: 90%;
+ font-weight: 600;
+}
+.rw-form-wrapper {
+ box-sizing: border-box;
+ font-size: 0.875rem;
+ margin-top: -1rem;
+}
+.rw-cell-error,
+.rw-form-error-wrapper {
+ padding: 1rem;
+ background-color: #fff5f5;
+ color: #c53030;
+ border-width: 1px;
+ border-color: #feb2b2;
+ border-radius: 0.25rem;
+ margin: 1rem 0;
+}
+.rw-form-error-title {
+ margin-top: 0;
+ margin-bottom: 0;
+ font-weight: 600;
+}
+.rw-form-error-list {
+ margin-top: 0.5rem;
+ list-style-type: disc;
+ list-style-position: inside;
+}
+.rw-button {
+ border: none;
+ color: #718096;
+ cursor: pointer;
+ display: flex;
+ justify-content: center;
+ font-size: 0.75rem;
+ font-weight: 600;
+ padding: 0.25rem 1rem;
+ text-transform: uppercase;
+ text-decoration: none;
+ letter-spacing: 0.025em;
+ border-radius: 0.25rem;
+ line-height: 2;
+ border: 0;
+}
+.rw-button:hover {
+ background-color: #718096;
+ color: #fff;
+}
+.rw-button.rw-button-small {
+ font-size: 0.75rem;
+ border-radius: 0.125rem;
+ padding: 0.25rem 0.5rem;
+ line-height: inherit;
+}
+.rw-button.rw-button-green {
+ background-color: #48bb78;
+ color: #fff;
+}
+.rw-button.rw-button-green:hover {
+ background-color: #38a169;
+ color: #fff;
+}
+.rw-button.rw-button-blue {
+ background-color: #3182ce;
+ color: #fff;
+}
+.rw-button.rw-button-blue:hover {
+ background-color: #2b6cb0;
+}
+.rw-button.rw-button-red {
+ background-color: #e53e3e;
+ color: #fff;
+}
+.rw-button.rw-button-red:hover {
+ background-color: #c53030;
+}
+.rw-button-icon {
+ font-size: 1.25rem;
+ line-height: 1;
+ margin-right: 0.25rem;
+}
+.rw-button-group {
+ display: flex;
+ justify-content: center;
+ margin: 0.75rem 0.5rem;
+}
+.rw-button-group .rw-button {
+ margin: 0 0.25rem;
+}
+.rw-form-wrapper .rw-button-group {
+ margin-top: 2rem;
+ margin-bottom: 0;
+}
+.rw-label {
+ display: block;
+ margin-top: 1.5rem;
+ color: #4a5568;
+ font-weight: 600;
+}
+.rw-label.rw-label-error {
+ color: #c53030;
+}
+.rw-input {
+ display: block;
+ margin-top: 0.5rem;
+ width: 100%;
+ padding: 0.5rem;
+ border-width: 1px;
+ border-style: solid;
+ border-color: #e2e8f0;
+ color: #4a5568;
+ border-radius: 0.25rem;
+ outline: none;
+}
+.rw-check-radio-item-none {
+ color: #4a5568;
+}
+.rw-check-radio-items {
+ display: flex;
+ justify-items: center;
+}
+.rw-input[type='checkbox'] {
+ display: inline;
+ width: 1rem;
+ margin-left: 0;
+ margin-right: 0.5rem;
+ margin-top: 0.25rem;
+}
+.rw-input[type='radio'] {
+ display: inline;
+ width: 1rem;
+ margin-left: 0;
+ margin-right: 0.5rem;
+ margin-top: 0.25rem;
+}
+.rw-input:focus {
+ border-color: #a0aec0;
+}
+.rw-input-error {
+ border-color: #c53030;
+ color: #c53030;
+}
+
+.rw-input-error:focus {
+ outline: none;
+ border-color: #c53030;
+ box-shadow: 0 0 5px #c53030;
+}
+
+.rw-field-error {
+ display: block;
+ margin-top: 0.25rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ font-size: 0.75rem;
+ color: #c53030;
+}
+.rw-table-wrapper-responsive {
+ overflow-x: scroll;
+}
+.rw-table-wrapper-responsive .rw-table {
+ min-width: 48rem;
+}
+.rw-table {
+ table-layout: auto;
+ width: 100%;
+ font-size: 0.875rem;
+}
+.rw-table th,
+.rw-table td {
+ padding: 0.75rem;
+}
+.rw-table td {
+ background-color: #ffffff;
+ color: #1a202c;
+}
+.rw-table thead tr {
+ background-color: #e2e8f0;
+ color: #4a5568;
+}
+.rw-table th {
+ font-weight: 600;
+ text-align: left;
+}
+.rw-table thead th {
+ text-align: left;
+}
+.rw-table tbody th {
+ text-align: right;
+}
+@media (min-width: 768px) {
+ .rw-table tbody th {
+ width: 20%;
+ }
+}
+.rw-table tbody tr {
+ background-color: #f7fafc;
+ border-top-width: 1px;
+}
+.rw-table tbody tr:nth-child(even) {
+ background-color: #fff;
+}
+.rw-table input {
+ margin-left: 0;
+}
+.rw-table-actions {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ height: 17px;
+ padding-right: 0.25rem;
+}
+.rw-table-actions .rw-button {
+ background-color: transparent;
+}
+.rw-table-actions .rw-button:hover {
+ background-color: #718096;
+ color: #fff;
+}
+.rw-table-actions .rw-button-blue {
+ color: #3182ce;
+}
+.rw-table-actions .rw-button-blue:hover {
+ background-color: #3182ce;
+ color: #fff;
+}
+.rw-table-actions .rw-button-red {
+ color: #e53e3e;
+}
+.rw-table-actions .rw-button-red:hover {
+ background-color: #e53e3e;
+ color: #fff;
+}
+.rw-text-center {
+ text-align: center;
+}
+.rw-login-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24rem;
+ margin: 4rem auto;
+ flex-wrap: wrap;
+}
+.rw-login-container .rw-form-wrapper {
+ width: 100%;
+}
+.rw-login-link {
+ margin-top: 1rem;
+ color: #4a5568;
+ font-size: 90%;
+ text-align: center;
+ flex-basis: 100%;
+}