├── tests ├── functional │ ├── all.ts │ └── main.ts └── unit │ ├── all.ts │ └── widgets │ ├── all.ts │ └── Tags.ts ├── src ├── main.css ├── config.ts ├── logo.png ├── main.tsx ├── widgets │ ├── Banner.tsx │ ├── Footer.tsx │ ├── ErrorList.tsx │ ├── Tags.tsx │ ├── ArticleAuthorControls.tsx │ ├── FeedPagination.tsx │ ├── FeedList.tsx │ ├── ArticleControls.tsx │ ├── Comment.tsx │ ├── ArticlePreview.tsx │ ├── ArticleMeta.tsx │ ├── Header.tsx │ ├── Login.tsx │ ├── Register.tsx │ ├── Home.tsx │ ├── Settings.tsx │ ├── Editor.tsx │ ├── Profile.tsx │ └── Article.tsx ├── processes │ ├── utils.ts │ ├── tagProcesses.ts │ ├── routeProcesses.ts │ ├── interfaces.d.ts │ ├── settingsProcesses.ts │ ├── loginProcesses.ts │ ├── profileProcesses.ts │ ├── feedProcesses.ts │ ├── editorProcesses.ts │ └── articleProcesses.ts ├── routes.ts ├── index.html ├── App.tsx ├── store.ts └── interfaces.d.ts ├── logo.png ├── .gitignore ├── .eslintrc.json ├── lighthouserc.json ├── firebase.json ├── .github └── workflows │ ├── merge-greetings.yml │ └── ci-cd.yml ├── tsconfig.json ├── LICENSE ├── package.json ├── .dojorc ├── README.md ├── tslint.json ├── CODE_OF_CONDUCT.md └── .firebase └── hosting.0.cache /tests/functional/all.ts: -------------------------------------------------------------------------------- 1 | import './main'; 2 | -------------------------------------------------------------------------------- /tests/unit/all.ts: -------------------------------------------------------------------------------- 1 | import './widgets/all'; 2 | -------------------------------------------------------------------------------- /tests/unit/widgets/all.ts: -------------------------------------------------------------------------------- 1 | import './Tags'; 2 | -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | /* Put your styles and imports here */ 2 | -------------------------------------------------------------------------------- /tests/functional/main.ts: -------------------------------------------------------------------------------- 1 | /* Write your app tests here */ 2 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const baseUrl = 'https://api.realworld.io/api'; 2 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/dojo-realworld-example-app/HEAD/logo.png -------------------------------------------------------------------------------- /src/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/dojo-realworld-example-app/HEAD/src/logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | coverage-final.lcov 4 | _build/ 5 | .vscode/ 6 | dist/ 7 | output/ 8 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { renderer, tsx } from "@dojo/framework/core/vdom"; 2 | import { registry } from "./store"; 3 | 4 | import { App } from "./App"; 5 | 6 | const r = renderer(() => ); 7 | r.mount({ domNode: document.getElementById("app")!, registry }); 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "parser": "@typescript-eslint/parser", 7 | "parserOptions": { 8 | "ecmaVersion": 13, 9 | "sourceType": "module" 10 | }, 11 | "plugins": [ 12 | "@typescript-eslint" 13 | ], 14 | "rules": { 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lighthouserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ci": { 3 | "collect": { 4 | "numberOfRuns": 5 5 | }, 6 | "assert": { 7 | "assertions": { 8 | "categories:performance": ["warn", {"minScore": 0.9}], 9 | "categories:accessibility": ["error", {"minScore": 0.7}] 10 | } 11 | }, 12 | "upload": {}, 13 | "server": {}, 14 | "wizard": {} 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/widgets/Banner.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | 3 | const factory = create(); 4 | 5 | export const Banner = factory(function Banner() { 6 | return ( 7 |
8 |
9 |

conduit

10 |

A place to share your knowledge.

11 |
12 |
13 | ); 14 | }); 15 | 16 | export default Banner; 17 | -------------------------------------------------------------------------------- /src/processes/utils.ts: -------------------------------------------------------------------------------- 1 | import { createCommandFactory } from '@dojo/framework/stores/process'; 2 | import { State } from '../interfaces'; 3 | 4 | export function getHeaders(token?: string): any { 5 | const headers: { [key: string]: string } = { 6 | 'Content-Type': 'application/json' 7 | }; 8 | if (token) { 9 | headers['Authorization'] = `Token ${token}`; 10 | } 11 | return headers; 12 | } 13 | 14 | export const commandFactory = createCommandFactory(); 15 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "output/dist", 4 | "headers": [ 5 | { 6 | "source" : "**/*.@(svg|css|js|jpg|jpeg|gif|png)", 7 | "headers" : [ 8 | { 9 | "key" : "Cache-Control", 10 | "value" : "max-age=30672000" 11 | } 12 | ] 13 | } 14 | ], 15 | "ignore": [ 16 | "firebase.json", 17 | "**/.*", 18 | "**/node_modules/**" 19 | ], 20 | "rewrites": [ 21 | { 22 | "source": "**", 23 | "destination": "/index.html" 24 | } 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/processes/tagProcesses.ts: -------------------------------------------------------------------------------- 1 | import { createProcess } from '@dojo/framework/stores/process'; 2 | import { replace } from '@dojo/framework/stores/state/operations'; 3 | import { commandFactory } from './utils'; 4 | import { baseUrl } from '../config'; 5 | 6 | const getTagsCommand = commandFactory(async ({ path }) => { 7 | const response = await fetch(`${baseUrl}/tags`); 8 | const json = await response.json(); 9 | 10 | return [replace(path('tags'), json.tags)]; 11 | }); 12 | 13 | export const getTagsProcess = createProcess('get-tags', [getTagsCommand]); 14 | -------------------------------------------------------------------------------- /.github/workflows/merge-greetings.yml: -------------------------------------------------------------------------------- 1 | name: Merge greetings 2 | 3 | on: 4 | pull_request: 5 | types: closed 6 | 7 | jobs: 8 | contribution-greetings: 9 | if: github.event.pull_request.merged 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: greet the contributor 13 | uses: kerhub/saved-replies@v1.0.0 14 | with: 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | reply: | 17 | Thanks @${{ github.event.pull_request.user.login }}! 18 | 19 | Your contribution is now fully part of this project :rocket: 20 | -------------------------------------------------------------------------------- /src/widgets/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | 3 | const factory = create(); 4 | 5 | export const Footer = factory(function Footer() { 6 | return ( 7 | 18 | ); 19 | }); 20 | 21 | export default Footer; 22 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | path: 'login', 4 | outlet: 'login' 5 | }, 6 | { 7 | path: 'register', 8 | outlet: 'register' 9 | }, 10 | { 11 | path: 'user/{username}', 12 | outlet: 'user' 13 | }, 14 | { 15 | path: 'user/{username}/favorites', 16 | outlet: 'favorites' 17 | }, 18 | { 19 | path: 'article/{slug}', 20 | outlet: 'article' 21 | }, 22 | { 23 | path: 'settings', 24 | outlet: 'settings' 25 | }, 26 | { 27 | path: 'editor', 28 | outlet: 'new-post', 29 | children: [ 30 | { 31 | path: 'editor/{slug}', 32 | outlet: 'edit-post' 33 | } 34 | ] 35 | }, 36 | { 37 | path: 'home', 38 | outlet: 'home', 39 | defaultRoute: true 40 | } 41 | ]; 42 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dojo-realworld 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": false, 4 | "experimentalDecorators": true, 5 | "jsx": "react", 6 | "jsxFactory": "tsx", 7 | "lib": [ 8 | "dom", 9 | "es5", 10 | "es2015.promise", 11 | "es2015.iterable", 12 | "es2015.symbol", 13 | "es2015.symbol.wellknown" 14 | ], 15 | "module": "umd", 16 | "moduleResolution": "node", 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "outDir": "_build/", 20 | "removeComments": false, 21 | "importHelpers": true, 22 | "downlevelIteration": true, 23 | "sourceMap": true, 24 | "strict": true, 25 | "target": "es5", 26 | "types": [ "intern" ] 27 | }, 28 | "include": [ 29 | "./src/**/*.ts", 30 | "./tests/**/*.ts" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /src/widgets/ErrorList.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | import { Errors } from "../interfaces"; 3 | 4 | interface ErrorListProperties { 5 | errors: Errors; 6 | } 7 | 8 | const factory = create({}).properties(); 9 | 10 | export const ErrorList = factory(function ErrorList({ properties }) { 11 | const { errors } = properties(); 12 | const errorCategories = Object.keys(errors); 13 | let errorList: string[] = []; 14 | for (let i = 0; i < errorCategories.length; i++) { 15 | errorList = [ 16 | ...errorList, 17 | ...errors[errorCategories[i]].map((error: string) => `${errorCategories[i]} ${error}`) 18 | ]; 19 | } 20 | errorList; 21 | 22 | return ( 23 |
    24 | {errorList.map((error: string) => ( 25 |
  • {error}
  • 26 | ))} 27 |
