├── .env ├── .gitignore ├── .vscode └── tasks.json ├── README.md ├── circle.yml ├── docs ├── CNAME ├── assets │ ├── js │ │ └── bundle.js │ └── json │ │ └── settings.json └── index.html ├── gulpfile.js ├── hjson └── settings.hjson ├── logo.png ├── package-lock.json ├── package.json ├── rollup.config.js ├── social_preview.png ├── src ├── Domain │ ├── Model │ │ ├── Article.ts │ │ ├── ArticleContainer.ts │ │ ├── ArticleTabItem.ts │ │ ├── Comment.ts │ │ ├── MenuItem.ts │ │ ├── PostArticle.ts │ │ ├── PostUser.ts │ │ ├── Profile.ts │ │ ├── Scene.ts │ │ ├── ServerError.ts │ │ ├── User.ts │ │ └── UserForm.ts │ ├── Repository │ │ ├── ConduitProductionRepository.ts │ │ ├── Interface │ │ │ ├── ConduitRepository.ts │ │ │ └── UserRepository.ts │ │ └── UserLocalStorageRepository.ts │ ├── UseCase │ │ ├── ApplicationUseCase.ts │ │ ├── ArticleUseCase.ts │ │ ├── ArticlesUseCase.ts │ │ ├── EditerUseCase.ts │ │ ├── LoginUseCase.ts │ │ ├── ProfileUseCase.ts │ │ ├── RegisterUseCase.ts │ │ └── SettingsUseCase.ts │ └── Utility │ │ └── MenuItemsBuilder.ts ├── Infrastructure │ ├── HTTPURL.ts │ ├── HTTPURLParser.ts │ ├── Initializable.ts │ ├── SPALocation.ts │ ├── SPAPathBuilder.ts │ └── Settings.ts ├── Presentation │ ├── ApplicationController.ts │ ├── View │ │ ├── ArticleTabView.riot │ │ ├── ArticleView.riot │ │ ├── ArticleWidgetView.riot │ │ ├── ArticlesTableView.riot │ │ ├── BannerView.riot │ │ ├── CommentFormView.riot │ │ ├── CommentTableView.riot │ │ ├── FooterView.riot │ │ ├── HeaderView.riot │ │ ├── MarkdownView.riot │ │ ├── PagenationView.riot │ │ ├── ProfileView.riot │ │ └── TagsView.riot │ ├── ViewController │ │ ├── Article.riot │ │ ├── ArticleViewController.ts │ │ ├── Articles.riot │ │ ├── ArticlesViewController.ts │ │ ├── Editer.riot │ │ ├── EditerViewController.ts │ │ ├── Login.riot │ │ ├── LoginViewController.ts │ │ ├── Profile.riot │ │ ├── ProfileViewController.ts │ │ ├── Register.riot │ │ ├── RegisterViewController.ts │ │ ├── Settings.riot │ │ ├── SettingsViewController.ts │ │ └── ShowError.riot │ └── application.riot └── main.ts ├── test ├── HTTPURLParserTest.ts ├── SPALocationTest.ts └── SPAPathBuilderTest.ts ├── tsconfig.json └── tslint.json /.env: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iq3addLi/riot_realworld_example_app/2af5fc527f492a992cb2710732c07d8765de5f8e/.env -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "command": "sh", 4 | "args": ["-c"], 5 | "presentation": { 6 | "echo": true, 7 | "reveal": "always", 8 | "focus": false, 9 | "panel": "shared", 10 | "showReuseMessage": true, 11 | "clear": false 12 | }, 13 | "echoCommand" : true, 14 | "tasks": [ 15 | { 16 | "label" : "build", 17 | "command": "npm run build", 18 | "type" : "shell", 19 | "group": { 20 | "kind": "build", 21 | "isDefault": true, 22 | }, 23 | "isBackground" : false, 24 | "problemMatcher": [ 25 | "$tsc", 26 | "$gulp-tsc" 27 | ] 28 | }, 29 | { 30 | "label" : "test", 31 | "args" : ["npm run test"], 32 | "type" : "shell", 33 | "group": { 34 | "kind": "test", 35 | "isDefault": true 36 | }, 37 | "isBackground" : false, 38 | "problemMatcher": [ 39 | "$tsc", 40 | "$gulp-tsc" 41 | ] 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![RealWorld Frontend](https://img.shields.io/badge/realworld-frontend-%23783578.svg)](http://realworld.io) [](https://Riot.js.org) [![CircleCI](https://circleci.com/gh/iq3addLi/riot_realworld_example_app.svg?style=shield)](https://circleci.com/gh/iq3addLi/riot_realworld_example_app) 2 | 3 | # ![RealWorld Example App](./logo.png) 4 | 5 | > ### Riot.js codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 6 | 7 | 8 | ### [Demo](http://riot-realworld.addli.co.jp)    [RealWorld](https://github.com/gothinkster/realworld) 9 | 10 | This codebase was created to demonstrate a fully fledged fullstack application built with **Riot.js** including CRUD operations, authentication, routing, pagination, and more. 11 | 12 | We've gone to great lengths to adhere to the **Riot.js** community styleguides & best practices. 13 | 14 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 15 | 16 | 17 | 18 | | ℹ️ Important Notices | 19 | | :----------------------------------------------------------- | 20 | | Here's the document from when I updated from v3 to v4. I was able to update v5 **without any difficulty at all**. And so far, **v7 works fine**. Riot.js is awesome!!😊 | 21 | 22 | 23 | 24 | ## Introduction 25 | 26 | Please see [here](https://riot.js.org) about Riot.js. Recently I often see what is called the **frontend framework**. This library is useful for HTML componentization and reusability. Although Riot.js can be used in a wide variety of ways, please be aware that the usage for this example is as follows. 27 | 28 | * Aware of large-scale development 29 | * Incorporates iOS Application development methods 30 | 31 | These will be explained later. Perhaps there is a big difference from the coding of other samples out there. I currently please feedback on the coding of this project on the [discord channel of Riot.js](https://discord.gg/PagXe5Y). It follows the standard expected API usage and guarantees that it is *not tricky*. 32 | 33 | [toc] 34 | 35 | 36 | 37 | 38 | ## How it works 39 | 40 | ### On local 41 | 42 | Same to [v3](https://github.com/iq3addLi/riot_v3_realworld_example_app#getting-started). 43 | 44 | #### Clone project 45 | 46 | ```bash 47 | $ cd << your working directory >> 48 | $ git clone https://github.com/iq3addLi/riot_realworld_example_app.git 49 | ``` 50 | 51 | #### Install packages 52 | 53 | ```bash 54 | $ cd riot_realworld_example_app 55 | $ npm install 56 | ``` 57 | 58 | #### Launch server 59 | 60 | ```bash 61 | $ gulp connect 62 | ``` 63 | 64 | #### Open in browser 65 | 66 | ```bash 67 | $ open http://localhost:8080 68 | ``` 69 | 70 | 71 | 72 | ### How to build 73 | 74 | #### Build with gulp and rollup 75 | 76 | ```bash 77 | $ gulp 78 | ``` 79 | 80 | For details, please read [gulpfile](https://github.com/iq3addLi/riot_realworld_example_app/blob/master/gulpfile.js). 81 | 82 | 83 | 84 | ## Getting started 85 | 86 | ### Entrance 87 | 88 | [src/main.ts](src/main.ts) is entrance of code. Follow *import*. Enjoy the contents of the code! 89 | 90 | 91 | 92 | ### Change API Server 93 | 94 | [hjson/settings.hjson](hjson/settings.hjson) contains the API server host. Let's change to the API you built. 95 | 96 | ```json 97 | // endpoint of Conduit API ( '/' suffix is unneed ) 98 | //"endpoint": "https://conduit.productionready.io/api" 99 | "endpoint": "http://127.0.0.1:8080" 100 | ``` 101 | 102 | It changes when you build. 103 | 104 | 105 | 106 | ## Design policy 107 | 108 | Same completely to [v3](https://github.com/iq3addLi/riot_v3_realworld_example_app#design-policy). 109 | 110 | 111 | 112 | ## How this project uses Riot.js 113 | 114 | ### Pre-compile with npm packages 115 | 116 | Riot.js can compile `.riot` files on browser side. This is very convenient for getting started quickly. However, I chose to precompile with npm for this project. This is because TypeScript can be used for most of the implementation code. When developing applications as large as RealWorld, type checking with TypeScript greatly contributes to work efficiency. 117 | 118 | After a few trials, I concluded that the compilation task is rollup and other tasks are reliable to do with gulp. See **gulpfile.js** and **rollup.config.js**. I hope it will help those who are considering taking a configuration like this project. 119 | 120 | 121 | 122 | ### `.riot` is the interface definition 123 | 124 | I treated the `.riot` file as an interface definition. Information to be displayed on the `.riot` side and functions called from event handlers are not coded in this, but are coded in `*ViewController.ts`. 125 | 126 | The reason is next 127 | 128 | #### I wanted to use TypeScript as much as possible. 129 | 130 | There is a way to write TypeScript in 19 | 20 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | var gulp = require("gulp") 4 | 5 | // ━━━━━━━━━━━━━━━━━━━━━━ 6 | // Linting for TypeScript 7 | // ━━━━━━━━━━━━━━━━━━━━━━ 8 | var tslint = require("gulp-tslint") 9 | gulp.task("tslint", function() { 10 | return gulp.src([ 11 | "./src/**/*.ts", 12 | "./test/**/*.ts" 13 | ]) 14 | .pipe( 15 | tslint({ 16 | configuration: "./tslint.json", 17 | fix: true 18 | }) 19 | ) 20 | .pipe(tslint.report()) 21 | }) 22 | 23 | // ━━━━━━━━━━━━━━━━━━━━━━ 24 | // Minify 25 | // ━━━━━━━━━━━━━━━━━━━━━━ 26 | // var uglifyes = require("uglify-es") 27 | // var composer = require("gulp-uglify/composer") 28 | // var pump = require("pump") 29 | // var minify = composer(uglifyes, console) 30 | // gulp.task("compress", function (cb) { 31 | // var options = {} 32 | // pump([ 33 | // gulp.src("./docs/assets/js/bundle.js"), 34 | // minify(options), 35 | // gulp.dest("./docs/assets/js/") 36 | // ], 37 | // cb 38 | // ) 39 | // }) 40 | 41 | // ━━━━━━━━━━━━━━━━━━━━━━ 42 | // Build JS/TS 43 | // ━━━━━━━━━━━━━━━━━━━━━━ 44 | var exec = require("child_process").exec 45 | 46 | gulp.task("buildjs", function (cb) { 47 | exec("npm run rollup", function (err, stdout, stderr) { 48 | console.log(stdout) 49 | console.log(stderr) 50 | cb(err) 51 | }) 52 | }) 53 | 54 | // ━━━━━━━━━━━━━━━━━━━━━━ 55 | // Convert settings 56 | // ━━━━━━━━━━━━━━━━━━━━━━ 57 | var Hjson = require("gulp-hjson") 58 | 59 | gulp.task("convert-hjson-to-json", function() { 60 | return gulp.src(["./hjson/**/*"]) 61 | .pipe(Hjson({ to: "json" })) 62 | .pipe(gulp.dest("./docs/assets/json/")) 63 | }) 64 | 65 | 66 | // ━━━━━━━━━━━━━━━━━━━━━━ 67 | // Default 68 | // ━━━━━━━━━━━━━━━━━━━━━━ 69 | gulp.task("default", 70 | gulp.series( 71 | "convert-hjson-to-json", 72 | "tslint", 73 | "buildjs" 74 | //,"compress" 75 | ) 76 | ) 77 | 78 | // ━━━━━━━━━━━━━━━━━━━━━━ 79 | // Launch local server 80 | // ━━━━━━━━━━━━━━━━━━━━━━ 81 | var connect = require("gulp-connect") 82 | gulp.task("connect", function() { 83 | connect.server({ 84 | port: 8000, 85 | root: "./docs", 86 | livereload: true, 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /hjson/settings.hjson: -------------------------------------------------------------------------------- 1 | { 2 | // document.title 3 | "title" : "Conduit", 4 | 5 | // display article count in articles and profile scene 6 | "countOfArticleInView" : 10, 7 | 8 | // endpoint of Conduit API ( '/' suffix is unneed ) 9 | "endpoint": "https://conduit.productionready.io/api" 10 | //"endpoint": "http://127.0.0.1:8080" 11 | } 12 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iq3addLi/riot_realworld_example_app/2af5fc527f492a992cb2710732c07d8765de5f8e/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "riot_realworld_example_app", 3 | "version": "7.0.0", 4 | "description": "Exemplary real world application built with Riot.js v7 🖐✌️", 5 | "scripts": { 6 | "build": "gulp", 7 | "rollup": "rollup -c", 8 | "connect": "gulp connect", 9 | "test": "mocha -r ts-node/register test/**/*.ts" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/iq3addLi/riot_realworld_example_app.git" 14 | }, 15 | "keywords": [ 16 | "riot", 17 | "riotjs", 18 | "realworld", 19 | "single-page-app", 20 | "example" 21 | ], 22 | "author": "iq3 (+Li, Inc.)", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/iq3addLi/riot_realworld_example_app/issues" 26 | }, 27 | "homepage": "https://github.com/iq3addLi/riot_realworld_example_app", 28 | "devDependencies": { 29 | "@types/chai": "^4.2.22", 30 | "@types/gulp": "^4.0.9", 31 | "@types/gulp-connect": "^5.0.5", 32 | "@types/gulp-replace": "^0.0.31", 33 | "@types/marked": "^4.0.0", 34 | "@types/mocha": "^5.2.7", 35 | "@types/riot-route": "^3.1.1", 36 | "chai": "^4.3.4", 37 | "fetch-polyfill": "^0.8.2", 38 | "gulp": "^4.0.0", 39 | "gulp-connect": "^5.7.0", 40 | "gulp-hjson": "^2.4.3", 41 | "gulp-replace": "^1.1.3", 42 | "gulp-tslint": "^8.1.4", 43 | "gulp-uglify": "^3.0.2", 44 | "i18next": "^21.0.0", 45 | "jwt-decode": "^3.0.0", 46 | "kind-of": ">=6.0.3", 47 | "marked": "^4.0.10", 48 | "mocha": "^10.0.0", 49 | "moment": "^2.29.4", 50 | "natives": "^1.1.6", 51 | "reflect-metadata": "^0.1.13", 52 | "riot": "^7.0.0", 53 | "riot-route": "^3.1.4", 54 | "rollup": "^2.78.0", 55 | "rollup-plugin-commonjs": "^10.1.0", 56 | "rollup-plugin-node-resolve": "^5.2.0", 57 | "rollup-plugin-riot": "^6.0.0", 58 | "rollup-plugin-typescript": "^1.0.1", 59 | "ts-node": "^10.9.1", 60 | "typescript": "^4.7.4", 61 | "typescript-simple": "^8.0.6", 62 | "uglify-es": "^3.3.9" 63 | }, 64 | "optionalDependencies": { 65 | "tslint": "^6.0.0" 66 | }, 67 | "volta": { 68 | "node": "16.16.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import riot from "rollup-plugin-riot" 2 | import nodeResolve from "rollup-plugin-node-resolve" 3 | import commonjs from "rollup-plugin-commonjs" 4 | import typescript from "rollup-plugin-typescript" 5 | 6 | export default { 7 | input: "src/main.ts", 8 | output: { 9 | file: "docs/assets/js/bundle.js", 10 | format: "iife" 11 | }, 12 | plugins: [ 13 | riot(), 14 | nodeResolve({ mainFields: ['module', 'main'] }), 15 | typescript(), 16 | commonjs() 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /social_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iq3addLi/riot_realworld_example_app/2af5fc527f492a992cb2710732c07d8765de5f8e/social_preview.png -------------------------------------------------------------------------------- /src/Domain/Model/Article.ts: -------------------------------------------------------------------------------- 1 | import Profile from "./Profile" 2 | // import Initializable from "../../Infrastructure/Initializable" 3 | 4 | export default class Article/* implements Initializable*/ { 5 | title: string 6 | slug: string 7 | body: string 8 | createdAt: Date 9 | updatedAt: Date 10 | tagList: string[] 11 | description: string 12 | author: Profile 13 | favorited: boolean 14 | favoritesCount: number 15 | 16 | public static init = (object: any) => { 17 | return new Article(object.title, object.slug, object.body, new Date(object.createdAt), new Date(object.updatedAt), object.tagList, 18 | object.description, object.author, object.favorited, object.favoritesCount ) 19 | } 20 | 21 | constructor(title: string, slug: string, body: string, createdAt: Date, updatedAt: Date, 22 | tagList: string[], description: string, profile: Profile, favorited: boolean, favoritesCount: number ) { 23 | this.title = title 24 | this.slug = slug 25 | this.body = body 26 | this.createdAt = createdAt 27 | this.updatedAt = updatedAt 28 | this.tagList = tagList 29 | this.description = description 30 | this.author = profile 31 | this.favorited = favorited 32 | this.favoritesCount = favoritesCount 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Domain/Model/ArticleContainer.ts: -------------------------------------------------------------------------------- 1 | import Article from "./Article" 2 | 3 | export default class ArticleContainer { 4 | count: number 5 | articles: Article[] 6 | 7 | constructor(count: number, articles: Article[]) { 8 | this.count = count 9 | this.articles = articles 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Domain/Model/ArticleTabItem.ts: -------------------------------------------------------------------------------- 1 | export default class ArticleTabItem { 2 | identifier: string 3 | title: string 4 | isActive: boolean 5 | 6 | constructor(identifier: string, title: string, isActive: boolean) { 7 | this.identifier = identifier 8 | this.title = title 9 | this.isActive = isActive 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Domain/Model/Comment.ts: -------------------------------------------------------------------------------- 1 | import Profile from "./Profile" 2 | 3 | // "comments": [ 4 | // { 5 | // "id": 42546, 6 | // "createdAt": "2019-07-13T23:42:57.417Z", 7 | // "updatedAt": "2019-07-13T23:42:57.417Z", 8 | // "body": "ok", 9 | // "author": { 10 | // "username": "ruslanguns2", 11 | // "bio": null, 12 | // "image": "https://static.productionready.io/images/smiley-cyrus.jpg", 13 | // "following": false 14 | // } 15 | // } 16 | // ] 17 | 18 | export default class Comment { 19 | id: number 20 | createdAt: Date 21 | updatedAt: Date 22 | body: string 23 | author: Profile 24 | 25 | public static init = (object: any) => { 26 | return new Comment(object.id, new Date(object.createdAt), new Date(object.updatedAt), object.body, 27 | object.author ) 28 | } 29 | 30 | constructor(id: number, createdAt: Date, updatedAt: Date, body: string, author: Profile ) { 31 | this.id = id 32 | this.createdAt = createdAt 33 | this.updatedAt = updatedAt 34 | this.body = body 35 | this.author = author 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Domain/Model/MenuItem.ts: -------------------------------------------------------------------------------- 1 | export default class MenuItem { 2 | identifier: string 3 | title: string 4 | isActive: boolean 5 | href: string 6 | icon?: string = null 7 | image?: string = null 8 | 9 | constructor(identifier: string, title: string, href: string, isActive: boolean, icon?: string, image?: string) { 10 | this.identifier = identifier 11 | this.title = title 12 | this.isActive = isActive 13 | this.href = href 14 | this.icon = (icon !== undefined) ? icon : null 15 | this.image = (image !== undefined) ? image : null 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Domain/Model/PostArticle.ts: -------------------------------------------------------------------------------- 1 | export default class PostArticle { 2 | title: string 3 | description: string 4 | body: string 5 | tagList?: string[] 6 | 7 | constructor(title: string, description: string, body: string, tagList?: string[] ) { 8 | this.title = title 9 | this.description = description 10 | this.body = body 11 | this.tagList = tagList 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Domain/Model/PostUser.ts: -------------------------------------------------------------------------------- 1 | export default class PostUser { 2 | email: string 3 | username: string 4 | bio: string 5 | image: string 6 | password?: string 7 | 8 | public static init = (object: any) => { 9 | return new PostUser( object.email, object.username, object.bio, object.image, object.password) 10 | } 11 | 12 | constructor(email: string, username: string, bio: string, image: string, password?: string) { 13 | this.email = email 14 | this.username = username 15 | this.bio = bio 16 | this.image = image 17 | this.password = password 18 | } 19 | 20 | trimmed = () => { 21 | let object = { 22 | "email": this.email, 23 | "username": this.username, 24 | "bio": this.bio, 25 | "image": this.image 26 | } 27 | if ( this.password || this.password.length !== 0) { object["password"] = this.password } 28 | 29 | return object 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Domain/Model/Profile.ts: -------------------------------------------------------------------------------- 1 | export default class Profile { 2 | username: string 3 | bio: string 4 | image: string 5 | following: boolean 6 | 7 | public static init = (object: any) => { 8 | return new Profile(object.username, object.bio, object.image, object.following ) 9 | } 10 | 11 | constructor( username: string, bio: string, image: string, following: boolean ) { 12 | this.username = username 13 | this.bio = bio 14 | this.image = image 15 | this.following = following 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Domain/Model/Scene.ts: -------------------------------------------------------------------------------- 1 | import { RiotComponentShell } from "riot" 2 | 3 | export default interface Scene { 4 | name: string 5 | component: RiotComponentShell 6 | filter?: string 7 | props?: object 8 | } 9 | -------------------------------------------------------------------------------- /src/Domain/Model/ServerError.ts: -------------------------------------------------------------------------------- 1 | export default class ServerError { 2 | subject: string 3 | objects: string[] 4 | 5 | constructor(subject: string, objects: string[]) { 6 | this.subject = subject 7 | this.objects = objects 8 | } 9 | 10 | concatObjects = () => { 11 | let concated = "" 12 | for ( let index in this.objects ) { 13 | concated += this.objects[index] 14 | if ( Number(index) !== this.objects.length - 1 ) { 15 | concated += ", " 16 | } 17 | } 18 | return concated 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Domain/Model/User.ts: -------------------------------------------------------------------------------- 1 | import Profile from "./Profile" 2 | 3 | export default class User { 4 | id: number 5 | email: string 6 | createdAt: Date 7 | updatedAt: Date 8 | username: string 9 | token: string 10 | bio?: string 11 | image?: string 12 | 13 | public static init = (object: any) => { 14 | return new User(object.id, object.email, object.createdAt, object.updatedAt, object.username, 15 | object.token, object.bio, object.image) 16 | } 17 | 18 | constructor(id: number, email: string, createdAt: Date, updatedAt: Date, 19 | username: string, token: string, bio?: string, image?: string) { 20 | this.id = id 21 | this.email = email 22 | this.createdAt = createdAt 23 | this.updatedAt = updatedAt 24 | this.username = username 25 | this.token = token 26 | this.bio = bio 27 | this.image = image 28 | } 29 | 30 | profile = () => { 31 | return new Profile(this.username, this.bio, this.image, false ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Domain/Model/UserForm.ts: -------------------------------------------------------------------------------- 1 | export default class UserForm { 2 | username: string 3 | email: string 4 | password: string 5 | 6 | constructor(username: string, email: string, password: string) { 7 | this.username = username 8 | this.email = email 9 | this.password = password 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Domain/Repository/ConduitProductionRepository.ts: -------------------------------------------------------------------------------- 1 | import ConduitRepository from "./interface/ConduitRepository" 2 | import User from "../Model/User" 3 | import Article from "../Model/Article" 4 | import PostArticle from "../Model/PostArticle" 5 | import Comment from "../Model/Comment" 6 | import Profile from "../Model/Profile" 7 | import ServerError from "../Model/ServerError" 8 | import ArticleContainer from "../Model/ArticleContainer" 9 | import PostUser from "../Model/PostUser" 10 | import Settings from "../../Infrastructure/Settings" 11 | 12 | export default class ConduitProductionRepository implements ConduitRepository { 13 | 14 | private _endpoint = null 15 | 16 | login = (email: string, password: string ) => { 17 | return this.fetchingPromise( "/users/login", "POST", this.headers(), {"user": {"email": email, "password": password}}).then( json => User.init(json.user) ) 18 | } 19 | 20 | register = (username: string, email: string, password: string ) => { 21 | return this.fetchingPromise( "/users", "POST", this.headers(), {"user": {"username": username, "email": email, "password": password}}).then( json => User.init(json.user) ) 22 | } 23 | 24 | getUser = (token: string ) => { 25 | return this.fetchingPromise( "/user", "GET", this.headers( token ), null).then( json => User.init(json.user) ) 26 | } 27 | 28 | updateUser = (token: string, user: PostUser) => { 29 | return this.fetchingPromise( "/user", "PUT", this.headers( token ), {"user": user.trimmed() }).then( json => User.init(json.user) ) 30 | } 31 | 32 | getArticles = ( token?: string, limit?: number, offset?: number ) => { 33 | return this.getArticleContainer( this.buildPath("/articles", this.buildArticlesQuery(limit, offset)), "GET", this.headers( token ) ) 34 | } 35 | 36 | getArticlesOfAuthor = ( username: string, token?: string, limit?: number, offset?: number ) => { 37 | return this.getArticleContainer( this.buildPath("/articles", this.buildArticlesQuery(limit, offset, null, null, username) ), "GET", this.headers( token ) ) 38 | } 39 | 40 | getArticlesForFavoriteUser = ( username: string, token?: string, limit?: number, offset?: number ) => { 41 | return this.getArticleContainer( this.buildPath("/articles", this.buildArticlesQuery(limit, offset, null, username) ), "GET", this.headers( token ) ) 42 | } 43 | 44 | getArticlesOfTagged = ( tag: string, token?: string, limit?: number, offset?: number ) => { 45 | return this.getArticleContainer( this.buildPath("/articles", this.buildArticlesQuery(limit, offset, tag) ), "GET", this.headers( token ) ) 46 | } 47 | 48 | getArticlesByFollowingUser = ( token: string, limit?: number, offset?: number ) => { 49 | return this.getArticleContainer( this.buildPath("/articles/feed", this.buildArticlesQuery(limit, offset)), "GET", this.headers( token ) ) 50 | } 51 | 52 | getArticle = ( slug: string, token?: string ) => { 53 | return this.fetchingPromise( "/articles/" + slug, "GET", this.headers( token ) ).then( json => Article.init(json.article) ) 54 | } 55 | 56 | postArticle = ( token: string, article: PostArticle ) => { 57 | return this.fetchingPromise( "/articles/", "POST", this.headers( token ), { "article": article } ).then( json => Article.init(json.article) ) 58 | } 59 | 60 | updateArticle = ( token: string, article: PostArticle, slug: string) => { 61 | return this.fetchingPromise( "/articles/" + slug, "PUT", this.headers( token ), { "article": article } ).then( json => Article.init(json.article) ) 62 | } 63 | 64 | deleteArticle = ( token: string, slug: string) => { 65 | return this.fetchingPromise( "/articles/" + slug, "DELETE", this.headers( token )) 66 | } 67 | 68 | favorite = ( token: string, slug: string ): Promise
=> { 69 | return this.fetchingPromise( "/articles/" + slug + "/favorite", "POST", this.headers( token )).then( json => Article.init(json.article)) 70 | } 71 | 72 | unfavorite = ( token: string, slug: string ): Promise
=> { 73 | return this.fetchingPromise( "/articles/" + slug + "/favorite", "DELETE", this.headers( token )).then( json => Article.init(json.article)) 74 | } 75 | 76 | getComments = ( slug: string ): Promise => { 77 | return this.fetchingPromise( "/articles/" + slug + "/comments", "GET", this.headers()).then( json => json.comments.map( comment => Comment.init(comment) )) 78 | } 79 | 80 | postComment = ( token: string, slug: string, comment: string ): Promise => { 81 | return this.fetchingPromise( "/articles/" + slug + "/comments", "POST", this.headers( token ), { "comment": { "body": comment } } ).then( json => Comment.init(json.comment)) 82 | } 83 | 84 | deleteComment = ( token: string, slug: string, commentId: number ) => { 85 | return this.fetchingPromise( "/articles/" + slug + "/comments/" + commentId, "DELETE", this.headers( token ) ) 86 | } 87 | 88 | getProfile = ( username: string, token?: string ) => { 89 | return this.fetchingPromise( "/profiles/" + username, "GET", this.headers(token)).then( json => Profile.init(json.profile) ) 90 | } 91 | 92 | follow = ( token: string, username: string ): Promise => { 93 | return this.fetchingPromise( "/profiles/" + username + "/follow", "POST", this.headers(token)).then( json => Profile.init(json.profile)) 94 | } 95 | 96 | unfollow = ( token: string, username: string ): Promise => { 97 | return this.fetchingPromise( "/profiles/" + username + "/follow", "DELETE", this.headers(token)).then( json => Profile.init(json.profile)) 98 | } 99 | 100 | getTags = () => { 101 | return this.fetchingPromise( "/tags", "GET", this.headers()).then( json => json.tags ) 102 | } 103 | 104 | // Privates 105 | 106 | private endpoint = () => { 107 | if ( this._endpoint == null ) { 108 | this._endpoint = Settings.shared().valueForKey("endpoint") 109 | } 110 | return this._endpoint 111 | } 112 | 113 | private getArticleContainer = ( path: string, method: string, headers?: {[key: string]: string }) => { 114 | return this.fetchingPromise( path, method, headers ).then( json => new ArticleContainer( json.articlesCount, json.articles )) 115 | } 116 | 117 | private headers = ( token?: string ) => { 118 | let headers = { 119 | "Accept": "application/json", 120 | "Content-Type": "application/json" 121 | } 122 | if ( token != null ) { headers["Authorization"] = "Token " + token } 123 | return headers 124 | } 125 | 126 | private buildPath = ( scene: string, queries?: {[key: string]: string } ) => { 127 | let path = scene 128 | if ( queries != null ) { 129 | let concated = "?" 130 | Object.keys(queries).forEach((key, index, keys) => { 131 | concated += key + "=" + queries[key] 132 | if (index !== keys.length - 1) { concated += "&" } 133 | }) 134 | if (concated.length > 0) { path += concated } 135 | } 136 | return path 137 | } 138 | 139 | private buildArticlesQuery = (limit?: number, offset?: number, tag?: string, favorited?: string, author?: string) => { 140 | if (tag == null && favorited == null && author == null && offset == null && limit == null) { return null } 141 | let queries = {} 142 | if (offset != null && offset > 0 ) { queries["offset"] = offset.toString() } 143 | if (limit != null && limit > 0 ) { queries["limit"] = limit.toString() } 144 | if (tag != null) { queries["tag"] = tag } 145 | else if (favorited != null) { queries["favorited"] = favorited } 146 | else if (author != null) { queries["author"] = author } 147 | return queries 148 | } 149 | 150 | private evaluateResponse = async( response: Response, successHandler: (json: any) => void, failureHandler: ( error: Error|Error[]) => void ) => { 151 | if ( response.status === 200 ) { 152 | successHandler( await response.json() ) 153 | } else if ( response.status === 422 ) { 154 | const json = await response.json() 155 | const errors = Object.keys(json.errors).map(key => new ServerError(key, json.errors[key])) 156 | failureHandler( errors.map((error) => new Error( error.subject + " " + error.concatObjects() )) ) 157 | } else { 158 | failureHandler( new Error("Unexpected error. code=" + response.status ) ) 159 | } 160 | } 161 | 162 | private fetchingPromise = (path: string, method: string, headers?: {[key: string]: string }, body?: object ) => { 163 | const init = { "method": method } 164 | if ( headers != null ) { init["headers"] = headers } 165 | if ( body != null ) { init["body"] = JSON.stringify(body) } 166 | return new Promise( async (resolve, reject) => { 167 | const response = await fetch( this.endpoint() + path, init) 168 | this.evaluateResponse( response, 169 | json => { resolve( json ) }, 170 | error => { reject( error ) } 171 | ) 172 | }) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/Domain/Repository/Interface/ConduitRepository.ts: -------------------------------------------------------------------------------- 1 | import User from "../../Model/User" 2 | import Article from "../../Model/Article" 3 | import PostArticle from "../../Model/PostArticle" 4 | import ArticleContainer from "../../Model/ArticleContainer" 5 | import Comment from "../../Model/Comment" 6 | import Profile from "../../Model/Profile" 7 | import PostUser from "../../Model/PostUser" 8 | 9 | export default interface ConduitRepository { 10 | 11 | // Users 12 | login: (email: string, password: string ) => Promise 13 | register: (username: string, email: string, password: string ) => Promise 14 | 15 | // GET {{APIURL}}/user 16 | getUser: (token: string ) => Promise 17 | 18 | // PUT {{APIURL}}/user 19 | updateUser: (token: string, user: PostUser) => Promise 20 | 21 | // Articles 22 | getArticles: ( token?: string, limit?: number, offset?: number) => Promise 23 | getArticlesOfAuthor: ( username: string, token?: string, limit?: number, offset?: number ) => Promise 24 | getArticlesForFavoriteUser: ( username: string, token?: string, limit?: number, offset?: number ) => Promise 25 | getArticlesOfTagged: ( tag: string, token?: string, limit?: number, offset?: number ) => Promise 26 | getArticlesByFollowingUser: ( token: string, limit?: number, offset?: number ) => Promise 27 | 28 | // {{APIURL}}/articles/{{slug}} 29 | getArticle: (slug: string, token?: string) => Promise
30 | 31 | // POST {{APIURL}}/articles 32 | postArticle: ( token: string, article: PostArticle ) => Promise
33 | 34 | // PUT {{APIURL}}/articles/{{slug}} 35 | updateArticle: ( token: string, article: PostArticle, slug: string) => Promise
36 | 37 | // DELETE {{APIURL}}/articles/{{slug}} 38 | deleteArticle: ( token: string, slug: string) => Promise 39 | 40 | // POST {{APIURL}}/articles/{{slug}}/favorite 41 | favorite: ( token: string, slug: string ) => Promise
42 | 43 | // DEL {{APIURL}}/articles/{{slug}}/favorite 44 | unfavorite: ( token: string, slug: string ) => Promise
45 | 46 | // {{APIURL}}/articles/{{slug}}/comments 47 | getComments: ( slug: string ) => Promise 48 | 49 | // POST {{APIURL}}/articles/{{slug}}/comments 50 | postComment: ( token: string, slug: string, comment: string ) => Promise 51 | 52 | // DEL {{APIURL}}/articles/{{slug}}/comments/{{commentId}} 53 | deleteComment: ( token: string, slug: string, commentId: number ) => Promise 54 | 55 | // GET {{APIURL}}/profiles/{{USERNAME}} 56 | getProfile: ( username: string, token?: string ) => Promise 57 | 58 | // POST {{APIURL}}/profiles/{{FOLLOWEE}}/follow 59 | follow: ( token: string, username: string ) => Promise 60 | 61 | // DEL {{APIURL}}/profiles/{{FOLLOWEE}}/follow 62 | unfollow: ( token: string, username: string ) => Promise 63 | 64 | // GET {{APIURL}}/tags 65 | getTags: () => Promise 66 | } 67 | 68 | -------------------------------------------------------------------------------- /src/Domain/Repository/Interface/UserRepository.ts: -------------------------------------------------------------------------------- 1 | import User from "../../Model/User" 2 | 3 | export default interface UserRepository { 4 | user?: () => User 5 | setUser: (user: User) => void 6 | isLoggedIn: () => boolean 7 | } 8 | -------------------------------------------------------------------------------- /src/Domain/Repository/UserLocalStorageRepository.ts: -------------------------------------------------------------------------------- 1 | import UserRepository from "./interface/UserRepository" 2 | import User from "../Model/User" 3 | import jwt_decode from "jwt-decode" 4 | 5 | export default class UserLocalStorageRepository implements UserRepository { 6 | 7 | user = () => { 8 | const value = localStorage.getItem("user") 9 | if ( value == null ) { return null } 10 | const user = User.init(JSON.parse( value )) 11 | 12 | // check expired 13 | const decoded = jwt_decode( user.token ) 14 | const now = Date.now() / 1000 // mili sec 15 | const exp = Number(decoded["exp"]) // sec 16 | if ( now > exp ) { 17 | this.setUser(null) 18 | return null 19 | } 20 | 21 | return user 22 | } 23 | 24 | setUser = (user: User) => { 25 | if ( user ) { 26 | // set 27 | const string = JSON.stringify( user ) 28 | localStorage.setItem("user", string ) 29 | } else { 30 | // remove 31 | localStorage.removeItem("user") 32 | } 33 | } 34 | 35 | isLoggedIn = () => { 36 | return this.user() != null 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Domain/UseCase/ApplicationUseCase.ts: -------------------------------------------------------------------------------- 1 | import { mount, unmount, register, RiotComponentShell } from "riot" 2 | import route from "riot-route" 3 | 4 | import Scene from "../Model/Scene" 5 | 6 | import Settings from "../../Infrastructure/Settings" 7 | import SPALocation from "../../Infrastructure/SPALocation" 8 | 9 | export default class ApplicationUseCase { 10 | 11 | private scenes: Scene[] = [] 12 | private homeScene?: Scene 13 | private errorScene?: Scene 14 | private mainViewSelector!: string 15 | 16 | // Public 17 | initialize = ( completion: (error?: Error) => void ) => { 18 | 19 | // Download application settings. 20 | const requestSettings = fetch("assets/json/settings.json") 21 | .then( (res) => { return res.json() }) 22 | .then( (json) => { 23 | Settings.shared().set( json ) 24 | }) 25 | .catch(function(error) { 26 | throw error 27 | }) 28 | 29 | // Parallel request 30 | Promise.all([requestSettings]).then( () => { 31 | document.title = Settings.shared().valueForKey("title") 32 | // Success 33 | completion(null) 34 | }) 35 | .catch((error) => { 36 | // Has error 37 | completion(error) 38 | }) 39 | } 40 | 41 | /** Set the scenes. */ 42 | setScenes = ( scenes: Scene[] ) => { 43 | this.scenes = scenes 44 | } 45 | 46 | /** Home scene filter is ignored */ 47 | setHomeScene = ( scene: Scene ) => { 48 | this.homeScene = scene 49 | } 50 | 51 | /** RiotComponent in error scene is must accept 'message' props. */ 52 | setErrorScene = ( scene: Scene ) => { 53 | this.errorScene = scene 54 | } 55 | 56 | /** MainViewSelector is Must be set */ 57 | setMainViewSelector = ( selector: string ) => { 58 | this.mainViewSelector = selector 59 | } 60 | 61 | /** Must be running before showMainView() */ 62 | routing = () => { 63 | 64 | // register normal view controllers 65 | this.scenes.forEach( scene => register( scene.name, scene.component ) ) // memo: register() does not allow duplicate register. 66 | // register error view controller 67 | if ( this.errorScene ) { register( this.errorScene.name, this.errorScene.component ) } 68 | 69 | // Start routing 70 | route.start() 71 | 72 | // Routing normal 73 | let selector = this.mainViewSelector 74 | this.scenes.forEach( scene => { 75 | route( scene.filter, () => { 76 | unmount( selector, true ) 77 | this.catchableMount( selector, scene.props, scene.name ) 78 | }) 79 | }) 80 | // Routing notfound 81 | route( () => { 82 | unmount( selector, true ) 83 | this.showError( selector, "Your order is not found.", this.errorScene ) 84 | }) 85 | // Routing home 86 | route( "/", () => { 87 | unmount( selector, true ) 88 | this.catchableMount( selector, this.homeScene.props, this.homeScene.name ) 89 | }) 90 | } 91 | 92 | /** Show mainView. Please execute it after all preparations are completed. */ 93 | showMainView = () => { 94 | 95 | // Decide what to mount 96 | const location = SPALocation.shared() 97 | let scene: Scene 98 | // Is there an ordered scene? 99 | if ( location.scene() ) { 100 | const filterd = this.scenes.filter( scene => scene.name === location.scene() ) 101 | if (filterd.length > 0 ) { 102 | scene = filterd[0] 103 | } 104 | } 105 | // If not, use the home scene. But home scene may not be registered 106 | if (scene == null) { 107 | scene = this.homeScene 108 | } 109 | // Have you decided which scene to show? 110 | if (scene) { 111 | this.catchableMount( this.mainViewSelector, scene.props, scene.name ) 112 | } else { 113 | this.showError( this.mainViewSelector, "Your order is not found.", this.errorScene ) 114 | } 115 | } 116 | 117 | // Private 118 | private catchableMount = ( selector: string, props?: object, componentName?: string) => { 119 | try { 120 | mount( selector, props, componentName ) 121 | } catch ( error ) { 122 | this.showError( selector, error.message, this.errorScene ) 123 | } 124 | } 125 | 126 | private showError = ( selector: string, message: string, errorScene?: Scene ) => { 127 | if (errorScene) { 128 | mount( selector, { message: message }, errorScene.name ) 129 | } else { 130 | console.log("Failed mount view controller. error message = " + message ) 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Domain/UseCase/ArticleUseCase.ts: -------------------------------------------------------------------------------- 1 | import UserLocalStorageRepository from "../Repository/UserLocalStorageRepository" 2 | import ConduitProductionRepository from "../Repository/ConduitProductionRepository" 3 | import Article from "../Model/Article" 4 | import SPALocation from "../../Infrastructure/SPALocation" 5 | import Comment from "../Model/Comment" 6 | import SPAPathBuilder from "../../Infrastructure/SPAPathBuilder" 7 | import MenuItemsBuilder from "../Utility/MenuItemsBuilder" 8 | 9 | export default class ArticleUseCase { 10 | 11 | private conduit = new ConduitProductionRepository() 12 | private storage = new UserLocalStorageRepository() 13 | 14 | private _article?: Article = null 15 | private _comments: Comment[] = [] 16 | private state: ArticleState 17 | 18 | constructor() { 19 | this.state = new ArticleState( SPALocation.shared() ) 20 | } 21 | 22 | // Public 23 | isLoggedIn = () => { 24 | return this.storage.isLoggedIn() 25 | } 26 | 27 | loggedUser = () => { 28 | return this.storage.user() 29 | } 30 | 31 | menuItems = () => { 32 | return new MenuItemsBuilder().items( this.state.scene, this.storage.user() ) 33 | } 34 | 35 | loggedUserProfile = () => { 36 | return this.storage.user().profile() 37 | } 38 | 39 | requestArticle = () => { 40 | const token = this.storage.user() == null ? null : this.storage.user().token 41 | return this.conduit.getArticle( this.state.slug, token).then( (article) => { 42 | this._article = article 43 | return article 44 | }) 45 | } 46 | 47 | currentArticle = () => { 48 | return this._article 49 | } 50 | 51 | currentComments = () => { 52 | return this._comments 53 | } 54 | 55 | requestComments = () => { 56 | return this.conduit.getComments( this.state.slug ).then( (comments) => { 57 | this._comments = comments 58 | return comments 59 | }) 60 | } 61 | 62 | toggleFollowing = () => { 63 | const article = this._article 64 | if ( article === null ) { throw Error("An article is empty.") } 65 | // follow/unfollow 66 | const token = this.storage.user().token 67 | const username = article.author.username 68 | const process = ( p ) => { this._article.author = p; return p } 69 | switch (article.author.following) { 70 | case true: return this.conduit.unfollow( token, username ).then( process ) 71 | case false: return this.conduit.follow( token, username ).then( process ) 72 | } 73 | } 74 | 75 | toggleFavorite = (): Promise
=> { 76 | const article = this._article 77 | if ( article === null ) { throw Error("An article is empty.") } 78 | const token = this.storage.user().token 79 | const process = ( a ) => { this._article = a; return a } 80 | const favorited = article.favorited 81 | switch (favorited) { 82 | case true: return this.conduit.unfavorite( token, this.state.slug ).then( process ) 83 | case false: return this.conduit.favorite( token, this.state.slug ).then( process ) 84 | } 85 | } 86 | 87 | postComment = ( comment: string ) => { 88 | const token = this.storage.user().token 89 | return this.conduit.postComment( token, this.state.slug, comment ).then( (comment) => { 90 | this._comments.unshift(comment) 91 | return comment 92 | }) 93 | } 94 | 95 | deleteComment = ( commentId: number ) => { 96 | const token = this.storage.user().token 97 | return this.conduit.deleteComment(token, this.state.slug, commentId).then( () => { 98 | let index = this._comments.findIndex( ( target ) => target.id === commentId ) 99 | this._comments.splice(index, 1) 100 | }) 101 | } 102 | 103 | deleteArticle = () => { 104 | const token = this.storage.user().token 105 | return this.conduit.deleteArticle( token, this.state.slug ) 106 | } 107 | 108 | jumpToEditerScene = () => { 109 | location.href = new SPAPathBuilder("editer", [this._article.slug]).fullPath() 110 | } 111 | 112 | jumpToHome = () => { 113 | location.href = "/" 114 | } 115 | } 116 | 117 | class ArticleState { 118 | scene: string 119 | slug: string 120 | 121 | constructor( location: SPALocation ) { 122 | // scene 123 | this.scene = location.scene() 124 | // slug 125 | this.slug = location.paths()[0] 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Domain/UseCase/ArticlesUseCase.ts: -------------------------------------------------------------------------------- 1 | import Article from "../Model/Article" 2 | import Profile from "../Model/Profile" 3 | 4 | import ConduitProductionRepository from "../Repository/ConduitProductionRepository" 5 | import UserLocalStorageRepository from "../Repository/UserLocalStorageRepository" 6 | import SPALocation from "../../Infrastructure/SPALocation" 7 | import Settings from "../../Infrastructure/Settings" 8 | import SPAPathBuilder from "../../Infrastructure/SPAPathBuilder" 9 | import ArticleContainer from "../Model/ArticleContainer" 10 | import ArticleTabItem from "../Model/ArticleTabItem" 11 | import MenuItemsBuilder from "../Utility/MenuItemsBuilder" 12 | 13 | export default class ArticlesUseCase { 14 | 15 | private conduit = new ConduitProductionRepository() 16 | private storage = new UserLocalStorageRepository() 17 | 18 | private container?: ArticleContainer = null 19 | private state: ArticlesState 20 | 21 | constructor() { 22 | this.state = new ArticlesState( SPALocation.shared() ) 23 | } 24 | 25 | requestArticles = () => { 26 | const limit: number = Settings.shared().valueForKey("countOfArticleInView") 27 | const offset = this.state.page == null ? null : (this.state.page - 1) * limit 28 | const nextProcess = (c) => { this.container = c; return c } 29 | const token = this.storage.user() === null ? null : this.storage.user().token 30 | 31 | switch (this.state.kind) { 32 | case "your": 33 | return this.conduit.getArticlesByFollowingUser( token, limit, offset).then( nextProcess ) 34 | case "tag": 35 | return this.conduit.getArticlesOfTagged( this.state.tag, token, limit, offset).then( nextProcess ) 36 | case "global": 37 | default: 38 | return this.conduit.getArticles(token, limit, offset).then( nextProcess ) 39 | } 40 | } 41 | 42 | requestTags = () => { 43 | return this.conduit.getTags() 44 | } 45 | 46 | isLoggedIn = () => { 47 | return this.storage.isLoggedIn() 48 | } 49 | 50 | loggedUser = () => { 51 | return this.storage.user() 52 | } 53 | 54 | pageCount = () => { 55 | if (this.container == null || this.container.count === 0) { 56 | return 0 57 | } 58 | const limit: number = Settings.shared().valueForKey("countOfArticleInView") 59 | return Math.floor(this.container.count / limit) 60 | } 61 | 62 | currentPage = () => { 63 | return this.state.page 64 | } 65 | 66 | menuItems = () => { 67 | return new MenuItemsBuilder().items( this.state.scene, this.storage.user() ) 68 | } 69 | 70 | tabItems = () => { 71 | const tabs: ArticleTabItem[] = [] 72 | 73 | // Add "Your feed" ? 74 | if ( this.isLoggedIn() ) { 75 | tabs.push( new ArticleTabItem( "your", "Your Feed", ( this.state.kind === "your")) ) 76 | } 77 | // Add "Global feed" 78 | tabs.push( new ArticleTabItem( "global", "Global Feed", ( this.state.kind === "global")) ) 79 | 80 | // Add "# {tag}" ? 81 | if ( this.state.kind === "tag" ) { 82 | let tag = this.state.tag 83 | if (tag != null) { 84 | tabs.push( new ArticleTabItem( "tag", "# " + tag, true) ) 85 | } 86 | } 87 | return tabs 88 | } 89 | 90 | jumpPage = (page: number) => { 91 | const path = new SPAPathBuilder( this.state.scene, SPALocation.shared().paths(), { "page" : String(page) } ).fullPath() 92 | location.href = path 93 | } 94 | 95 | jumpToSubPath = (path: string) => { 96 | const full = new SPAPathBuilder( this.state.scene, [path]).fullPath() 97 | location.href = full 98 | } 99 | 100 | jumpToProfileScene = (profile: Profile ) => { 101 | location.href = new SPAPathBuilder("profile", [profile.username]).fullPath() 102 | } 103 | 104 | jumpToArticleScene = (article: Article) => { 105 | location.href = new SPAPathBuilder("article", [article.slug]).fullPath() 106 | } 107 | 108 | toggleFavorite = ( article: Article ): Promise => { 109 | if ( article === null ) { throw Error("Article is empty.") } 110 | const user = this.storage.user() 111 | if ( user === null ) { 112 | return new Promise( async (resolve, _) => { resolve(null) } ) 113 | } 114 | let process = ( article ) => { 115 | let filtered = this.container.articles.filter( a => a.slug === article.slug )[0] 116 | let index = this.container.articles.indexOf( filtered ) 117 | this.container.articles.splice(index, 1, article) 118 | return this.container.articles 119 | } 120 | return article.favorited ? 121 | this.conduit.unfavorite( user.token, article.slug ).then( process ) : 122 | this.conduit.favorite( user.token, article.slug ).then( process ) 123 | } 124 | } 125 | 126 | 127 | class ArticlesState { 128 | scene: string // articles 129 | kind: string // global, your or tag 130 | page: number // since by 1 131 | tag?: string // tagword or null 132 | 133 | constructor( location: SPALocation ) { 134 | 135 | // scene 136 | this.scene = location.scene() ? location.scene() : "articles" 137 | 138 | // kind 139 | const paths = location.paths() ? location.paths() : [] 140 | const kind = ( paths.length >= 1 ) ? paths[0] : "global" 141 | this.kind = kind 142 | 143 | // tag 144 | if ( kind === "tag" && paths.length >= 2 ) { 145 | this.tag = paths[1] 146 | } 147 | 148 | // page 149 | switch ( location.query() ) { 150 | case undefined: case null: this.page = 1; break 151 | default: 152 | const page = location.query()["page"] 153 | if ( page === undefined || page == null ) { this.page = 1 } 154 | else { this.page = Number(page) } 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Domain/UseCase/EditerUseCase.ts: -------------------------------------------------------------------------------- 1 | import UserLocalStorageRepository from "../Repository/UserLocalStorageRepository" 2 | import ConduitProductionRepository from "../Repository/ConduitProductionRepository" 3 | import PostArticle from "../Model/PostArticle" 4 | import Article from "../Model/Article" 5 | import SPAPathBuilder from "../../Infrastructure/SPAPathBuilder" 6 | import MenuItemsBuilder from "../Utility/MenuItemsBuilder" 7 | import SPALocation from "../../Infrastructure/SPALocation" 8 | 9 | export default class EditerUseCase { 10 | 11 | private conduit = new ConduitProductionRepository() 12 | private storage = new UserLocalStorageRepository() 13 | 14 | private state: EditerState 15 | 16 | constructor() { 17 | this.state = new EditerState( SPALocation.shared() ) 18 | } 19 | 20 | isLoggedIn = () => { 21 | return this.storage.isLoggedIn() 22 | } 23 | loggedUser = () => { 24 | return this.storage.user() 25 | } 26 | 27 | menuItems = () => { 28 | return new MenuItemsBuilder().items( this.state.scene, this.storage.user() ) 29 | } 30 | 31 | ifNeededRequestArticle = () => { 32 | if ( this.state.slug === null ) { 33 | // needless 34 | return new Promise
( async (resolve, _) => { resolve(null) } ) 35 | } 36 | return this.conduit.getArticle( this.state.slug ) 37 | } 38 | 39 | isNewArticle = () => { 40 | return this.state.slug === null 41 | } 42 | 43 | post = ( title: string, description: string, body: string, tagsString: string) => { 44 | let splitted = tagsString.split(",") 45 | let tags: string[] = (splitted.length > 0 ? splitted : null ) 46 | if ( this.state.slug === null ) { 47 | // new article 48 | return this.conduit.postArticle(this.storage.user().token, new PostArticle(title, description, body, tags)) 49 | } else { 50 | // update 51 | return this.conduit.updateArticle(this.storage.user().token, new PostArticle(title, description, body, tags), this.state.slug) 52 | } 53 | } 54 | 55 | jumpToArticleScene = (article: Article) => { 56 | location.href = new SPAPathBuilder("article", [article.slug]).fullPath() 57 | } 58 | 59 | jumpToNotFound = () => { 60 | location.href = "#/notfound" 61 | } 62 | } 63 | 64 | class EditerState { 65 | scene: string 66 | slug?: string // slug or null 67 | 68 | constructor( location: SPALocation ) { 69 | // scene 70 | this.scene = location.scene() 71 | 72 | // slug 73 | const paths = location.paths() ? location.paths() : [] 74 | this.slug = ( paths.length >= 1 ) ? paths[0] : null 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Domain/UseCase/LoginUseCase.ts: -------------------------------------------------------------------------------- 1 | import ConduitProductionRepository from "../Repository/ConduitProductionRepository" 2 | import UserLocalStorageRepository from "../Repository/UserLocalStorageRepository" 3 | import MenuItemsBuilder from "../Utility/MenuItemsBuilder" 4 | import SPALocation from "../../Infrastructure/SPALocation" 5 | 6 | export default class LoginUseCase { 7 | 8 | private conduit = new ConduitProductionRepository() 9 | private storage = new UserLocalStorageRepository() 10 | 11 | private state: LoginState 12 | 13 | constructor() { 14 | this.state = new LoginState( SPALocation.shared() ) 15 | } 16 | 17 | login = async ( email: string, password: string ) => { 18 | return this.conduit.login(email, password ).then( (user) => { 19 | this.storage.setUser( user ) 20 | }) 21 | } 22 | 23 | menuItems = () => { 24 | return new MenuItemsBuilder().items( this.state.scene, this.storage.user() ) 25 | } 26 | } 27 | 28 | class LoginState { 29 | scene: string 30 | 31 | constructor( location: SPALocation ) { 32 | // scene 33 | this.scene = location.scene() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Domain/UseCase/ProfileUseCase.ts: -------------------------------------------------------------------------------- 1 | import UserLocalStorageRepository from "../Repository/UserLocalStorageRepository" 2 | import Profile from "../Model/Profile" 3 | import Article from "../Model/Article" 4 | import ArticleContainer from "../Model/ArticleContainer" 5 | import SPALocation from "../../Infrastructure/SPALocation" 6 | import Settings from "../../Infrastructure/Settings" 7 | import ArticleTabItem from "../Model/ArticleTabItem" 8 | import SPAPathBuilder from "../../Infrastructure/SPAPathBuilder" 9 | import ConduitProductionRepository from "../Repository/ConduitProductionRepository" 10 | import MenuItemsBuilder from "../Utility/MenuItemsBuilder" 11 | 12 | export default class ProfileUseCase { 13 | 14 | private conduit = new ConduitProductionRepository() 15 | private storage = new UserLocalStorageRepository() 16 | 17 | private profile?: Profile 18 | private container?: ArticleContainer = null 19 | private state: ProfileState 20 | 21 | constructor() { 22 | this.state = new ProfileState( SPALocation.shared() ) 23 | } 24 | 25 | isLoggedIn = () => { 26 | return this.storage.isLoggedIn() 27 | } 28 | 29 | loggedUser = () => { 30 | return this.storage.user() 31 | } 32 | 33 | menuItems = () => { 34 | return new MenuItemsBuilder().items( this.state.scene, this.storage.user() ) 35 | } 36 | 37 | isOwnedProfile = () => { 38 | const user = this.storage.user() 39 | if ( user == null ) { return false } 40 | return user.username === this.profile.username 41 | } 42 | 43 | requestProfile = () => { 44 | const token = this.storage.user() != null ? this.storage.user().token : null 45 | return this.conduit.getProfile( this.state.username, token ).then( ( p ) => { this.profile = p; return p } ) 46 | } 47 | 48 | requestArticles = () => { 49 | // calc offset 50 | const limit: number = Settings.shared().valueForKey("countOfArticleInView") 51 | const page = this.currentPage() 52 | const offset = page == null ? null : (page - 1) * limit 53 | 54 | // prepare request 55 | const token = this.storage.user() != null ? this.storage.user().token : null 56 | const nextProcess = ( c ) => { this.container = c; return c } 57 | 58 | // request 59 | switch (this.state.articleKind) { 60 | case "favorite_articles": return this.conduit.getArticlesForFavoriteUser(this.state.username, token, limit, offset).then( nextProcess ); break 61 | default: return this.conduit.getArticlesOfAuthor(this.state.username, token, limit, offset).then( nextProcess ); break 62 | } 63 | } 64 | 65 | pageCount = () => { 66 | if (this.container == null || this.container.count === 0) { 67 | return 0 68 | } 69 | const limit: number = Settings.shared().valueForKey("countOfArticleInView") 70 | return Math.floor(this.container.count / limit) 71 | } 72 | 73 | currentPage = () => { 74 | return this.state.page 75 | } 76 | 77 | tabItems = () => { 78 | const tabs: ArticleTabItem[] = [ 79 | new ArticleTabItem( "my_articles", "My Articles", (this.state.articleKind === "my_articles")) , 80 | new ArticleTabItem( "favorite_articles", "Favorite Articles", (this.state.articleKind === "favorite_articles")) 81 | ] 82 | return tabs 83 | } 84 | 85 | toggleFollowing = () => { 86 | const profile = this.profile 87 | if ( profile === null ) { throw Error("A profile is empty.") } 88 | // follow/unfollow 89 | const token = this.storage.user().token 90 | const username = profile.username 91 | const process = ( p ) => { this.profile = p; return p } 92 | switch (profile.following) { 93 | case true: return this.conduit.unfollow( token, username ).then( process ) 94 | case false: return this.conduit.follow( token, username ).then( process ) 95 | } 96 | } 97 | 98 | jumpPage = (page: number) => { 99 | const pathBuilder = new SPAPathBuilder(this.state.scene, [this.state.username, this.state.articleKind], { "page" : String(page) } ) 100 | location.href = pathBuilder.fullPath() 101 | } 102 | 103 | jumpToSubPath = (path: string) => { 104 | const pathBuilder = new SPAPathBuilder(this.state.scene, [this.state.username, path]) 105 | location.href = pathBuilder.fullPath() 106 | } 107 | 108 | jumpToSettingScene = () => { 109 | location.href = new SPAPathBuilder("settings").fullPath() 110 | } 111 | 112 | jumpToProfileScene = (profile: Profile ) => { 113 | location.href = new SPAPathBuilder("profile", [profile.username]).fullPath() 114 | } 115 | 116 | jumpToArticleScene = (article: Article) => { 117 | location.href = new SPAPathBuilder("article", [article.slug]).fullPath() 118 | } 119 | 120 | toggleFavorite = ( article: Article ): Promise => { 121 | if ( article === null ) { throw Error("Article is empty.") } 122 | const user = this.storage.user() 123 | if ( user === null ) { 124 | return new Promise( async (resolve, _) => { resolve(null) } ) 125 | } 126 | const process = ( article ) => { 127 | const filtered = this.container.articles.filter( a => a.slug === article.slug )[0] 128 | const index = this.container.articles.indexOf( filtered ) 129 | this.container.articles.splice(index, 1, article) 130 | return this.container.articles 131 | } 132 | switch (article.favorited) { 133 | case true: return this.conduit.unfavorite( user.token, article.slug ).then( process ) 134 | case false: return this.conduit.favorite( user.token, article.slug ).then( process ) 135 | } 136 | } 137 | } 138 | 139 | 140 | class ProfileState { 141 | scene: string 142 | username: string 143 | articleKind: string 144 | page: number 145 | 146 | constructor( location: SPALocation ) { 147 | 148 | // scene 149 | this.scene = location.scene() 150 | 151 | // username 152 | const paths = location.paths() 153 | if ( paths.length >= 1 ) { 154 | this.username = paths[0] 155 | } else { 156 | throw Error("A username is can't empty in this scene.") 157 | } 158 | 159 | // articleKind 160 | this.articleKind = ( paths.length === 2 ) ? paths[1] : "my_articles" 161 | if ( paths.length >= 3 ) { 162 | throw Error("Unexpected query of http.") 163 | } 164 | 165 | // page 166 | switch ( location.query() ) { 167 | case undefined: case null: this.page = 1; break 168 | default: 169 | const page = location.query()["page"] 170 | if ( page === undefined || page == null ) { this.page = 1 } 171 | else { this.page = Number(page) } 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/Domain/UseCase/RegisterUseCase.ts: -------------------------------------------------------------------------------- 1 | import ConduitProductionRepository from "../Repository/ConduitProductionRepository" 2 | import UserLocalStorageRepository from "../Repository/UserLocalStorageRepository" 3 | import MenuItemsBuilder from "../Utility/MenuItemsBuilder" 4 | import SPALocation from "../../Infrastructure/SPALocation" 5 | 6 | export default class RegisterUseCase { 7 | 8 | private conduit = new ConduitProductionRepository() 9 | private storage = new UserLocalStorageRepository() 10 | 11 | private state: RegisterState 12 | 13 | constructor() { 14 | this.state = new RegisterState( SPALocation.shared() ) 15 | } 16 | 17 | register = async ( username: string, email: string, password: string ) => { 18 | return this.conduit.register( username, email, password ).then((user) => { 19 | this.storage.setUser( user ) 20 | }) 21 | } 22 | 23 | menuItems = () => { 24 | return new MenuItemsBuilder().items( this.state.scene, this.storage.user() ) 25 | } 26 | } 27 | 28 | class RegisterState { 29 | scene: string 30 | 31 | constructor( location: SPALocation ) { 32 | // scene 33 | this.scene = location.scene() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Domain/UseCase/SettingsUseCase.ts: -------------------------------------------------------------------------------- 1 | import UserLocalStorageRepository from "../Repository/UserLocalStorageRepository" 2 | import PostUser from "../Model/PostUser" 3 | import MenuItemsBuilder from "../Utility/MenuItemsBuilder" 4 | import SPALocation from "../../Infrastructure/SPALocation" 5 | import ConduitProductionRepository from "../Repository/ConduitProductionRepository" 6 | 7 | export default class SettingsUseCase { 8 | 9 | private conduit = new ConduitProductionRepository() 10 | private storage = new UserLocalStorageRepository() 11 | 12 | private state: SettingsState 13 | 14 | constructor() { 15 | this.state = new SettingsState( SPALocation.shared() ) 16 | } 17 | 18 | isLoggedIn = () => { 19 | return this.storage.isLoggedIn() 20 | } 21 | 22 | loggedUser = () => { 23 | return this.storage.user() 24 | } 25 | 26 | menuItems = () => { 27 | return new MenuItemsBuilder().items( this.state.scene, this.storage.user() ) 28 | } 29 | 30 | post = (email: string, username: string, bio: string, image: string, password?: string) => { 31 | return this.conduit.updateUser( this.storage.user().token, new PostUser(email, username, bio, image, password) ) 32 | } 33 | 34 | logoutThenJumpToHome = () => { 35 | this.storage.setUser( null ) 36 | this.jumpToHome() 37 | } 38 | 39 | jumpToHome = () => { 40 | location.href = "/" 41 | } 42 | 43 | jumpToNotFound = () => { 44 | location.href = "#/notfound" 45 | } 46 | } 47 | 48 | class SettingsState { 49 | scene: string 50 | 51 | constructor( location: SPALocation ) { 52 | // scene 53 | this.scene = location.scene() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Domain/Utility/MenuItemsBuilder.ts: -------------------------------------------------------------------------------- 1 | import MenuItem from "../Model/MenuItem" 2 | import User from "../Model/User" 3 | 4 | export default class MenuItemsBuilder { 5 | 6 | items = ( scene: string, user?: User ) => { 7 | if (user !== null) { 8 | return [ 9 | new MenuItem( "articles", "Home", "#/", (scene === "articles")), 10 | new MenuItem( "editer", "New Article", "#/editer", (scene === "editer"), "ion-compose"), 11 | new MenuItem( "settings", "Settings", "#/settings", (scene === "settings"), "ion-gear-a"), 12 | new MenuItem( "profile", user.username, "#/profile/" + user.username, (scene === "profile"), null, user.image ) 13 | ] 14 | } else { 15 | return [ 16 | new MenuItem( "articles", "Home", "#/", (scene === "articles")), 17 | new MenuItem( "login", "Sign In", "#/login", (scene === "login")), 18 | new MenuItem( "register", "Sign Up", "#/register", (scene === "register")) 19 | ] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Infrastructure/HTTPURL.ts: -------------------------------------------------------------------------------- 1 | export default class HTTPURL { 2 | 3 | scheme: string 4 | host: string 5 | port?: number 6 | path?: string 7 | query?: { [key: string]: string} 8 | 9 | constructor( scheme: string, host: string, path?: string, query?: { [key: string]: string}, port?: number ) { 10 | this.scheme = scheme 11 | this.host = host 12 | this.path = path 13 | this.query = query 14 | this.port = port 15 | } 16 | 17 | fullPath = () => { 18 | return this.scheme + "://" + this.hostAndPort() + "/" + this.path + "?" + this.concatedQuery() 19 | } 20 | 21 | debugDescription = () => { 22 | for (const key in this) { 23 | const value: any = this[key] 24 | switch (typeof value) { 25 | case "object": 26 | console.log( key + " = " + JSON.stringify(value)) 27 | break 28 | case "string": 29 | console.log( key + " = " + value ) 30 | break 31 | } 32 | } 33 | console.log("fullPath = " + this.fullPath()) 34 | } 35 | 36 | private concatedQuery = () => { 37 | let concated = "" 38 | Object.keys(this.query).forEach((key, index, keys) => { 39 | concated += key + "=" + this.query[key] 40 | if (index !== keys.length - 1) { concated += "&" } 41 | }) 42 | return concated 43 | } 44 | 45 | private hostAndPort = () => { 46 | if ( this.port === null ) { 47 | return this.host 48 | } else { 49 | return this.host + ":" + this.port 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Infrastructure/HTTPURLParser.ts: -------------------------------------------------------------------------------- 1 | import HTTPURL from "./HTTPURL" 2 | 3 | interface HostAndPort { 4 | host: string, 5 | port?: number 6 | } 7 | 8 | /// Easliy HTTP(S) URL Parser for SPA. ftp, ssh, git and other is not supported ;) 9 | export default class HTTPURLParser { 10 | 11 | parse = ( urlString: string ): HTTPURL|null => { 12 | try { 13 | const scheme = this.parsedScheme(urlString) 14 | const host = this.parsedHost(urlString) 15 | const port = this.parsedPort(urlString) 16 | const path = this.parsedPath(urlString) 17 | const query = this.parsedQuery(urlString) 18 | if ( scheme == null || host == null || Number.isNaN(port) || (path === null && query !== null) ) { 19 | throw Error("urlString is not HTTPURL.") 20 | } 21 | // Success 22 | return new HTTPURL( scheme, host, path, query, port ) 23 | } catch (error) { 24 | if ( error instanceof Error) { 25 | console.log( error.message ) 26 | } 27 | // Failure 28 | return null 29 | } 30 | } 31 | 32 | private parsedScheme = (urlString: string) => { 33 | // scheme 34 | const slasher = "://" 35 | const index = urlString.indexOf(slasher) 36 | if (index === -1) { 37 | return null // not url 38 | } 39 | return urlString.substr(0, index) 40 | } 41 | 42 | private parsedHost = (urlString: string) => { 43 | return this.splitHostAndPort(this.parsedHostAndPort(urlString)).host 44 | } 45 | 46 | private parsedPort = (urlString: string) => { 47 | return this.splitHostAndPort(this.parsedHostAndPort(urlString)).port 48 | } 49 | 50 | private parsedPath = (urlString: string) => { 51 | const slasher = "://" 52 | const index = urlString.indexOf(slasher) 53 | if (index === -1) { 54 | return null // not url 55 | } 56 | const indexS = urlString.indexOf("/", index + slasher.length) 57 | if (indexS === -1) { 58 | return null // path is not found 59 | } 60 | // "?" terminate 61 | const indexQ = urlString.indexOf("?", indexS) 62 | if (indexQ !== -1) { 63 | const path = urlString.substr(indexS + 1, indexQ - indexS - 1) 64 | return path.length === 0 ? null : path 65 | } 66 | // path only 67 | const path = urlString.substr(indexS + 1) 68 | return path.length === 0 ? null : path 69 | } 70 | 71 | private parsedQuery = (urlString: string) => { 72 | // query 73 | const indexQ = urlString.indexOf("?") 74 | const query: { [key: string]: string} = {} 75 | if (indexQ === -1) { 76 | return null // query not found 77 | } 78 | if (urlString.split("?").length !== 2) { 79 | throw Error("Unexpected query.") 80 | } 81 | const queryString = urlString.split("?")[1] 82 | const keyValues = queryString.split("&") 83 | for (const i in keyValues) { 84 | const keyValue = keyValues[i] 85 | const arr = keyValue.split("=") 86 | if (arr.length !== 2) { 87 | throw Error("A query has unexpected key-value.") 88 | } 89 | query[arr[0]] = arr[1] 90 | } 91 | return query 92 | } 93 | 94 | private parsedHostAndPort = (urlString: string) => { 95 | // host 96 | const slasher = "://" 97 | const index = urlString.indexOf(slasher) 98 | const afterScheme = urlString.substr(index + slasher.length) 99 | 100 | // "/" terminate 101 | const indexS = afterScheme.indexOf("/") 102 | if (indexS !== -1) { 103 | return afterScheme.substr(0, indexS) 104 | } 105 | // "?" terminate 106 | const indexQ = afterScheme.indexOf("?") 107 | if (indexQ !== -1) { 108 | return afterScheme.substr(0, indexQ) 109 | } 110 | // host only 111 | return afterScheme 112 | } 113 | 114 | 115 | private splitHostAndPort = (hostWithPort: string): HostAndPort => { 116 | 117 | // port 118 | const indexC = hostWithPort.indexOf(":") 119 | if ( indexC !== -1 ) { 120 | const splitted = hostWithPort.split(":") 121 | if (splitted.length === 2) { 122 | return { host: splitted[0], port: Number(splitted[1]) } 123 | } 124 | throw Error("A host has multiple colon.") 125 | } 126 | return { host: hostWithPort, port: null } 127 | } 128 | } 129 | 130 | -------------------------------------------------------------------------------- /src/Infrastructure/Initializable.ts: -------------------------------------------------------------------------------- 1 | // This code is unuse. 2 | export default interface Initializable { 3 | /*static*/ init: (object: any) => T // Typescript is can't defined static method on Interface :( 4 | } 5 | 6 | // abstruct class Initializable { 7 | // static init: (object: any) => T // Typescript is can't use `this` keyword on abstract class ;( 8 | // } 9 | -------------------------------------------------------------------------------- /src/Infrastructure/SPALocation.ts: -------------------------------------------------------------------------------- 1 | import HTTPURLParser from "./HTTPURLParser" 2 | 3 | /** 4 | * Location parser in Hash Based Url 5 | */ 6 | export default class SPALocation { 7 | 8 | public static shared = () => { 9 | if ( SPALocation._instance === undefined ) { 10 | SPALocation._instance = new SPALocation() 11 | } 12 | SPALocation._instance.updateProperties() 13 | return SPALocation._instance 14 | } 15 | 16 | private static _instance: SPALocation 17 | 18 | private _scene?: string = null 19 | private _paths?: string[] = null 20 | private _query?: {[key: string]: string} = null 21 | 22 | constructor() { 23 | if (SPALocation._instance) { 24 | throw new Error("You must use the shared().") 25 | } 26 | SPALocation._instance = this 27 | } 28 | 29 | public scene = () => { return this._scene } 30 | public paths = () => { return this._paths } 31 | public query = () => { return this._query } 32 | 33 | private updateProperties = () => { 34 | try { 35 | if (location.hash === null || location.hash.length === 0 ) { 36 | throw Error("location is empty.") 37 | } 38 | const url = new HTTPURLParser().parse(location.href) 39 | const path = url.path 40 | if ( path === null ) { 41 | throw Error("A path is empty.") 42 | } 43 | const index = path.indexOf("#/") 44 | if ( index === -1 ) { 45 | throw Error("hashbang is not found.") 46 | } 47 | const str = path.substr( index + 2 ).replace(/\/$/, "") 48 | const splited = str.split("/") 49 | if ( splited.length < 1 ) { 50 | throw Error("A path is not splittable.") 51 | } 52 | this._scene = splited.shift() 53 | this._paths = splited.length > 0 ? splited : null 54 | this._query = url.query 55 | 56 | } catch (error) { 57 | this._scene = null 58 | this._paths = null 59 | this._query = null 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Infrastructure/SPAPathBuilder.ts: -------------------------------------------------------------------------------- 1 | import HTTPURLParser from "./HTTPURLParser" 2 | 3 | export default class SPAPathBuilder { 4 | 5 | private scene?: string 6 | private paths?: string[] 7 | private query?: {[key: string]: string} 8 | 9 | constructor( scene?: string, paths?: string[], query?: {[key: string]: string}) { 10 | this.scene = scene 11 | this.paths = paths 12 | this.query = query 13 | } 14 | 15 | path = () => { 16 | return this.sceneString() + this.pathString() + this.keyValueStrings() 17 | } 18 | 19 | fullPath = () => { 20 | const url = new HTTPURLParser().parse(location.href) 21 | return url.scheme + "://" + this.hostAndPort(url.host, url.port) + (this.path() === "" ? "" : "/" + this.path()) 22 | } 23 | 24 | private hostAndPort = (host: string, port?: number) => { 25 | if (port == null || port === 0) { 26 | return host 27 | } else { 28 | return host + ":" + port 29 | } 30 | } 31 | 32 | private keyValueStrings = () => { 33 | if ( this.query == null ) { return "" } 34 | return "?" + Object.entries( this.query ) 35 | .map((keyValue) => keyValue.join("=") ) 36 | .join("&") 37 | } 38 | 39 | private pathString = () => { 40 | if ( this.paths == null ) { return "" } 41 | return "/" + this.paths.join("/") 42 | } 43 | 44 | private sceneString = () => { 45 | if ( this.scene == null ) { return "" } 46 | return "#/" + this.scene 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Infrastructure/Settings.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Application Settings Container 3 | */ 4 | export default class Settings { 5 | 6 | public static shared = () => { 7 | if ( Settings._instance === undefined ) { 8 | Settings._instance = new Settings() 9 | } 10 | return Settings._instance 11 | } 12 | 13 | private static _instance: Settings 14 | 15 | private settings: Object 16 | constructor() { 17 | if (Settings._instance) { 18 | throw new Error("must use the shared().") 19 | } 20 | Settings._instance = this 21 | } 22 | public set = ( settings: Object ) => { 23 | this.settings = settings 24 | } 25 | public valueForKey = ( key: string ) => { 26 | if ( this.settings == null ) { 27 | throw new Error("Must be set.") 28 | } 29 | return this.settings[key] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Presentation/ApplicationController.ts: -------------------------------------------------------------------------------- 1 | // Usecase 2 | import ApplicationUseCase from "../Domain/UseCase/ApplicationUseCase" 3 | 4 | // Riot components 5 | import ArticlesComponent from "./ViewController/Articles.riot" 6 | import ArticleComponent from "./ViewController/Article.riot" 7 | import LoginComponent from "./ViewController/Login.riot" 8 | import RegisterComponent from "./ViewController/Register.riot" 9 | import EditerComponent from "./ViewController/Editer.riot" 10 | import ProfileComponent from "./ViewController/Profile.riot" 11 | import SettingsComponent from "./ViewController/Settings.riot" 12 | import ShowErrorComponent from "./ViewController/ShowError.riot" 13 | 14 | // Model 15 | import Scene from "../Domain/Model/Scene" 16 | 17 | export default class ApplicationController { 18 | 19 | // Usecase 20 | private useCase = new ApplicationUseCase() 21 | 22 | willFinishLaunching = () => { 23 | 24 | // Setup usecase 25 | this.useCase.setMainViewSelector("div#mainView") 26 | this.useCase.setScenes([ 27 | { name: "login", component: LoginComponent, filter: "/login" }, 28 | { name: "settings", component: SettingsComponent, filter: "/settings" }, 29 | { name: "articles", component: ArticlesComponent, filter: "/articles.." }, 30 | { name: "article", component: ArticleComponent, filter: "/article.." }, 31 | { name: "editer", component: EditerComponent, filter: "/editer.." }, 32 | { name: "profile", component: ProfileComponent, filter: "/profile.." }, 33 | { name: "register", component: RegisterComponent, filter: "/register" } 34 | ]) 35 | this.useCase.setHomeScene( { name: "articles", component: ArticlesComponent } ) 36 | this.useCase.setErrorScene( { name: "show_error", component: ShowErrorComponent } ) 37 | } 38 | 39 | didFinishLaunching = () => { 40 | 41 | const useCase = this.useCase 42 | useCase.initialize( ( error: Error ) => { 43 | if (error != null) { 44 | throw Error("Application initialize is failed: " + error.message ) 45 | } 46 | useCase.routing() 47 | useCase.showMainView() 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Presentation/View/ArticleTabView.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 20 | 21 |
22 | 27 |
28 | 29 |
30 | -------------------------------------------------------------------------------- /src/Presentation/View/ArticleView.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 20 | 21 | 27 | 28 | -------------------------------------------------------------------------------- /src/Presentation/View/ArticleWidgetView.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 45 | 46 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /src/Presentation/View/ArticlesTableView.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24 | 25 | 36 | 37 |
38 | 48 | actionOfClickArticle( article ) }> 49 |

{ article.title }

50 |

{ article.description }

51 | Read more... 52 |
    53 |
  • { tagWord }
  • 54 |
55 |
56 |
57 | 58 |
59 | -------------------------------------------------------------------------------- /src/Presentation/View/BannerView.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 20 | 21 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/Presentation/View/CommentFormView.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 20 | 21 | 22 |
23 | 24 | 25 |
26 | 27 |
28 | 29 | 30 | 42 | 43 |
44 | 45 |
46 | -------------------------------------------------------------------------------- /src/Presentation/View/CommentTableView.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 36 | 37 |
38 | 39 | 40 |
41 |

42 |

43 |

44 |
45 | 46 | 47 | 66 |
67 | 68 | -------------------------------------------------------------------------------- /src/Presentation/View/FooterView.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 | conduit 6 | 7 | An interactive learning project from Thinkster. Code & design licensed under MIT. 8 | 9 |
10 |
11 | 12 |
-------------------------------------------------------------------------------- /src/Presentation/View/HeaderView.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 22 | 23 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/Presentation/View/MarkdownView.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | -------------------------------------------------------------------------------- /src/Presentation/View/PagenationView.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 28 | 29 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/Presentation/View/ProfileView.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 28 | 29 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/Presentation/View/TagsView.riot: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /src/Presentation/ViewController/Article.riot: -------------------------------------------------------------------------------- 1 |
2 | 3 | 60 | 61 |
62 | 63 |
64 | 65 | 66 |
-------------------------------------------------------------------------------- /src/Presentation/ViewController/ArticleViewController.ts: -------------------------------------------------------------------------------- 1 | import { RiotCoreComponent } from "riot" 2 | import ArticleUseCase from "../../Domain/UseCase/ArticleUseCase" 3 | import Article from "../../Domain/Model/Article" 4 | 5 | export default class ArticleViewController { 6 | 7 | // Outlets 8 | view: RiotCoreComponent|any 9 | headerView: RiotCoreComponent|any 10 | aboveWidgetView: RiotCoreComponent|any 11 | belowWidgetView: RiotCoreComponent|any 12 | articleView: RiotCoreComponent|any 13 | commentFormView: RiotCoreComponent|any 14 | commentTableView: RiotCoreComponent|any 15 | 16 | // Usecase 17 | private useCase = new ArticleUseCase() 18 | 19 | // Lifecycle 20 | viewWillAppear = () => { 21 | // console.log("viewWillAppear") // No action 22 | } 23 | viewDidAppear = () => { 24 | // setup header 25 | this.headerView.setItems( this.useCase.menuItems() ) 26 | 27 | // setup profile 28 | if ( this.useCase.isLoggedIn() ) { 29 | let profile = this.useCase.loggedUserProfile() 30 | this.aboveWidgetView.setLoggedUserProfile( profile ) 31 | this.belowWidgetView.setLoggedUserProfile( profile ) 32 | this.commentTableView.setLoggedUserProfile( profile ) 33 | this.commentFormView.setProfile( profile ) 34 | } 35 | 36 | // setup article 37 | this.useCase.requestArticle().then( (article) => { 38 | this.articleView.setArticle( article ) 39 | this.setArticleForWidgets( article ) 40 | 41 | this.view.update() 42 | }) 43 | 44 | // setup comments 45 | this.useCase.requestComments().then( (comments) => { 46 | this.commentTableView.setComments( comments ) 47 | }) 48 | } 49 | 50 | // Public 51 | currentArticleTitle = () => { 52 | let article = this.useCase.currentArticle() 53 | return article !== null ? article.title : "" 54 | } 55 | 56 | // Actions 57 | didFollowHandler = () => { 58 | this.useCase.toggleFollowing().then( () => { 59 | this.setArticleForWidgets( this.useCase.currentArticle() ) 60 | }) 61 | } 62 | didArticleFavoriteHandler = () => { 63 | this.useCase.toggleFavorite().then( () => { 64 | this.setArticleForWidgets( this.useCase.currentArticle() ) 65 | }) 66 | } 67 | didArticleEditingHandler = () => { 68 | this.useCase.jumpToEditerScene() 69 | } 70 | didArticleDeleteHandler = () => { 71 | this.useCase.deleteArticle().then( () => { 72 | this.useCase.jumpToHome() 73 | }) 74 | } 75 | 76 | didCommentSubmitHandler = ( comment: string ) => { 77 | this.useCase.postComment( comment ).then( () => { 78 | this.commentTableView.setComments( this.useCase.currentComments() ) 79 | this.commentFormView.clearComment() 80 | }) 81 | } 82 | didCommentDeleteHandler = ( commentId: number ) => { 83 | this.useCase.deleteComment( commentId ).then( () => { 84 | this.commentTableView.setComments( this.useCase.currentComments() ) 85 | }) 86 | } 87 | 88 | // Private 89 | private setArticleForWidgets = ( article: Article ) => { 90 | this.aboveWidgetView.setArticle( article ) 91 | this.belowWidgetView.setArticle( article ) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Presentation/ViewController/Articles.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 58 | 59 | 60 |
61 | 62 |
63 |
64 | 65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | 80 | -------------------------------------------------------------------------------- /src/Presentation/ViewController/ArticlesViewController.ts: -------------------------------------------------------------------------------- 1 | import ArticlesUseCase from "../../Domain/UseCase/ArticlesUseCase" 2 | import { RiotCoreComponent } from "riot" 3 | import ArticleTabItem from "../../Domain/Model/ArticleTabItem" 4 | import Profile from "../../Domain/Model/Profile" 5 | import Article from "../../Domain/Model/Article" 6 | 7 | export default class ArticlesViewController { 8 | 9 | // Outlets 10 | view: RiotCoreComponent|any 11 | headerView: RiotCoreComponent|any 12 | bannerView: RiotCoreComponent|any 13 | articleTabView: RiotCoreComponent|any 14 | articlesTableView: RiotCoreComponent|any 15 | tagsView: RiotCoreComponent|any 16 | pagenationView: RiotCoreComponent|any 17 | 18 | // Usecase 19 | private useCase = new ArticlesUseCase() 20 | 21 | // Lifecycle 22 | viewWillAppear = () => { 23 | // console.log("viewWillAppear") // No action 24 | } 25 | 26 | viewDidAppear = () => { 27 | this.headerView.setItems( this.useCase.menuItems() ) 28 | this.bannerView.setVisible( !this.useCase.isLoggedIn() ) 29 | this.articleTabView.setItems( this.useCase.tabItems() ) 30 | 31 | this.useCase.requestArticles().then( (container) => { 32 | // setup table of article 33 | this.articlesTableView.setArticles( container.articles ) 34 | // setup pagenation 35 | this.pagenationView.setCountOfPage( this.useCase.pageCount(), this.useCase.currentPage() ) 36 | }) 37 | 38 | this.useCase.requestTags().then( (tags) => { 39 | this.tagsView.setTagWords( tags ) 40 | }) 41 | } 42 | 43 | // Actions 44 | didSelectTab = ( item: ArticleTabItem ) => { 45 | this.useCase.jumpToSubPath( item.identifier ) 46 | } 47 | didSelectProfile = ( profile: Profile ) => { 48 | this.useCase.jumpToProfileScene (profile) 49 | } 50 | didSelectArticle = ( article: Article ) => { 51 | this.useCase.jumpToArticleScene(article) 52 | } 53 | 54 | didFavoriteArticle = ( article: Article ) => { 55 | this.useCase.toggleFavorite(article).then( (articles) => { 56 | if ( articles === null ) { return } 57 | this.articlesTableView.setArticles( articles ) 58 | }) 59 | } 60 | didSelectPageNumber = ( page: number ) => { 61 | this.useCase.jumpPage(page) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Presentation/ViewController/Editer.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 47 | 48 |
49 | 50 |
51 |
52 |
53 |
54 | 55 |
    56 |
  • { message }
  • 57 |
58 | 59 |
60 |
61 |
62 | 63 |
64 |
65 | 66 |
67 |
68 | 69 |
70 |
71 |
72 |
73 | 76 |
77 |
78 | 79 |
80 |
81 |
82 |
83 | 84 |
85 | 86 | 87 | -------------------------------------------------------------------------------- /src/Presentation/ViewController/EditerViewController.ts: -------------------------------------------------------------------------------- 1 | import { RiotCoreComponent } from "riot" 2 | import EditerUseCase from "../../Domain/UseCase/EditerUseCase" 3 | 4 | export default class EditerViewController { 5 | 6 | // Outlets 7 | view: RiotCoreComponent|any 8 | headerView: RiotCoreComponent|any 9 | 10 | // Usecase 11 | private useCase = new EditerUseCase() 12 | 13 | // Lifecycle 14 | viewWillAppear = () => { 15 | if ( this.useCase.isLoggedIn() === false ) { 16 | this.useCase.jumpToNotFound() 17 | } 18 | } 19 | viewDidAppear = () => { 20 | this.headerView.setItems( this.useCase.menuItems() ) 21 | 22 | // request article 23 | this.useCase.ifNeededRequestArticle().then( (article) => { 24 | if (article == null ) { return } 25 | // setup form 26 | this.view.setArticle( article.title, article.description, article.body, article.tagList.join(",") ) 27 | }) 28 | } 29 | 30 | postArticle = ( title: string, description: string , body: string, tagsString: string ) => { 31 | this.useCase.post(title, description, body, tagsString).then( (article) => { 32 | // success 33 | this.useCase.jumpToArticleScene(article) 34 | }).catch( (error) => { 35 | // failure 36 | if (error instanceof Array ) { 37 | this.view.setErrorMessages( error.map( (aError) => aError.message ) ) 38 | } else if ( error instanceof Error ) { 39 | this.view.setErrorMessages( [ error.message ] ) 40 | } 41 | }) 42 | } 43 | 44 | submitButtonTitle = () => { 45 | return this.useCase.isNewArticle() ? "Publish Article" : "Update Article" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Presentation/ViewController/Login.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 40 | 41 |
42 | 43 |
44 |
45 |
46 | 47 |
48 |

Sign In

49 |

50 | Need an account? 51 |

52 | 53 |
    54 |
  • { message }
  • 55 |
56 | 57 |
58 | 59 |
60 |
61 | 62 |
63 | 66 |
67 | 68 |
69 |
70 |
71 | 72 |
73 | 74 | 75 | -------------------------------------------------------------------------------- /src/Presentation/ViewController/LoginViewController.ts: -------------------------------------------------------------------------------- 1 | import { RiotCoreComponent } from "riot" 2 | import LoginUseCase from "../../Domain/UseCase/LoginUseCase" 3 | 4 | export default class LoginViewController { 5 | 6 | // Outlets 7 | view: RiotCoreComponent|any 8 | headerView: RiotCoreComponent|any 9 | 10 | // Usecase 11 | private useCase = new LoginUseCase() 12 | 13 | // Lifecycle 14 | viewWillAppear = () => { 15 | // console.log("viewWillAppear") // No action 16 | } 17 | viewDidAppear = () => { 18 | this.headerView.setItems( this.useCase.menuItems() ) 19 | } 20 | 21 | // Public 22 | login = ( email: string, password: string ) => { 23 | this.useCase.login( email, password ).then( () => { 24 | // success 25 | window.location.href = "/" 26 | }).catch( (error) => { 27 | // failure 28 | if (error instanceof Array ) { 29 | this.view.setErrorMessages( error.map( (aError) => aError.message ) ) 30 | } else if ( error instanceof Error ) { 31 | this.view.setErrorMessages( [ error.message ] ) 32 | } 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Presentation/ViewController/Profile.riot: -------------------------------------------------------------------------------- 1 | 2 | 3 | 56 | 57 |
58 | 59 |
60 | 61 | 62 |