├── database ├── .gitignore ├── boostr.config.private-template.mjs └── boostr.config.mjs ├── .prettierignore ├── assets └── logo.png ├── backend ├── jsconfig.json ├── boostr.config.private-template.mjs ├── src │ ├── index.js │ ├── components │ │ ├── application.js │ │ ├── comment.js │ │ ├── entity.js │ │ ├── with-author.js │ │ ├── article.js │ │ └── user.js │ └── jwt.js ├── package.json └── boostr.config.mjs ├── frontend ├── jsconfig.json ├── public │ └── layr-favicon-3vtu1VGUfUfDawVC0zL4Oz.immutable.png ├── package.json ├── src │ ├── index.js │ ├── ui.jsx │ └── components │ │ ├── comment.jsx │ │ ├── application.jsx │ │ ├── user.jsx │ │ └── article.jsx └── boostr.config.mjs ├── .vscode └── extensions.json ├── .gitignore ├── .editorconfig ├── package.json ├── boostr.config.mjs ├── docs └── comparison.md └── README.md /database/.gitignore: -------------------------------------------------------------------------------- 1 | /data 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/layrjs/react-layr-realworld-example-app/HEAD/assets/logo.png -------------------------------------------------------------------------------- /backend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "editorconfig.editorconfig"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | node_modules 4 | .npmrc 5 | dist 6 | build 7 | _private 8 | _old 9 | *.private.* 10 | -------------------------------------------------------------------------------- /database/boostr.config.private-template.mjs: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | stages: { 3 | production: { 4 | url: '********' 5 | } 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /frontend/public/layr-favicon-3vtu1VGUfUfDawVC0zL4Oz.immutable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/layrjs/react-layr-realworld-example-app/HEAD/frontend/public/layr-favicon-3vtu1VGUfUfDawVC0zL4Oz.immutable.png -------------------------------------------------------------------------------- /database/boostr.config.mjs: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | type: 'database', 3 | 4 | stages: { 5 | development: { 6 | url: 'mongodb://localhost:13579/dev', 7 | platform: 'local' 8 | } 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /backend/boostr.config.private-template.mjs: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | stages: { 3 | development: { 4 | environment: { 5 | JWT_SECRET: '********' 6 | } 7 | }, 8 | production: { 9 | environment: { 10 | JWT_SECRET: '********' 11 | } 12 | } 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /backend/src/index.js: -------------------------------------------------------------------------------- 1 | import {MongoDBStore} from '@layr/mongodb-store'; 2 | 3 | import {Application} from './components/application'; 4 | 5 | export default () => { 6 | const store = new MongoDBStore(process.env.DATABASE_URL); 7 | 8 | store.registerRootComponent(Application); 9 | 10 | return Application; 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-layr-realworld-example-app", 3 | "version": "1.0.0", 4 | "private": true, 5 | "author": "Manuel Vila ", 6 | "license": "MIT", 7 | "prettier": "@boostr/prettierrc", 8 | "devDependencies": { 9 | "@boostr/prettierrc": "^1.0.0", 10 | "boostr": "^2.0.60", 11 | "prettier": "^2.3.2" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/components/application.js: -------------------------------------------------------------------------------- 1 | import {Component, provide} from '@layr/component'; 2 | 3 | import {User} from './user'; 4 | import {Article} from './article'; 5 | import {Comment} from './comment'; 6 | 7 | export class Application extends Component { 8 | @provide() static User = User; 9 | @provide() static Article = Article; 10 | @provide() static Comment = Comment; 11 | } 12 | -------------------------------------------------------------------------------- /boostr.config.mjs: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | type: 'application', 3 | 4 | services: { 5 | frontend: './frontend', 6 | backend: './backend', 7 | database: './database' 8 | }, 9 | 10 | environment: { 11 | APPLICATION_NAME: 'Conduit', 12 | APPLICATION_DESCRIPTION: 'A place to share your knowledge.' 13 | }, 14 | 15 | stages: { 16 | production: { 17 | environment: { 18 | NODE_ENV: 'production' 19 | } 20 | } 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-layr-realworld-example-app-backend", 3 | "version": "1.0.0", 4 | "private": true, 5 | "author": "Manuel Vila ", 6 | "license": "MIT", 7 | "dependencies": { 8 | "@layr/component": "^2.0.23", 9 | "@layr/mongodb-store": "^2.0.33", 10 | "@layr/storable": "^2.0.36", 11 | "@layr/utilities": "^1.0.2", 12 | "@layr/with-roles": "^2.0.25", 13 | "bcryptjs": "^2.4.3", 14 | "jsonwebtoken": "^9.0.0", 15 | "slugify": "^1.6.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/jwt.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | 3 | // Tip: Use `openssl rand -hex 64` to generate a JWT secret 4 | 5 | const secretBuffer = Buffer.from(process.env.JWT_SECRET, 'hex'); 6 | const algorithm = 'HS256'; 7 | 8 | export function generateJWT(payload) { 9 | return jwt.sign(payload, secretBuffer, {algorithm}); 10 | } 11 | 12 | export function verifyJWT(token) { 13 | try { 14 | return jwt.verify(token, secretBuffer, {algorithms: [algorithm]}); 15 | } catch (err) { 16 | return undefined; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-layr-realworld-example-app-frontend", 3 | "version": "1.0.0", 4 | "private": true, 5 | "author": "Manuel Vila ", 6 | "license": "MIT", 7 | "dependencies": { 8 | "@layr/component": "^2.0.23", 9 | "@layr/component-http-client": "^2.0.29", 10 | "@layr/react-integration": "^2.0.89", 11 | "@layr/routable": "^2.0.72", 12 | "@layr/storable": "^2.0.36", 13 | "@layr/utilities": "^1.0.2", 14 | "classnames": "^2.3.1", 15 | "dompurify": "^2.3.1", 16 | "marked": "^4.2.5", 17 | "react": "^17.0.1", 18 | "react-dom": "^17.0.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import {ComponentHTTPClient} from '@layr/component-http-client'; 2 | import {Storable} from '@layr/storable'; 3 | 4 | import {extendApplication} from './components/application'; 5 | 6 | export default async () => { 7 | const client = new ComponentHTTPClient(process.env.BACKEND_URL, { 8 | mixins: [Storable], 9 | async retryFailedRequests() { 10 | return confirm('Sorry, a network error occurred. Would you like to retry?'); 11 | } 12 | }); 13 | 14 | const BackendApplication = await client.getComponent(); 15 | 16 | const Application = extendApplication(BackendApplication); 17 | 18 | return Application; 19 | }; 20 | -------------------------------------------------------------------------------- /backend/src/components/comment.js: -------------------------------------------------------------------------------- 1 | import {expose, validators} from '@layr/component'; 2 | import {attribute} from '@layr/storable'; 3 | 4 | import {Entity} from './entity'; 5 | import {WithAuthor} from './with-author'; 6 | 7 | const {rangeLength} = validators; 8 | 9 | @expose({ 10 | find: {call: true}, 11 | prototype: { 12 | load: {call: true}, 13 | save: {call: 'author'}, 14 | delete: {call: 'author'} 15 | } 16 | }) 17 | export class Comment extends WithAuthor(Entity) { 18 | @expose({get: true, set: 'author'}) @attribute('Article') article; 19 | 20 | @expose({get: true, set: 'author'}) 21 | @attribute('string', {validators: [rangeLength([1, 50000])]}) 22 | body = ''; 23 | } 24 | -------------------------------------------------------------------------------- /backend/boostr.config.mjs: -------------------------------------------------------------------------------- 1 | export default ({services}) => ({ 2 | type: 'backend', 3 | 4 | dependsOn: 'database', 5 | 6 | environment: { 7 | FRONTEND_URL: services.frontend.url, 8 | BACKEND_URL: services.backend.url, 9 | DATABASE_URL: services.database.url 10 | }, 11 | 12 | rootComponent: './src/index.js', 13 | 14 | stages: { 15 | development: { 16 | url: 'http://localhost:13578/', 17 | platform: 'local' 18 | }, 19 | production: { 20 | url: 'https://backend.react-layr-realworld-example-app.layrjs.com/', 21 | platform: 'aws', 22 | aws: { 23 | region: 'us-west-2', 24 | lambda: { 25 | memorySize: 1024, 26 | timeout: 15 27 | } 28 | } 29 | } 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /backend/src/components/entity.js: -------------------------------------------------------------------------------- 1 | import {Component, consume, expose} from '@layr/component'; 2 | import {Storable, primaryIdentifier, attribute, index} from '@layr/storable'; 3 | import {WithRoles, role} from '@layr/with-roles'; 4 | 5 | export class Entity extends WithRoles(Storable(Component)) { 6 | @consume() static User; 7 | 8 | @expose({get: true, set: true}) @primaryIdentifier() id; 9 | 10 | @expose({get: true}) @index() @attribute('Date') createdAt = new Date(); 11 | 12 | @attribute('Date?') updatedAt; 13 | 14 | @role('user') static async userRoleResolver() { 15 | return (await this.User.getAuthenticatedUser()) !== undefined; 16 | } 17 | 18 | @role('guest') static async guestRoleResolver() { 19 | return !(await this.resolveRole('user')); 20 | } 21 | 22 | async beforeSave() { 23 | await super.beforeSave(); 24 | 25 | this.updatedAt = new Date(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/components/with-author.js: -------------------------------------------------------------------------------- 1 | import {expose} from '@layr/component'; 2 | import {attribute, finder} from '@layr/storable'; 3 | import {role} from '@layr/with-roles'; 4 | 5 | export const WithAuthor = (Base) => { 6 | class WithAuthor extends Base { 7 | @expose({get: true, set: 'author'}) @attribute('User') author; 8 | 9 | @expose({get: 'user'}) 10 | @finder(async function () { 11 | const user = await this.constructor.User.getAuthenticatedUser(); 12 | 13 | await user.load({followedUsers: {}}); 14 | 15 | return {author: {$in: user.followedUsers}}; 16 | }) 17 | @attribute('boolean?') 18 | authorIsFollowedByAuthenticatedUser; 19 | 20 | @role('author') async authorRoleResolver() { 21 | if (await this.resolveRole('guest')) { 22 | return undefined; 23 | } 24 | 25 | if (this.isNew()) { 26 | return true; 27 | } 28 | 29 | await this.getGhost().load({author: {}}); 30 | 31 | return ( 32 | this.getGhost().author === (await this.constructor.User.getAuthenticatedUser()).getGhost() 33 | ); 34 | } 35 | } 36 | 37 | return WithAuthor; 38 | }; 39 | -------------------------------------------------------------------------------- /frontend/src/ui.jsx: -------------------------------------------------------------------------------- 1 | import React, {useMemo} from 'react'; 2 | import {useDelay} from '@layr/react-integration'; 3 | import {formatError} from '@layr/utilities'; 4 | 5 | export function ErrorMessage({children}) { 6 | const message = typeof children === 'string' ? children : formatError(children); 7 | 8 | return ( 9 |
10 |
{message}
11 |
12 | ); 13 | } 14 | 15 | export function LoadingSpinner({delay}) { 16 | const style = useMemo( 17 | () => ({ 18 | borderRadius: '50%', 19 | width: '40px', 20 | height: '40px', 21 | margin: '90px auto', 22 | position: 'relative', 23 | borderTop: '3px solid rgba(0, 0, 0, 0.1)', 24 | borderRight: '3px solid rgba(0, 0, 0, 0.1)', 25 | borderBottom: '3px solid rgba(0, 0, 0, 0.1)', 26 | borderLeft: '3px solid #818a91', 27 | transform: 'translateZ(0)', 28 | animation: 'loading-spinner 0.5s infinite linear' 29 | }), 30 | [] 31 | ); 32 | 33 | return ( 34 | 35 |
36 | 44 |
45 |
46 | ); 47 | } 48 | 49 | export function Delayed({duration = 500, children}) { 50 | const [isElapsed] = useDelay(duration); 51 | 52 | return isElapsed ? children : null; 53 | } 54 | -------------------------------------------------------------------------------- /docs/comparison.md: -------------------------------------------------------------------------------- 1 | ## Comparison of some RealWorld example apps in terms of the amount of code 2 | 3 | > LOC represents the number of line of code excluding comments and test suites. 4 | > Count done on June 24th, 2021 using [Tokei](https://github.com/XAMPPRocky/tokei). 5 | 6 | ### Frontends 7 | 8 | | Project | LOC | 9 | | --------------------------------------------------------------------------------- | :--: | 10 | | [Layr + React](https://github.com/layrjs/react-layr-realworld-example-app) | 1190 | 11 | | [React + Redux](https://github.com/khaledosman/react-redux-realworld-example-app) | 2045 | 12 | | [Angular](https://github.com/khaledosman/angular-realworld-example-app) | 2164 | 13 | | [Vue.js + Vite](https://github.com/mutoe/vue3-realworld-example-app) | 2409 | 14 | | [Next.js + SWR](https://github.com/gothinkster/react-mobx-realworld-example-app) | 2928 | 15 | 16 | ### Backends 17 | 18 | | Project | LOC | 19 | | ------------------------------------------------------------------------------------ | :--: | 20 | | [Layr](https://github.com/layrjs/react-layr-realworld-example-app) | 366 | 21 | | [Express + Sequelize](https://github.com/Varun-Hegde/Conduit_NodeJS) | 905 | 22 | | [NestJS + Mongoose + GraphQL](https://github.com/ramzitannous/medium-graphql-nestjs) | 1379 | 23 | | [Koa + Knex.js](https://github.com/gothinkster/koa-knex-realworld-example) | 1535 | 24 | | [Hapi + Mongoose](https://github.com/gothinkster/hapijs-realworld-example-app) | 1872 | 25 | -------------------------------------------------------------------------------- /frontend/boostr.config.mjs: -------------------------------------------------------------------------------- 1 | export default ({services}) => ({ 2 | type: 'web-frontend', 3 | 4 | dependsOn: 'backend', 5 | 6 | environment: { 7 | FRONTEND_URL: services.frontend.url, 8 | BACKEND_URL: services.backend.url 9 | }, 10 | 11 | rootComponent: './src/index.js', 12 | 13 | html: { 14 | language: 'en', 15 | head: { 16 | title: services.frontend.environment.APPLICATION_NAME, 17 | metas: [ 18 | {name: 'description', content: services.frontend.environment.APPLICATION_DESCRIPTION}, 19 | {charset: 'utf-8'}, 20 | {name: 'viewport', content: 'width=device-width, initial-scale=1'}, 21 | {'http-equiv': 'x-ua-compatible', 'content': 'ie=edge'} 22 | ], 23 | links: [ 24 | {rel: 'icon', href: '/layr-favicon-3vtu1VGUfUfDawVC0zL4Oz.immutable.png'}, 25 | { 26 | rel: 'stylesheet', 27 | href: 'https://fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic' 28 | }, 29 | { 30 | rel: 'stylesheet', 31 | href: 'https://code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css' 32 | }, 33 | { 34 | rel: 'stylesheet', 35 | href: 'https://demo.productionready.io/main.css' 36 | } 37 | ] 38 | } 39 | }, 40 | 41 | stages: { 42 | development: { 43 | url: 'http://localhost:13577/', 44 | platform: 'local' 45 | }, 46 | production: { 47 | url: 'https://react-layr-realworld-example-app.layrjs.com/', 48 | platform: 'aws', 49 | aws: { 50 | region: 'us-west-2', 51 | cloudFront: { 52 | priceClass: 'PriceClass_100' 53 | } 54 | } 55 | } 56 | } 57 | }); 58 | -------------------------------------------------------------------------------- /frontend/src/components/comment.jsx: -------------------------------------------------------------------------------- 1 | import {consume} from '@layr/component'; 2 | import {Routable} from '@layr/routable'; 3 | import React from 'react'; 4 | import {view} from '@layr/react-integration'; 5 | 6 | export const extendComment = (Base) => { 7 | class Comment extends Routable(Base) { 8 | @consume() static User; 9 | 10 | @view() ItemView({onDelete}) { 11 | const {User} = this.constructor; 12 | 13 | return ( 14 |
15 |
16 |

{this.body}

17 |
18 | 19 |
20 | 21 | 22 | 23 |   24 | 25 | {this.author.username} 26 | 27 | {this.createdAt.toDateString()} 28 | {this.author === User.authenticatedUser && ( 29 | 30 | 31 | 32 | )} 33 |
34 |
35 | ); 36 | } 37 | 38 | @view() FormView({onSubmit}) { 39 | return ( 40 |
{ 42 | event.preventDefault(); 43 | onSubmit(); 44 | }} 45 | autoComplete="off" 46 | className="card comment-form" 47 | > 48 |
49 | 457 | 458 | 459 |
460 | { 466 | this.email = event.target.value; 467 | }} 468 | autoComplete="off" 469 | required 470 | /> 471 |
472 | 473 |
474 | { 480 | const value = event.target.value; 481 | if (value) { 482 | this.password = value; 483 | } else { 484 | this.getAttribute('password').unsetValue(); 485 | } 486 | }} 487 | autoComplete="new-password" 488 | /> 489 |
490 | 491 | 494 | 495 | 496 | ); 497 | } 498 | } 499 | 500 | return User; 501 | }; 502 | -------------------------------------------------------------------------------- /frontend/src/components/article.jsx: -------------------------------------------------------------------------------- 1 | import {consume} from '@layr/component'; 2 | import {Routable} from '@layr/routable'; 3 | import React, {useState, useMemo, useCallback} from 'react'; 4 | import {page, view, useData, useAction, useNavigator} from '@layr/react-integration'; 5 | import {marked} from 'marked'; 6 | import DOMPurify from 'dompurify'; 7 | 8 | const PAGE_SIZE = 10; 9 | 10 | export const extendArticle = (Base) => { 11 | class Article extends Routable(Base) { 12 | @consume() static Application; 13 | @consume() static User; 14 | 15 | @page('[/]articles/:slug') ItemPage() { 16 | return useData( 17 | async () => { 18 | await this.load({ 19 | title: true, 20 | description: true, 21 | body: true, 22 | tags: true, 23 | slug: true, 24 | author: {username: true, imageURL: true}, 25 | createdAt: true 26 | }); 27 | }, 28 | 29 | () => { 30 | const bodyHTML = {__html: DOMPurify.sanitize(marked(this.body))}; 31 | 32 | return ( 33 |
34 |
35 |
36 |

{this.title}

37 | 38 | 39 | 40 |
41 |
42 | 43 |
44 |
45 |
46 |
47 | 48 |
49 |
50 | 51 |
52 | 53 |
54 | 55 |
56 | 57 |
58 |
59 |
60 | ); 61 | } 62 | ); 63 | } 64 | 65 | @view() ListItemView() { 66 | const {User} = this.constructor; 67 | 68 | const toggleFavorite = useAction(async () => { 69 | if (!User.authenticatedUser) { 70 | window.alert('To add an article to your favorites, please sign in.'); 71 | return; 72 | } 73 | 74 | if (!this.isFavoritedByAuthenticatedUser) { 75 | this.getAttribute('isFavoritedByAuthenticatedUser').setValue(true, {source: 'server'}); // Optimistic update 76 | await User.authenticatedUser.favorite(this); 77 | } else { 78 | this.getAttribute('isFavoritedByAuthenticatedUser').setValue(false, {source: 'server'}); // Optimistic update 79 | await User.authenticatedUser.unfavorite(this); 80 | } 81 | }); 82 | 83 | const favoriteButtonClass = this.isFavoritedByAuthenticatedUser 84 | ? 'btn btn-sm btn-primary' 85 | : 'btn btn-sm btn-outline-primary'; 86 | 87 | return ( 88 |
89 |
90 | 91 | 92 | 93 | 94 |
95 | 96 | {this.author.username} 97 | 98 | {this.createdAt.toDateString()} 99 |
100 | 101 |
102 | 111 |
112 |
113 | 114 | 115 |

{this.title}

116 |

{this.description}

117 | Read more... 118 | 119 |
120 |
121 | ); 122 | } 123 | 124 | @view() MetaView({children}) { 125 | return ( 126 |
127 | 128 | 129 | 130 | 131 |
132 | 133 | {this.author.username} 134 | 135 | {this.createdAt.toDateString()} 136 |
137 | 138 | {children} 139 |
140 | ); 141 | } 142 | 143 | @view() TagListView() { 144 | return ( 145 |
    146 | {this.tags.map((tag) => ( 147 |
  • 148 | {tag} 149 |
  • 150 | ))} 151 |
152 | ); 153 | } 154 | 155 | @view() ActionsView() { 156 | const {Application, User} = this.constructor; 157 | 158 | if (this.author !== User.authenticatedUser) { 159 | return null; 160 | } 161 | 162 | const edit = useAction(async () => { 163 | this.EditPage.navigate(); 164 | }); 165 | 166 | const delete_ = useAction(async () => { 167 | await this.delete(); 168 | Application.HomePage.navigate(); 169 | }); 170 | 171 | return ( 172 | 173 | 176 | 177 | 184 | 185 | ); 186 | } 187 | 188 | @view() CommentListView() { 189 | const {User, Comment} = this.constructor; 190 | 191 | return useData( 192 | async () => { 193 | const comments = await Comment.find( 194 | {article: this}, 195 | {body: true, author: {username: true, imageURL: true}, createdAt: true}, 196 | {sort: {createdAt: 'desc'}} 197 | ); 198 | 199 | const newComment = 200 | User.authenticatedUser && new Comment({author: User.authenticatedUser, article: this}); 201 | 202 | return {comments, newComment}; 203 | }, 204 | 205 | ({comments, newComment}, refresh) => ( 206 |
207 | {newComment ? ( 208 |
209 | { 211 | await newComment.save(); 212 | refresh(); 213 | }} 214 | /> 215 |
216 | ) : ( 217 |

218 | Sign in 219 |  or  220 | Sign up 221 |  to add comments on this article. 222 |

223 | )} 224 | 225 |
226 | {comments.map((comment) => { 227 | return ( 228 | { 231 | await comment.delete(); 232 | refresh(); 233 | }} 234 | /> 235 | ); 236 | })} 237 |
238 |
239 | ) 240 | ); 241 | } 242 | 243 | @page('[/]articles/add') static AddPage() { 244 | const {User} = this; 245 | 246 | return User.ensureAuthenticatedUser((authenticatedUser) => { 247 | const article = useMemo(() => new this({author: authenticatedUser})); 248 | 249 | const save = useAction(async () => { 250 | await article.save(); 251 | article.ItemPage.navigate(); 252 | }, [article]); 253 | 254 | return ; 255 | }); 256 | } 257 | 258 | @page('[/]articles/:slug/edit') EditPage() { 259 | return useData( 260 | async () => { 261 | await this.load({title: true, description: true, body: true, tags: true}); 262 | }, 263 | 264 | () => 265 | ); 266 | } 267 | 268 | @view() EditView() { 269 | const fork = useMemo(() => this.fork(), []); 270 | 271 | const save = useAction(async () => { 272 | await fork.save(); 273 | this.merge(fork); 274 | this.ItemPage.navigate(); 275 | }, [fork]); 276 | 277 | return ; 278 | } 279 | 280 | @view() FormView({onSubmit}) { 281 | const [tag, setTag] = useState(''); 282 | 283 | const addTag = useCallback(() => { 284 | const trimmedTag = tag.trim(); 285 | if (trimmedTag !== '') { 286 | if (!this.tags.includes(trimmedTag)) { 287 | this.tags = [...this.tags, trimmedTag]; 288 | } 289 | setTag(''); 290 | } 291 | }); 292 | 293 | const removeTag = useCallback((tagToRemove) => { 294 | this.tags = this.tags.filter((tag) => tag !== tagToRemove); 295 | }); 296 | 297 | const handleTagKeyDown = useCallback((event) => { 298 | const TAB = 9; 299 | const ENTER = 13; 300 | const COMMA = 188; 301 | 302 | const {keyCode} = event; 303 | 304 | if (keyCode === TAB || keyCode === ENTER || keyCode === COMMA) { 305 | if (keyCode !== TAB) { 306 | event.preventDefault(); 307 | } 308 | addTag(); 309 | } 310 | }); 311 | 312 | return ( 313 |
314 |
315 |
316 |
317 |
{ 319 | event.preventDefault(); 320 | onSubmit(); 321 | }} 322 | autoComplete="off" 323 | > 324 |
325 |
326 | { 332 | this.title = event.target.value; 333 | }} 334 | required 335 | /> 336 |
337 | 338 |
339 | { 345 | this.description = event.target.value; 346 | }} 347 | required 348 | /> 349 |
350 | 351 |
352 |