28 | ); 29 | }); 30 | 31 | export default ErrorList; 32 | -------------------------------------------------------------------------------- /src/widgets/Tags.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | import { fetchFeedProcess } from "../processes/feedProcesses"; 3 | import store from "../store"; 4 | 5 | const factory = create({ store }); 6 | 7 | export const Tags = factory(function Tags({ middleware: { store } }) { 8 | const { get, path, executor } = store; 9 | const tags = get(path("tags")) || []; 10 | 11 | return ( 12 |
13 |
14 |

Popular Tags

15 | 30 |
31 |
32 | ); 33 | }); 34 | 35 | export default Tags; 36 | -------------------------------------------------------------------------------- /src/widgets/ArticleAuthorControls.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | import { Link } from "@dojo/framework/routing/Link"; 3 | import { SlugPayload } from "../processes/interfaces"; 4 | 5 | interface ArticleAuthorControlsProperties { 6 | slug: string; 7 | deleteArticle: (opts: SlugPayload) => void; 8 | } 9 | 10 | const factory = create({}).properties(); 11 | 12 | export const ArticleAuthorControls = factory(function ArticleAuthorControls({ properties }) { 13 | const { slug, deleteArticle } = properties(); 14 | return ( 15 | 16 | 17 | Edit Article 18 | 19 | 27 | 28 | ); 29 | }); 30 | 31 | export default ArticleAuthorControls; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Thinkster 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/processes/routeProcesses.ts: -------------------------------------------------------------------------------- 1 | import { createProcess } from '@dojo/framework/stores/process'; 2 | import { replace } from '@dojo/framework/stores/state/operations'; 3 | import { commandFactory } from './utils'; 4 | import { ChangeRoutePayload } from './interfaces'; 5 | 6 | function isProfile(currentOutlet: string, outlet: string) { 7 | const outlets = ['user', 'favorites']; 8 | return outlets.indexOf(currentOutlet) > -1 && outlets.indexOf(outlet) > -1; 9 | } 10 | 11 | const changeRouteCommand = commandFactory(({ get, path, payload: { outlet, context } }) => { 12 | const currentOutlet = get(path('routing', 'outlet')); 13 | return [ 14 | replace(path('routing', 'outlet'), outlet), 15 | replace(path('routing', 'params'), context.params), 16 | replace(path('settings'), undefined), 17 | replace(path('editor'), undefined), 18 | isProfile(currentOutlet, outlet) ? replace(path('profile', 'feed'), {}) : replace(path('profile'), undefined), 19 | replace(path('feed'), undefined), 20 | replace(path('errors'), {}) 21 | ]; 22 | }); 23 | export const changeRouteProcess = createProcess('change-route', [changeRouteCommand]); 24 | -------------------------------------------------------------------------------- /src/widgets/FeedPagination.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | import { DNode } from "@dojo/framework/core/interfaces"; 3 | 4 | interface FeedPaginationProperties { 5 | total: number; 6 | currentPage: number; 7 | fetchFeed: (page: number) => void; 8 | } 9 | 10 | const factory = create({}).properties(); 11 | 12 | export const FeedPagination = factory(function({ properties }) { 13 | const { total, currentPage, fetchFeed } = properties(); 14 | 15 | let pageNumbers: DNode[] = []; 16 | for (let page = 0; page < Math.ceil(total / 10); page++) { 17 | const isActive = currentPage === page; 18 | const onclick = (event: MouseEvent) => { 19 | event.preventDefault(); 20 | if (page !== currentPage) { 21 | fetchFeed(page); 22 | } 23 | }; 24 | pageNumbers.push( 25 |
  • 26 | 27 | {`${page + 1}`} 28 | 29 |
  • 30 | ); 31 | } 32 | 33 | return ( 34 | 37 | ); 38 | }); 39 | 40 | export default FeedPagination; 41 | -------------------------------------------------------------------------------- /src/widgets/FeedList.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | import store from "../store"; 3 | 4 | import { favoriteFeedArticleProcess } from "./../processes/feedProcesses"; 5 | import { ArticlePreview } from "./ArticlePreview"; 6 | import { ArticleItem } from "../interfaces"; 7 | 8 | interface FeedListProperties { 9 | type: string; 10 | articles: ArticleItem[]; 11 | } 12 | 13 | const factory = create({ store }).properties(); 14 | 15 | export const FeedList = factory(function Tab({ middleware: { store }, properties }) { 16 | const { executor } = store; 17 | const { articles, type } = properties(); 18 | if (articles.length) { 19 | return ( 20 |
    21 | {articles.map((article) => ( 22 | { 26 | executor(favoriteFeedArticleProcess)({ 27 | slug: article.slug, 28 | favorited: article.favorited, 29 | type 30 | }); 31 | }} 32 | /> 33 | ))} 34 |
    35 | ); 36 | } 37 | 38 | return
    No articles here, yet!
    ; 39 | }); 40 | 41 | export default FeedList; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dojo-realworld", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "precommit": "lint-staged", 6 | "prettier": "prettier --write 'src/**/*.ts' 'tests/**/*.ts'", 7 | "test": "dojo build -m test && dojo test", 8 | "build": "dojo build", 9 | "dev": "dojo build -m dev -w -s" 10 | }, 11 | "dependencies": { 12 | "@dojo/framework": "^6.0.0", 13 | "snarkdown": "^1.2.2", 14 | "tslib": "1.10.0" 15 | }, 16 | "devDependencies": { 17 | "@dojo/cli": "^6.0.0", 18 | "@dojo/cli-build-app": "^6.0.0", 19 | "@dojo/cli-test-intern": "^6.0.0", 20 | "@types/sinon": "^1.16.35", 21 | "@typescript-eslint/eslint-plugin": "^5.6.0", 22 | "@typescript-eslint/parser": "^5.6.0", 23 | "eslint": "^8.4.1", 24 | "husky": "0.14.3", 25 | "lint-staged": "6.0.0", 26 | "prettier": "1.9.2", 27 | "sinon": "^2.0.0", 28 | "typescript": "~3.4.5" 29 | }, 30 | "lint-staged": { 31 | "*.{ts,tsx}": [ 32 | "prettier --write", 33 | "git add" 34 | ] 35 | }, 36 | "prettier": { 37 | "singleQuote": true, 38 | "tabWidth": 4, 39 | "useTabs": true, 40 | "parser": "typescript", 41 | "printWidth": 120, 42 | "arrowParens": "always" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.dojorc: -------------------------------------------------------------------------------- 1 | { 2 | "build-app": { 3 | "pwa": { 4 | "manifest": { 5 | "name": "dojo-realworld", 6 | "short_name": "dojo-realworld", 7 | "description": "dojo-realworld", 8 | "background_color": "#ffffff", 9 | "theme_color": "#222127", 10 | "icons": [ 11 | { 12 | "src": "src/logo.png", 13 | "sizes": [ 14 | 128, 15 | 256, 16 | 512 17 | ] 18 | } 19 | ] 20 | }, 21 | "serviceWorker": { 22 | "clientsClaim": true, 23 | "routes": [ 24 | { 25 | "urlPattern": "https://fonts.googleapis.com/css", 26 | "strategy": "networkFirst", 27 | "expiration": { 28 | "maxEntries": 25 29 | } 30 | }, 31 | { 32 | "urlPattern": "https://code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css", 33 | "strategy": "networkFirst", 34 | "expiration": { 35 | "maxEntries": 25 36 | } 37 | }, 38 | { 39 | "urlPattern": "https://demo.productionready.io", 40 | "strategy": "networkFirst", 41 | "expiration": { 42 | "maxEntries": 25 43 | } 44 | }, 45 | { 46 | "urlPattern": "https://conduit.productionready.io/api", 47 | "strategy": "networkFirst", 48 | "expiration": { 49 | "maxEntries": 25 50 | } 51 | } 52 | ] 53 | } 54 | }, 55 | "legacy": false 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/widgets/ArticleControls.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | import { FollowUserPayload, FavoriteArticlePayload } from "../processes/interfaces"; 3 | 4 | interface ArticleControlsProperties { 5 | slug: string; 6 | favoritesCount: number; 7 | authorUsername: string; 8 | favorited: boolean; 9 | following: boolean; 10 | followUser: (opts: FollowUserPayload) => void; 11 | favoriteArticle: (opts: FavoriteArticlePayload) => void; 12 | } 13 | 14 | const factory = create({}).properties(); 15 | 16 | export const ArticleControls = factory(function ArticleControls({ properties }) { 17 | const { favoritesCount, slug, favoriteArticle, favorited, following, authorUsername, followUser } = properties(); 18 | return ( 19 | 20 | 29 | 39 | 40 | ); 41 | }); 42 | 43 | export default ArticleControls; 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Dojo Example App](logo.png) 2 | 3 | > ### Dojo codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://dojo-2-realworld.firebaseapp.com) spec and API. 4 | 5 | ### [Demo](https://dojo-realworld.netlify.app)    [RealWorld](https://github.com/gothinkster/realworld) 6 | 7 | This codebase was created to demonstrate a fully fledged full-stack progressive web application built with Dojo including automatic code splitting by route, CRUD operations, authentication, routing, pagination, build time rendering and more. 8 | 9 | We've gone to great lengths to adhere to the Dojo community style guides & best practices. 10 | 11 | For more information on how to this works with other front-ends/back-ends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repository. 12 | 13 | # How it works 14 | 15 | Dojo RealWorld using @dojo/framework and @dojo/cli-build-app. 16 | 17 | For more information on Dojo visit [dojo.io](https://dojo.io) 18 | 19 | # Getting started 20 | 21 | You can view a live demo over at https://dojo-2-realworld.firebaseapp.com 22 | 23 | To get the frontend running locally: 24 | 25 | - Clone this repository 26 | - `npm install` to install all required dependencies 27 | - `npm run dev` to start the [local server](http://localhost:9999) with watch and hot reload 28 | 29 | The local web server uses port 9999. 30 | 31 | For a production build of the application: 32 | 33 | - `npm run build` to build the output into the `output/dist` directory. 34 | 35 | To run the tests run `npm run test` 36 | -------------------------------------------------------------------------------- /src/widgets/Comment.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | import Link from "@dojo/framework/routing/Link"; 3 | import { DeleteCommentPayload } from "../processes/interfaces"; 4 | import { Comment as CommentItem } from "./../interfaces"; 5 | 6 | interface CommentProperties { 7 | slug: string; 8 | comment: CommentItem; 9 | loggedInUser: string; 10 | deleteComment: (opts: DeleteCommentPayload) => void; 11 | } 12 | 13 | const factory = create({}).properties(); 14 | 15 | export const Comment = factory(function Comment({ properties }) { 16 | const { slug, comment, loggedInUser, deleteComment } = properties(); 17 | return ( 18 |
    19 |
    20 |

    {comment.body}

    21 |
    22 |
    23 | 24 | 25 | 26 | 27 | {` ${comment.author.username}`} 28 | 29 |
    {new Date(comment.createdAt).toDateString()}
    30 | {loggedInUser === comment.author.username && ( 31 |
    32 | { 34 | deleteComment({ slug, id: comment.id }); 35 | }} 36 | classes={["ion-trash-a"]} 37 | > 38 |
    39 | )} 40 |
    41 |
    42 | ); 43 | }); 44 | 45 | export default Comment; 46 | -------------------------------------------------------------------------------- /src/processes/interfaces.d.ts: -------------------------------------------------------------------------------- 1 | import { Session } from '../interfaces'; 2 | import { OutletContext } from '@dojo/framework/routing/interfaces'; 3 | 4 | export interface SlugPayload { 5 | slug: string; 6 | } 7 | 8 | export interface TitlePayload { 9 | title: string; 10 | } 11 | 12 | export interface DescriptionPayload { 13 | description: string; 14 | } 15 | 16 | export interface BodyPayload { 17 | body: string; 18 | } 19 | 20 | export interface TagPayload { 21 | tag: string; 22 | } 23 | 24 | export interface BioPayload { 25 | bio: string; 26 | } 27 | 28 | export interface ImagePayload { 29 | imageUrl: string; 30 | } 31 | 32 | export interface EmailPayload { 33 | email: string; 34 | } 35 | 36 | export interface UsernamePayload { 37 | username: string; 38 | } 39 | 40 | export interface PasswordPayload { 41 | password: string; 42 | } 43 | 44 | export interface FollowUserPayload { 45 | username: string; 46 | following: boolean; 47 | slug?: string; 48 | } 49 | 50 | export interface FavoriteArticlePayload extends SlugPayload { 51 | favorited: boolean; 52 | type?: string; 53 | } 54 | 55 | export interface NewCommentPayload { 56 | newComment: string; 57 | slug: string; 58 | } 59 | 60 | export interface AddCommentPayload extends SlugPayload, NewCommentPayload {} 61 | 62 | export interface DeleteCommentPayload extends SlugPayload { 63 | id: number; 64 | } 65 | 66 | export interface FetchFeedPayload { 67 | type: string; 68 | filter: string; 69 | page: number; 70 | } 71 | 72 | export interface SetSessionPayload { 73 | session: Session; 74 | } 75 | 76 | export interface ChangeRoutePayload { 77 | outlet: string; 78 | context: OutletContext; 79 | } 80 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | import { Outlet } from "@dojo/framework/routing/Outlet"; 3 | 4 | import Header from "./widgets/Header"; 5 | import Settings from "./widgets/Settings"; 6 | import Login from "./widgets/Login"; 7 | import Register from "./widgets/Register"; 8 | import Profile from "./widgets/Profile"; 9 | import Editor from "./widgets/Editor"; 10 | import Article from "./widgets/Article"; 11 | import Home from "./widgets/Home"; 12 | import Footer from "./widgets/Footer"; 13 | 14 | const factory = create(); 15 | 16 | export const App = factory(function App() { 17 | return ( 18 |
    19 |
    20 | } /> 21 | } /> 22 | { 25 | if (details.isExact()) { 26 | return ; 27 | } 28 | }} 29 | /> 30 | } 33 | /> 34 | } /> 35 | { 38 | if (details.isExact()) { 39 | return ; 40 | } 41 | }} 42 | /> 43 |
    } /> 44 | } /> 45 | } /> 46 |
    47 |
    48 | ); 49 | }); 50 | 51 | export default App; 52 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import has from '@dojo/framework/core/has'; 2 | import global from '@dojo/framework/shim/global'; 3 | import { createStoreMiddleware } from '@dojo/framework/core/middleware/store'; 4 | import { registerRouterInjector } from '@dojo/framework/routing/RouterInjector'; 5 | 6 | import { getTagsProcess } from './processes/tagProcesses'; 7 | import { setSessionProcess } from './processes/loginProcesses'; 8 | import { State } from './interfaces'; 9 | import Store from '@dojo/framework/stores/Store'; 10 | import config from './routes'; 11 | import Registry from '@dojo/framework/core/Registry'; 12 | import { changeRouteProcess } from './processes/routeProcesses'; 13 | 14 | export const registry = new Registry(); 15 | const router = registerRouterInjector(config, registry); 16 | 17 | const store = createStoreMiddleware((store: Store) => { 18 | let session: any; 19 | if (!has('build-time-render') && global.sessionStorage) { 20 | session = global.sessionStorage.getItem('conduit-session'); 21 | } 22 | if (session) { 23 | setSessionProcess(store)({ session: JSON.parse(session) }); 24 | } 25 | getTagsProcess(store)({}); 26 | router.on('nav', ({ outlet, context }: any) => { 27 | changeRouteProcess(store)({ outlet, context }); 28 | }); 29 | 30 | function onRouteChange() { 31 | const outlet = store.get(store.path('routing', 'outlet')); 32 | const params = store.get(store.path('routing', 'params')); 33 | if (outlet) { 34 | const link = router.link(outlet, params); 35 | if (link !== undefined) { 36 | router.setPath(link); 37 | } 38 | } 39 | } 40 | 41 | store.onChange(store.path('routing', 'outlet'), onRouteChange); 42 | store.onChange(store.path('routing', 'params'), onRouteChange); 43 | }); 44 | 45 | export default store; 46 | -------------------------------------------------------------------------------- /src/widgets/ArticlePreview.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | import { Link } from "@dojo/framework/routing/Link"; 3 | 4 | import { ArticleItem } from "../interfaces"; 5 | 6 | export interface ArticlePreviewProperties { 7 | article: ArticleItem; 8 | favoriteArticle: () => void; 9 | } 10 | 11 | const factory = create({}).properties(); 12 | 13 | export const ArticlePreview = factory(function ArticlePreview({ properties }) { 14 | const { 15 | favoriteArticle, 16 | article, 17 | article: { author, favorited, slug } 18 | } = properties(); 19 | 20 | let buttonClasses = ["btn", "btn-outline-primary", "btn-sm", "pull-xs-right"]; 21 | if (favorited) { 22 | buttonClasses = ["btn", "btn-primary", "btn-sm", "pull-xs-right"]; 23 | } 24 | 25 | return ( 26 |
    27 |
    28 | 29 | 30 | 31 |
    32 | 33 | {author.username} 34 | 35 | {new Date(article.createdAt).toDateString()} 36 |
    37 | 46 |
    47 | 48 |

    {article.title}

    49 |

    {article.description}

    50 | Read more... 51 | 52 |
    53 | ); 54 | }); 55 | 56 | export default ArticlePreview; 57 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "align": false, 4 | "ban": [], 5 | "class-name": true, 6 | "comment-format": [ true, "check-space" ], 7 | "curly": true, 8 | "eofline": true, 9 | "forin": false, 10 | "indent": [ true, "tabs" ], 11 | "interface-name": [ true, "never-prefix" ], 12 | "jsdoc-format": true, 13 | "label-position": true, 14 | "max-line-length": 120, 15 | "member-access": false, 16 | "member-ordering": false, 17 | "no-any": false, 18 | "no-arg": true, 19 | "no-bitwise": false, 20 | "no-consecutive-blank-lines": true, 21 | "no-console": false, 22 | "no-construct": false, 23 | "no-debugger": true, 24 | "no-duplicate-variable": true, 25 | "no-empty": false, 26 | "no-eval": true, 27 | "no-inferrable-types": [ true, "ignore-params" ], 28 | "no-shadowed-variable": false, 29 | "no-string-literal": false, 30 | "no-switch-case-fall-through": false, 31 | "no-trailing-whitespace": true, 32 | "no-unused-expression": false, 33 | "no-use-before-declare": false, 34 | "no-var-keyword": true, 35 | "no-var-requires": false, 36 | "object-literal-sort-keys": false, 37 | "one-line": [ true, "check-open-brace", "check-whitespace" ], 38 | "radix": true, 39 | "trailing-comma": [ true, { 40 | "multiline": "never", 41 | "singleline": "never" 42 | } ], 43 | "triple-equals": [ true, "allow-null-check" ], 44 | "typedef": false, 45 | "typedef-whitespace": [ true, { 46 | "call-signature": "nospace", 47 | "index-signature": "nospace", 48 | "parameter": "nospace", 49 | "property-declaration": "nospace", 50 | "variable-declaration": "nospace" 51 | }, { 52 | "call-signature": "onespace", 53 | "index-signature": "onespace", 54 | "parameter": "onespace", 55 | "property-declaration": "onespace", 56 | "variable-declaration": "onespace" 57 | } ], 58 | "variable-name": [ true, "check-format", "allow-leading-underscore", "ban-keywords", "allow-pascal-case" ] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/widgets/ArticleMeta.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | import { Link } from "@dojo/framework/routing/Link"; 3 | import { ArticleItem } from "../interfaces"; 4 | import { ArticleControls } from "./ArticleControls"; 5 | import { ArticleAuthorControls } from "./ArticleAuthorControls"; 6 | import { FavoriteArticlePayload, FollowUserPayload, SlugPayload } from "../processes/interfaces"; 7 | 8 | interface ArticleMetaProperties { 9 | currentUser: string; 10 | article: ArticleItem; 11 | isAuthenticated: boolean; 12 | favoriteArticle: (opts: FavoriteArticlePayload) => void; 13 | followUser: (opts: FollowUserPayload) => void; 14 | deleteArticle: (opts: SlugPayload) => void; 15 | } 16 | 17 | const factory = create({}).properties(); 18 | 19 | export const ArticleMeta = factory(function ArticleMeta({ properties }) { 20 | const { article, isAuthenticated, currentUser, favoriteArticle, followUser, deleteArticle } = properties(); 21 | const { author, slug, createdAt, favorited, favoritesCount } = article; 22 | 23 | return ( 24 |
    25 | 26 | 27 | 28 |
    29 | 30 | {author.username} 31 | 32 | {new Date(createdAt).toDateString()} 33 |
    34 | {isAuthenticated && 35 | (currentUser === author.username ? ( 36 | 37 | ) : ( 38 | 47 | ))} 48 |
    49 | ); 50 | }); 51 | 52 | export default ArticleMeta; 53 | -------------------------------------------------------------------------------- /tests/unit/widgets/Tags.ts: -------------------------------------------------------------------------------- 1 | const { describe, it } = intern.getInterface('bdd'); 2 | const { assert } = intern.getPlugin('chai'); 3 | import { stub } from 'sinon'; 4 | 5 | import { v, w } from '@dojo/framework/core/vdom'; 6 | import harness from '@dojo/framework/testing/harness'; 7 | import createMockStoreMiddleware from '@dojo/framework/testing/mocks/middleware/store'; 8 | 9 | import { fetchFeedProcess } from '../../../src/processes/feedProcesses'; 10 | import { Tags } from './../../../src/widgets/Tags'; 11 | import store from './../../../src/store'; 12 | import { replace } from '@dojo/framework/stores/state/operations'; 13 | 14 | const mockEvent = { 15 | preventDefault() {} 16 | }; 17 | 18 | describe('tags widget', () => { 19 | it('no tags', () => { 20 | const fetchFeed = stub(); 21 | const mockStore = createMockStoreMiddleware([[fetchFeedProcess, fetchFeed]]); 22 | const h = harness(() => w(Tags, {}), { middleware: [[store, mockStore]] }); 23 | const expected = v('div', { classes: ['col-md-3'] }, [ 24 | v('div', { classes: ['sidebar'] }, [v('p', ['Popular Tags']), v('div', { classes: ['tag-list'] }, [])]) 25 | ]); 26 | 27 | h.expect(() => expected); 28 | }); 29 | 30 | it('with tags', () => { 31 | const fetchFeed = stub(); 32 | const mockStore = createMockStoreMiddleware([[fetchFeedProcess, fetchFeed]]); 33 | const h = harness(() => w(Tags, {}), { middleware: [[store, mockStore]] }); 34 | mockStore((path) => [replace(path('tags'), ['first', 'second'])]); 35 | h.expect(() => 36 | v('div', { classes: ['col-md-3'] }, [ 37 | v('div', { classes: ['sidebar'] }, [ 38 | v('p', ['Popular Tags']), 39 | v('div', { classes: ['tag-list'] }, [ 40 | v('a', { href: '', onclick: () => {}, key: '0', classes: ['tag-pill', 'tag-default'] }, [ 41 | 'first' 42 | ]), 43 | v('a', { href: '', onclick: () => {}, key: '1', classes: ['tag-pill', 'tag-default'] }, [ 44 | 'second' 45 | ]) 46 | ]) 47 | ]) 48 | ]) 49 | ); 50 | 51 | h.trigger('@0', 'onclick', mockEvent); 52 | assert.isTrue(fetchFeed.calledOnce); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/widgets/Header.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | import ActiveLink from "@dojo/framework/routing/ActiveLink"; 3 | import store from "../store"; 4 | 5 | const factory = create({ store }).properties(); 6 | 7 | export const Header = factory(function Factory({ middleware: { store } }) { 8 | const { get, path } = store; 9 | const isAuthenticated = !!get(path("session", "token")); 10 | const loggedInUser = get(path("session", "username")); 11 | 12 | return ( 13 | 68 | ); 69 | }); 70 | 71 | export default Header; 72 | -------------------------------------------------------------------------------- /src/interfaces.d.ts: -------------------------------------------------------------------------------- 1 | export type WithTarget = T & { target: E }; 2 | 3 | export interface ResourceBased { 4 | isLoaded: boolean; 5 | isLoading: boolean; 6 | } 7 | 8 | export interface User { 9 | username: string; 10 | bio: string; 11 | image: string; 12 | } 13 | 14 | export interface AuthorProfile extends User { 15 | following: boolean; 16 | feed: Feed; 17 | } 18 | 19 | export interface Session extends User, ResourceBased { 20 | email: string; 21 | token: string; 22 | } 23 | 24 | export interface Comment { 25 | id: number; 26 | createdAt: string; 27 | updatedAt: string; 28 | body: string; 29 | author: AuthorProfile; 30 | } 31 | 32 | export interface Routing { 33 | outlet: string; 34 | params: { [index: string]: string }; 35 | } 36 | 37 | export interface ArticleItem { 38 | slug: string; 39 | title: string; 40 | description: string; 41 | body: string; 42 | tagList: string[]; 43 | createdAt: string; 44 | updatedAt: string; 45 | favorited: boolean; 46 | favoritesCount: number; 47 | author: AuthorProfile; 48 | } 49 | 50 | export interface Settings extends User, ResourceBased { 51 | email: string; 52 | password?: string; 53 | } 54 | 55 | export interface Article extends ResourceBased { 56 | item: ArticleItem; 57 | comments: Comment[]; 58 | newComment: string; 59 | } 60 | 61 | export interface Feed extends ResourceBased { 62 | category: string; 63 | tagName: string; 64 | filter: string; 65 | items: ArticleItem[]; 66 | offset: number; 67 | page: number; 68 | total: number; 69 | } 70 | 71 | export interface Errors { 72 | [index: string]: string[]; 73 | } 74 | 75 | export interface Login extends ResourceBased { 76 | email: string; 77 | password: string; 78 | failed: boolean; 79 | } 80 | 81 | export interface Register extends ResourceBased { 82 | username: string; 83 | password: string; 84 | email: string; 85 | failed: boolean; 86 | } 87 | 88 | export interface Editor extends ResourceBased { 89 | slug: string; 90 | title: string; 91 | description: string; 92 | body: string; 93 | tag: string; 94 | tagList: string[]; 95 | } 96 | 97 | export interface Profile { 98 | user: AuthorProfile & ResourceBased; 99 | feed: Feed; 100 | } 101 | 102 | interface State { 103 | settings: Settings; 104 | article: { 105 | [index: string]: Article; 106 | }; 107 | feed: Feed; 108 | session: Session; 109 | profile: Profile; 110 | routing: Routing; 111 | tags: string[]; 112 | errors: Errors; 113 | login: ResourceBased; 114 | register: ResourceBased; 115 | editor: Editor; 116 | } 117 | -------------------------------------------------------------------------------- /src/widgets/Login.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | import icache from "@dojo/framework/core/middleware/icache"; 3 | import Link from "@dojo/framework/routing/Link"; 4 | 5 | import store from "../store"; 6 | import { loginProcess } from "../processes/loginProcesses"; 7 | import ErrorList from "./ErrorList"; 8 | 9 | const factory = create({ icache, store }); 10 | 11 | export const Login = factory(function Login({ middleware: { store, icache } }) { 12 | const { get, path, executor } = store; 13 | const isLoading = get(path("login", "isLoading")); 14 | const errors = get(path("errors")); 15 | const email = icache.get("email") || ""; 16 | const password = icache.get("password") || ""; 17 | function setEmail(event: KeyboardEvent) { 18 | const target = event.target as HTMLInputElement; 19 | icache.set("email", target.value); 20 | } 21 | function setPassword(event: KeyboardEvent) { 22 | const target = event.target as HTMLInputElement; 23 | icache.set("password", target.value); 24 | } 25 | 26 | return ( 27 |
    28 |
    29 |
    30 |
    31 |

    Sign In

    32 |

    33 | Need an account? 34 |

    35 | {errors && } 36 |
    { 38 | event.preventDefault(); 39 | executor(loginProcess)({ email, password }); 40 | }} 41 | > 42 |
    43 |
    44 | 52 |
    53 |
    54 | 62 |
    63 |
    64 | 71 |
    72 |
    73 |
    74 |
    75 |
    76 | ); 77 | }); 78 | 79 | export default Login; 80 | -------------------------------------------------------------------------------- /src/processes/settingsProcesses.ts: -------------------------------------------------------------------------------- 1 | import { createProcess } from '@dojo/framework/stores/process'; 2 | import { replace } from '@dojo/framework/stores/state/operations'; 3 | import { getHeaders, commandFactory } from './utils'; 4 | import { baseUrl } from '../config'; 5 | import { EmailPayload, PasswordPayload, UsernamePayload, ImagePayload, BioPayload } from './interfaces'; 6 | 7 | const emailInputCommand = commandFactory(({ payload: { email }, path }) => { 8 | return [replace(path('settings', 'email'), email)]; 9 | }); 10 | 11 | const passwordInputCommand = commandFactory(({ payload: { password }, path }) => { 12 | return [replace(path('settings', 'password'), password)]; 13 | }); 14 | 15 | const usernameInputCommand = commandFactory(({ payload: { username }, path }) => { 16 | return [replace(path('settings', 'username'), username)]; 17 | }); 18 | 19 | const bioInputCommand = commandFactory(({ payload: { bio }, path }) => { 20 | return [replace(path('settings', 'bio'), bio)]; 21 | }); 22 | 23 | const imageUrlInputCommand = commandFactory(({ payload: { imageUrl }, path }) => { 24 | return [replace(path('settings', 'image'), imageUrl)]; 25 | }); 26 | 27 | const startUserSettingsCommand = commandFactory(({ path }) => { 28 | return [replace(path('settings'), { isLoaded: false, isLoading: false })]; 29 | }); 30 | 31 | const getUserSettingsCommand = commandFactory(({ path, get }) => { 32 | return [replace(path('settings'), get(path('session')))]; 33 | }); 34 | 35 | const updateUserSettingsCommand = commandFactory(async ({ path, get }) => { 36 | const token = get(path('session', 'token')); 37 | const requestPayload = get(path('settings')); 38 | const response = await fetch(`${baseUrl}/user`, { 39 | method: 'put', 40 | headers: getHeaders(token), 41 | body: JSON.stringify(requestPayload) 42 | }); 43 | 44 | const json = await response.json(); 45 | 46 | return [ 47 | replace(path('session'), json.user), 48 | replace(path('settings'), undefined), 49 | replace(path('routing', 'outlet'), 'user'), 50 | replace(path('routing', 'params'), { username: get(path('settings', 'username')) }) 51 | ]; 52 | }); 53 | 54 | export const getUserSettingsProcess = createProcess('user-settings', [ 55 | startUserSettingsCommand, 56 | getUserSettingsCommand 57 | ]); 58 | export const updateUserSettingsProcess = createProcess('update-user-settings', [updateUserSettingsCommand]); 59 | export const usernameInputProcess = createProcess('username-input', [usernameInputCommand]); 60 | export const emailInputProcess = createProcess('email-input', [emailInputCommand]); 61 | export const passwordInputProcess = createProcess('password-input', [passwordInputCommand]); 62 | export const bioInputProcess = createProcess('bio-input', [bioInputCommand]); 63 | export const imageUrlInputProcess = createProcess('image-url-input', [imageUrlInputCommand]); 64 | -------------------------------------------------------------------------------- /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: CI - CD 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: 8 | - '**' 9 | 10 | jobs: 11 | first_interaction: 12 | if: github.event_name == 'pull_request' 13 | name: 'first interaction' 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/first-interaction@v1 17 | with: 18 | repo-token: ${{ secrets.GITHUB_TOKEN }} 19 | pr-message: | 20 | Thanks for your first pull request on this project! 21 | 22 | This is a kindly reminder to read the following resources: 23 | - [code of conduct]() 24 | - [contribution guidelines]() 25 | 26 | It'll help us to review your contribution and to ensure it's aligned with our standards. 27 | greetings: 28 | if: github.event_name == 'pull_request' 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: kerhub/saved-replies@v1.0.0 32 | with: 33 | token: "${{ secrets.GITHUB_TOKEN }}" 34 | reply: | 35 | Hi @${{ github.event.pull_request.user.login }}, thanks for being part of the community :heart: 36 | We'll review your contribution as soon as possible! 37 | prettier: 38 | uses: kerhub/reusable-workflows/.github/workflows/node-job.yml@main 39 | with: 40 | command: npm run prettier --check \"**\" 41 | linter: 42 | uses: kerhub/reusable-workflows/.github/workflows/node-job.yml@main 43 | with: 44 | command: npx eslint --fix src/**/*.ts 45 | unit_tests: 46 | name: 'unit tests' 47 | uses: kerhub/reusable-workflows/.github/workflows/node-job.yml@main 48 | with: 49 | command: npm run test 50 | deploy_preview: 51 | name: 'deploy preview' 52 | if: github.event_name == 'pull_request' 53 | needs: [prettier, linter, unit_tests] 54 | uses: kerhub/reusable-workflows/.github/workflows/netlify-preview-deploy.yml@main 55 | with: 56 | build_directory: './output/dist' 57 | secrets: 58 | netlifyAuthToken: "${{ secrets.NETLIFY_AUTH_TOKEN }}" 59 | netlifySiteId: "${{ secrets.NETLIFY_SITE_ID }}" 60 | repoToken: "${{ secrets.GITHUB_TOKEN }}" 61 | deploy_live: 62 | name: 'deploy live' 63 | if: github.event_name == 'push' 64 | needs: [prettier, linter, unit_tests] 65 | uses: kerhub/reusable-workflows/.github/workflows/netlify-live-deploy.yml@main 66 | with: 67 | build_directory: './output/dist' 68 | secrets: 69 | netlifyAuthToken: "${{ secrets.NETLIFY_AUTH_TOKEN }}" 70 | netlifySiteId: "${{ secrets.NETLIFY_SITE_ID }}" 71 | lighthouse_preview: 72 | name: 'lighthouse preview' 73 | needs: deploy_preview 74 | uses: kerhub/reusable-workflows/.github/workflows/lighthouse-preview.yml@main 75 | with: 76 | siteName: 'dojo-realworld' 77 | secrets: 78 | netlifyAuthToken: "${{ secrets.NETLIFY_AUTH_TOKEN }}" 79 | lighthouse_live: 80 | name: 'lighthouse live' 81 | needs: deploy_live 82 | uses: kerhub/reusable-workflows/.github/workflows/lighthouse-live.yml@main 83 | with: 84 | siteUrl: 'https://dojo-realworld.netlify.app/' 85 | -------------------------------------------------------------------------------- /src/widgets/Register.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | import icache from "@dojo/framework/core/middleware/icache"; 3 | import Link from "@dojo/framework/routing/Link"; 4 | 5 | import store from "../store"; 6 | import { registerProcess } from "../processes/loginProcesses"; 7 | import ErrorList from "./ErrorList"; 8 | 9 | const factory = create({ icache, store }); 10 | 11 | export const Register = factory(function Register({ middleware: { store, icache } }) { 12 | const { get, path, executor } = store; 13 | const isLoading = get(path("register", "isLoading")); 14 | const errors = get(path("errors")); 15 | const email = icache.get("email") || ""; 16 | const username = icache.get("username") || ""; 17 | const password = icache.get("password") || ""; 18 | function setEmail(event: KeyboardEvent) { 19 | const target = event.target as HTMLInputElement; 20 | icache.set("email", target.value); 21 | } 22 | function setPassword(event: KeyboardEvent) { 23 | const target = event.target as HTMLInputElement; 24 | icache.set("password", target.value); 25 | } 26 | function setUsername(event: KeyboardEvent) { 27 | const target = event.target as HTMLInputElement; 28 | icache.set("username", target.value); 29 | } 30 | 31 | return ( 32 |
    33 |
    34 |
    35 |
    36 |

    Sign In

    37 |

    38 | Have an account? 39 |

    40 | {errors && } 41 |
    { 43 | event.preventDefault(); 44 | executor(registerProcess)({ username, email, password }); 45 | }} 46 | > 47 |
    48 |
    49 | 56 |
    57 |
    58 | 66 |
    67 |
    68 | 76 |
    77 |
    78 | 85 |
    86 |
    87 |
    88 |
    89 |
    90 | ); 91 | }); 92 | 93 | export default Register; 94 | -------------------------------------------------------------------------------- /src/processes/loginProcesses.ts: -------------------------------------------------------------------------------- 1 | import global from '@dojo/framework/shim/global'; 2 | import { createProcess } from '@dojo/framework/stores/process'; 3 | import { replace } from '@dojo/framework/stores/state/operations'; 4 | import { getHeaders, commandFactory } from './utils'; 5 | import { baseUrl } from '../config'; 6 | import { SetSessionPayload } from './interfaces'; 7 | 8 | const startLoginCommand = commandFactory(({ path }) => { 9 | return [replace(path('login', 'isLoading'), true)]; 10 | }); 11 | 12 | const startRegisterCommand = commandFactory(({ path }) => { 13 | return [replace(path('register', 'isLoading'), true)]; 14 | }); 15 | 16 | const setSessionCommand = commandFactory(({ path, payload: { session } }) => { 17 | return [replace(path('session'), session)]; 18 | }); 19 | 20 | const loginCommand = commandFactory<{ email: string; password: string }>(async ({ path, payload }) => { 21 | const requestPayload = { 22 | user: { 23 | email: payload.email, 24 | password: payload.password 25 | } 26 | }; 27 | 28 | const response = await fetch(`${baseUrl}/users/login`, { 29 | method: 'post', 30 | body: JSON.stringify(requestPayload), 31 | headers: getHeaders() 32 | }); 33 | 34 | const json = await response.json(); 35 | if (!response.ok) { 36 | return [ 37 | replace(path('login', 'isLoading'), true), 38 | replace(path('errors'), json.errors), 39 | replace(path('session'), {}) 40 | ]; 41 | } 42 | 43 | global.sessionStorage.setItem('conduit-session', JSON.stringify(json.user)); 44 | 45 | return [ 46 | replace(path('routing', 'outlet'), 'home'), 47 | replace(path('login', 'isLoading'), false), 48 | replace(path('errors'), undefined), 49 | replace(path('session'), json.user) 50 | ]; 51 | }); 52 | 53 | const registerCommand = commandFactory<{ username: string; email: string; password: string }>( 54 | async ({ path, payload: { username, email, password } }) => { 55 | const requestPayload = { 56 | user: { 57 | username, 58 | email, 59 | password 60 | } 61 | }; 62 | 63 | const response = await fetch(`${baseUrl}/users`, { 64 | method: 'post', 65 | body: JSON.stringify(requestPayload), 66 | headers: getHeaders() 67 | }); 68 | const json = await response.json(); 69 | if (!response.ok) { 70 | return [ 71 | replace(path('register', 'isLoading'), false), 72 | replace(path('errors'), json.errors), 73 | replace(path('session'), {}) 74 | ]; 75 | } 76 | 77 | global.sessionStorage.setItem('conduit-session', JSON.stringify(json.user)); 78 | 79 | return [ 80 | replace(path('routing', 'outlet'), 'home'), 81 | replace(path('register', 'isLoading'), false), 82 | replace(path('errors'), undefined), 83 | replace(path('session'), json.user) 84 | ]; 85 | } 86 | ); 87 | 88 | const logoutCommand = commandFactory(({ path }) => { 89 | global.sessionStorage.removeItem('conduit-session'); 90 | return [replace(path('session'), {}), replace(path('routing', 'outlet'), 'home')]; 91 | }); 92 | 93 | export const loginProcess = createProcess('login', [startLoginCommand, loginCommand]); 94 | export const registerProcess = createProcess('register', [startRegisterCommand, registerCommand]); 95 | export const setSessionProcess = createProcess('set-session', [setSessionCommand]); 96 | export const logoutProcess = createProcess('logout', [logoutCommand]); 97 | -------------------------------------------------------------------------------- /src/processes/profileProcesses.ts: -------------------------------------------------------------------------------- 1 | import { createProcess } from '@dojo/framework/stores/process'; 2 | import { replace } from '@dojo/framework/stores/state/operations'; 3 | import { getHeaders, commandFactory } from './utils'; 4 | import { baseUrl } from '../config'; 5 | import { FollowUserPayload } from './interfaces'; 6 | 7 | const startGetProfileCommand = commandFactory(({ path }) => { 8 | return [ 9 | replace(path('profile', 'user', 'isLoading'), true), 10 | replace(path('profile', 'user', 'isLoaded'), false), 11 | replace(path('profile', 'feed', 'items'), undefined) 12 | ]; 13 | }); 14 | 15 | const startGetProfileFeedCommand = commandFactory(({ path, payload: { page, type } }) => { 16 | return [ 17 | replace(path('profile', 'feed', 'isLoading'), true), 18 | replace(path('profile', 'feed', 'isLoaded'), false), 19 | replace(path('profile', 'feed', 'category'), type), 20 | replace(path('profile', 'feed', 'page'), page) 21 | ]; 22 | }); 23 | 24 | const followUserCommand = commandFactory(async ({ get, path, payload: { username, following } }) => { 25 | const token = get(path('session', 'token')); 26 | const response = await fetch(`${baseUrl}/profiles/${username}/follow`, { 27 | method: following ? 'delete' : 'post', 28 | headers: getHeaders(token) 29 | }); 30 | const json = await response.json(); 31 | 32 | return [replace(path('profile'), json.profile)]; 33 | }); 34 | 35 | const getProfileCommand = commandFactory<{ username: string; type: string }>( 36 | async ({ get, path, payload: { username } }) => { 37 | const token = get(path('session', 'token')); 38 | const response = await fetch(`${baseUrl}/profiles/${username}`, { 39 | headers: getHeaders(token) 40 | }); 41 | const json = await response.json(); 42 | 43 | return [ 44 | replace(path('profile', 'user', 'username'), json.profile.username), 45 | replace(path('profile', 'user', 'image'), json.profile.image), 46 | replace(path('profile', 'user', 'bio'), json.profile.bio), 47 | replace(path('profile', 'user', 'following'), json.profile.following), 48 | replace(path('profile', 'user', 'isLoading'), false), 49 | replace(path('profile', 'user', 'isLoaded'), true) 50 | ]; 51 | } 52 | ); 53 | 54 | const getProfileFeedCommand = commandFactory<{ username: string; type: string; page: number }>( 55 | async ({ get, path, payload: { type, username, page } }) => { 56 | const token = get(path('session', 'token')); 57 | const offset = page * 10; 58 | let url = `${baseUrl}/articles?`; 59 | 60 | switch (type) { 61 | case 'favorites': 62 | url = `${url}favorited=${username}&`; 63 | break; 64 | case 'user': 65 | url = `${url}author=${username}&`; 66 | break; 67 | } 68 | 69 | const response = await fetch(`${url}limit=10&offset=${offset}`, { headers: getHeaders(token) }); 70 | const json = await response.json(); 71 | return [ 72 | replace(path('profile', 'feed', 'items'), json.articles), 73 | replace(path('profile', 'feed', 'total'), json.articlesCount), 74 | replace(path('profile', 'feed', 'offset'), offset), 75 | replace(path('profile', 'feed', 'isLoading'), false), 76 | replace(path('profile', 'feed', 'isLoaded'), true) 77 | ]; 78 | } 79 | ); 80 | 81 | export const getProfileProcess = createProcess('get-profile', [ 82 | [startGetProfileCommand, startGetProfileFeedCommand], 83 | [getProfileCommand, getProfileFeedCommand] 84 | ]); 85 | export const getProfileFeedProcess = createProcess('get-profile-feed', [ 86 | startGetProfileFeedCommand, 87 | getProfileFeedCommand 88 | ]); 89 | export const followUserProcess = createProcess('follow-user', [followUserCommand]); 90 | -------------------------------------------------------------------------------- /src/widgets/Home.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | import icache from "@dojo/framework/core/middleware/icache"; 3 | import store from "../store"; 4 | import FeedList from "./FeedList"; 5 | import { Tags } from "./Tags"; 6 | import { Banner } from "./Banner"; 7 | import { FeedPagination } from "./FeedPagination"; 8 | import { fetchFeedProcess } from "../processes/feedProcesses"; 9 | 10 | const factory = create({ store, icache }); 11 | 12 | export const Home = factory(function Home({ middleware: { store } }) { 13 | const { get, path, executor } = store; 14 | const isAuthenticated = !!get(path("session", "token")); 15 | const username = get(path("session", "username")); 16 | const articles = get(path("feed", "items")) || []; 17 | const tagName = get(path("feed", "tagName")); 18 | const loaded = get(path("feed", "isLoaded")) || false; 19 | const loading = get(path("feed", "isLoading")) || false; 20 | const type = get(path("feed", "category")) || (isAuthenticated ? "feed" : "global"); 21 | 22 | if (articles.length === 0 && !loading && !loaded) { 23 | executor(fetchFeedProcess)({ type, filter: username, page: 0 }); 24 | return null; 25 | } 26 | 27 | const currentPage = get(path("feed", "page")) || 0; 28 | const total = get(path("feed", "total")) || 0; 29 | 30 | if (type) 31 | return ( 32 |
    33 | 34 |
    35 |
    36 |
    37 | 81 |
    82 | {loading ? ( 83 |
    Loading...
    84 | ) : ( 85 | 86 | )} 87 |
    88 | {!loading && ( 89 | { 93 | executor(fetchFeedProcess)({ 94 | type, 95 | filter: type === "tag" ? tagName : username, 96 | page 97 | }); 98 | }} 99 | /> 100 | )} 101 |
    102 | 103 |
    104 |
    105 |
    106 | ); 107 | }); 108 | 109 | export default Home; 110 | -------------------------------------------------------------------------------- /src/processes/feedProcesses.ts: -------------------------------------------------------------------------------- 1 | import { createProcess } from '@dojo/framework/stores/process'; 2 | import { replace } from '@dojo/framework/stores/state/operations'; 3 | import { ArticleItem } from './../interfaces'; 4 | import { getHeaders, commandFactory } from './utils'; 5 | import { baseUrl } from '../config'; 6 | import { FavoriteArticlePayload, FetchFeedPayload } from './interfaces'; 7 | 8 | function getItemIndex(items: ArticleItem[], id: string) { 9 | let index = -1; 10 | for (let i = 0; i < items.length; i++) { 11 | if (items[i].slug === id) { 12 | index = i; 13 | break; 14 | } 15 | } 16 | return index; 17 | } 18 | 19 | const startFetchingFeedCommand = commandFactory(({ path, payload: { type, filter, page } }) => { 20 | return [ 21 | replace(path('feed', 'isLoading'), true), 22 | replace(path('feed', 'isLoaded'), false), 23 | replace(path('feed', 'category'), type), 24 | replace(path('feed', 'filter'), filter), 25 | replace(path('feed', 'tagName'), type === 'tag' ? filter : undefined), 26 | replace(path('feed', 'page'), page), 27 | replace(path('feed', 'items'), undefined) 28 | ]; 29 | }); 30 | 31 | export const fetchFeedCommand = commandFactory( 32 | async ({ get, path, payload: { type, page, filter } }) => { 33 | const token = get(path('session', 'token')); 34 | const offset = page * 10; 35 | let url: string; 36 | 37 | switch (type) { 38 | case 'feed': 39 | url = `${baseUrl}/articles/feed?`; 40 | break; 41 | case 'tag': 42 | url = `${baseUrl}/articles?tag=${filter}&`; 43 | break; 44 | default: 45 | url = `${baseUrl}/articles/?`; 46 | break; 47 | } 48 | 49 | const response = await fetch(`${url}limit=10&offset=${offset}`, { headers: getHeaders(token) }); 50 | const json = await response.json(); 51 | return [ 52 | replace(path('feed', 'items'), json.articles), 53 | replace(path('feed', 'total'), json.articlesCount), 54 | replace(path('feed', 'offset'), offset), 55 | replace(path('feed', 'page'), page), 56 | replace(path('feed', 'category'), type), 57 | replace(path('feed', 'filter'), filter), 58 | replace(path('feed', 'isLoading'), false), 59 | replace(path('feed', 'isLoaded'), true) 60 | ]; 61 | } 62 | ); 63 | 64 | const clearFeedCommand = commandFactory(({ path }) => { 65 | return [replace(path('feed'), undefined)]; 66 | }); 67 | 68 | const favoriteFeedArticleCommand = commandFactory( 69 | async ({ get, path, payload: { slug, favorited, type } }) => { 70 | const token = get(path('session', 'token')); 71 | const response = await fetch(`${baseUrl}/articles/${slug}/favorite`, { 72 | method: favorited ? 'delete' : 'post', 73 | headers: getHeaders(token) 74 | }); 75 | const json = await response.json(); 76 | let feedPath = path('feed', 'items'); 77 | if (type === 'favorites' || type === 'user') { 78 | feedPath = path('profile', 'feed', 'items'); 79 | } 80 | let articles = get(feedPath); 81 | 82 | const index = getItemIndex(articles, slug); 83 | articles = [...articles]; 84 | articles[index] = json.article; 85 | 86 | if (index !== -1) { 87 | if (type === 'favorites') { 88 | articles.splice(index, 1); 89 | return [replace(feedPath, articles)]; 90 | } 91 | articles[index] = json.article; 92 | return [replace(feedPath, articles)]; 93 | } 94 | return []; 95 | } 96 | ); 97 | 98 | export const fetchFeedProcess = createProcess('fetch-feed', [startFetchingFeedCommand, fetchFeedCommand]); 99 | export const clearFeedProcess = createProcess('clear-feed', [clearFeedCommand]); 100 | export const favoriteFeedArticleProcess = createProcess('fav-feed-article', [favoriteFeedArticleCommand]); 101 | -------------------------------------------------------------------------------- /src/processes/editorProcesses.ts: -------------------------------------------------------------------------------- 1 | import { createProcess } from '@dojo/framework/stores/process'; 2 | import { replace, add, remove } from '@dojo/framework/stores/state/operations'; 3 | import { getHeaders, commandFactory } from './utils'; 4 | import { baseUrl } from '../config'; 5 | import { TitlePayload, DescriptionPayload, BodyPayload, TagPayload, SlugPayload } from './interfaces'; 6 | 7 | const titleInputCommand = commandFactory(({ path, payload: { title } }) => { 8 | return [replace(path('editor', 'title'), title)]; 9 | }); 10 | 11 | const descriptionInputCommand = commandFactory(({ path, payload: { description } }) => { 12 | return [replace(path('editor', 'description'), description)]; 13 | }); 14 | 15 | const bodyInputCommand = commandFactory(({ path, payload: { body } }) => { 16 | return [replace(path('editor', 'body'), body)]; 17 | }); 18 | 19 | const tagInputCommand = commandFactory(({ path, payload: { tag } }) => { 20 | return [replace(path('editor', 'tag'), tag)]; 21 | }); 22 | 23 | const addTagCommand = commandFactory(({ get, at, path, payload: { tag } }) => { 24 | const length = (get(path('editor', 'tagList')) || []).length; 25 | return [add(at(path('editor', 'tagList'), length), tag)]; 26 | }); 27 | 28 | const clearTagInputCommand = commandFactory(({ path }) => { 29 | return [replace(path('editor', 'tag'), '')]; 30 | }); 31 | 32 | const removeTagCommand = commandFactory(({ get, at, path, payload: { tag } }) => { 33 | const tags = get(path('editor', 'tagList')); 34 | const index = tags.indexOf(tag); 35 | if (index !== -1) { 36 | return [remove(at(path('editor', 'tagList'), index))]; 37 | } 38 | return []; 39 | }); 40 | 41 | const getArticleForEditorCommand = commandFactory(async ({ path, payload: { slug } }) => { 42 | const response = await fetch(`${baseUrl}/articles/${slug}`); 43 | const json = await response.json(); 44 | return [replace(path('editor'), json.article)]; 45 | }); 46 | 47 | const clearEditorCommand = commandFactory(({ path }) => { 48 | return [replace(path('editor'), {})]; 49 | }); 50 | 51 | const startPublishCommand = commandFactory(({ path }) => { 52 | return [replace(path('editor', 'isLoading'), true)]; 53 | }); 54 | 55 | const publishArticleCommand = commandFactory(async ({ get, path }) => { 56 | const token = get(path('session', 'token')); 57 | const slug = get(path('editor', 'slug')); 58 | const requestPayload = { 59 | article: get(path('editor')) 60 | }; 61 | 62 | const url = slug ? `${baseUrl}/articles/${slug}` : `${baseUrl}/articles`; 63 | const response = await fetch(url, { 64 | method: slug ? 'put' : 'post', 65 | headers: getHeaders(token), 66 | body: JSON.stringify(requestPayload) 67 | }); 68 | const json = await response.json(); 69 | 70 | if (!response.ok) { 71 | return [replace(path('editor', 'isLoading'), false), replace(path('errors'), json.errors)]; 72 | } 73 | 74 | return [ 75 | replace(path('article', slug, 'item'), json.article), 76 | replace(path('article', slug, 'isLoading'), true), 77 | replace(path('editor'), undefined) 78 | ]; 79 | }); 80 | 81 | export const titleInputProcess = createProcess('title-input', [titleInputCommand]); 82 | export const descInputProcess = createProcess('desc-input', [descriptionInputCommand]); 83 | export const bodyInputProcess = createProcess('body-input', [bodyInputCommand]); 84 | export const tagInputProcess = createProcess('tag-input', [tagInputCommand]); 85 | export const addTagProcess = createProcess('add-tag', [addTagCommand, clearTagInputCommand]); 86 | export const removeTagProcess = createProcess('remove-tag', [removeTagCommand]); 87 | export const getEditorArticleProcess = createProcess('get-editor-article', [getArticleForEditorCommand]); 88 | export const publishArticleProcess = createProcess('publish-article', [startPublishCommand, publishArticleCommand]); 89 | export const clearEditorProcess = createProcess('clear-editor', [clearEditorCommand]); 90 | -------------------------------------------------------------------------------- /src/widgets/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { create, tsx } from "@dojo/framework/core/vdom"; 2 | import store from "../store"; 3 | import { 4 | bioInputProcess, 5 | emailInputProcess, 6 | passwordInputProcess, 7 | imageUrlInputProcess, 8 | getUserSettingsProcess, 9 | usernameInputProcess, 10 | updateUserSettingsProcess 11 | } from "./../processes/settingsProcesses"; 12 | import { logoutProcess } from "../processes/loginProcesses"; 13 | 14 | const factory = create({ store }); 15 | 16 | export const Settings = factory(function Settings({ middleware: { store } }) { 17 | const { get, path, executor } = store; 18 | const settings = get(path("settings")); 19 | 20 | if (!settings) { 21 | executor(getUserSettingsProcess)({}); 22 | return null; 23 | } 24 | 25 | return ( 26 |
    27 |
    28 |
    29 |
    30 |

    Your Settings

    31 |
    32 |
    33 |
    34 | { 37 | const target = event.target as HTMLInputElement; 38 | executor(imageUrlInputProcess)({ imageUrl: target.value }); 39 | }} 40 | placeholder="" 41 | classes={["form-control", "form-control-lg"]} 42 | /> 43 |
    44 |
    45 | { 48 | const target = event.target as HTMLInputElement; 49 | executor(usernameInputProcess)({ username: target.value }); 50 | }} 51 | placeholder="Your Name" 52 | classes={["form-control", "form-control-lg"]} 53 | /> 54 |
    55 |
    56 |