├── frontend ├── src │ ├── assets │ │ └── .gitkeep │ ├── app │ │ ├── app.component.scss │ │ ├── dashboard │ │ │ ├── pages │ │ │ │ ├── home │ │ │ │ │ ├── home.component.scss │ │ │ │ │ ├── home.component.html │ │ │ │ │ └── home.component.ts │ │ │ │ ├── explore │ │ │ │ │ ├── explore.component.scss │ │ │ │ │ ├── explore.component.html │ │ │ │ │ └── explore.component.ts │ │ │ │ ├── tweet │ │ │ │ │ ├── tweet.component.scss │ │ │ │ │ ├── tweet.component.html │ │ │ │ │ └── tweet.component.ts │ │ │ │ ├── followers │ │ │ │ │ ├── followers.component.scss │ │ │ │ │ ├── followers.component.html │ │ │ │ │ └── followers.component.ts │ │ │ │ ├── settings │ │ │ │ │ ├── settings.component.scss │ │ │ │ │ ├── settings.component.html │ │ │ │ │ └── settings.component.ts │ │ │ │ ├── followings │ │ │ │ │ ├── followings.component.scss │ │ │ │ │ ├── followings.component.html │ │ │ │ │ └── followings.component.ts │ │ │ │ ├── profile │ │ │ │ │ ├── topics-form │ │ │ │ │ │ ├── topics-form.component.scss │ │ │ │ │ │ ├── topics-form.component.html │ │ │ │ │ │ └── topics-form.component.ts │ │ │ │ │ ├── likes │ │ │ │ │ │ ├── likes.component.scss │ │ │ │ │ │ ├── likes.component.html │ │ │ │ │ │ └── likes.component.ts │ │ │ │ │ ├── tweets │ │ │ │ │ │ ├── tweets.component.scss │ │ │ │ │ │ ├── tweets.component.html │ │ │ │ │ │ └── tweets.component.ts │ │ │ │ │ ├── tweets-and-replies │ │ │ │ │ │ ├── tweets-and-replies.component.scss │ │ │ │ │ │ ├── tweets-and-replies.component.html │ │ │ │ │ │ └── tweets-and-replies.component.ts │ │ │ │ │ ├── edit-form │ │ │ │ │ │ ├── edit-form.component.scss │ │ │ │ │ │ ├── edit-form.component.ts │ │ │ │ │ │ └── edit-form.component.html │ │ │ │ │ ├── profile.component.scss │ │ │ │ │ ├── profile.component.ts │ │ │ │ │ └── profile.component.html │ │ │ │ └── notifications │ │ │ │ │ ├── notifications.component.scss │ │ │ │ │ ├── notifications.component.html │ │ │ │ │ └── notifications.component.ts │ │ │ ├── components │ │ │ │ ├── follower │ │ │ │ │ ├── follower.component.scss │ │ │ │ │ ├── follower.component.ts │ │ │ │ │ └── follower.component.html │ │ │ │ ├── tweet-form │ │ │ │ │ ├── tweet-form.component.scss │ │ │ │ │ ├── tweet-form.component.html │ │ │ │ │ └── tweet-form.component.ts │ │ │ │ ├── spin │ │ │ │ │ ├── spin.component.scss │ │ │ │ │ ├── spin.component.html │ │ │ │ │ └── spin.component.ts │ │ │ │ ├── notification │ │ │ │ │ ├── notification.component.scss │ │ │ │ │ ├── notification.component.html │ │ │ │ │ └── notification.component.ts │ │ │ │ ├── tweet │ │ │ │ │ ├── tweet.component.scss │ │ │ │ │ ├── tweet.component.ts │ │ │ │ │ └── tweet.component.html │ │ │ │ └── base-page │ │ │ │ │ ├── base-page.component.scss │ │ │ │ │ ├── base-page.component.ts │ │ │ │ │ └── base-page.component.html │ │ │ ├── dashboard-routing.module.ts │ │ │ ├── models.ts │ │ │ └── dashboard.module.ts │ │ ├── app.component.html │ │ ├── layouts │ │ │ └── auth │ │ │ │ ├── auth.component.scss │ │ │ │ ├── auth.component.ts │ │ │ │ └── auth.component.html │ │ ├── pages │ │ │ ├── register │ │ │ │ ├── register.component.scss │ │ │ │ ├── register.component.ts │ │ │ │ └── register.component.html │ │ │ └── login │ │ │ │ ├── login.component.scss │ │ │ │ ├── login.component.ts │ │ │ │ └── login.component.html │ │ ├── services │ │ │ ├── index.ts │ │ │ ├── app.service.ts │ │ │ ├── notification.service.ts │ │ │ ├── auth.guard.ts │ │ │ ├── home.guard.ts │ │ │ ├── tweet.service.ts │ │ │ ├── auth.service.ts │ │ │ ├── http.interceptor.ts │ │ │ ├── api.service.ts │ │ │ └── user.service.ts │ │ ├── app.component.ts │ │ ├── shared │ │ │ ├── pipes │ │ │ │ └── first-error.pipe.ts │ │ │ └── shared.module.ts │ │ ├── app-routing.module.ts │ │ ├── app.module.ts │ │ └── util.ts │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── favicon.ico │ ├── index.html │ ├── theme.less │ ├── main.ts │ ├── test.ts │ ├── polyfills.ts │ └── styles.scss ├── .editorconfig ├── tsconfig.app.json ├── tsconfig.spec.json ├── .browserslistrc ├── .gitignore ├── README.md ├── package.json ├── tsconfig.json ├── karma.conf.js └── angular.json ├── database.sqlite ├── public ├── cover.jpg ├── avatar.jpg ├── screenshot1.png ├── screenshot2.png └── index.html ├── tests ├── avatar.jpg ├── cover.jpg ├── tsconfig.json ├── .nycrc.json ├── App │ └── Controllers │ │ ├── AuthControllerTest.ts │ │ ├── HomeControllerTest.ts │ │ ├── TweetControllerTest.ts │ │ └── UserControllerTest.ts └── TestCase.ts ├── Models ├── Media.ts ├── Topic.ts ├── Like.ts ├── Notification.ts ├── User.ts └── Tweet.ts ├── .env.example ├── app.ts ├── Forms ├── TopicsForm.ts ├── LoginForm.ts ├── TweetForm.ts ├── UserForm.ts └── RegisterForm.ts ├── Services └── AppErrorHandler.ts ├── index.ts ├── .mocharc.json ├── .gitignore ├── config ├── auth.ts ├── app.ts └── database.ts ├── Controllers └── Http │ ├── TopicsController.ts │ ├── NotificationsController.ts │ ├── AuthController.ts │ ├── HomeController.ts │ ├── UsersController.ts │ └── TweetsController.ts ├── Providers ├── AppProvider.ts └── RoutingProvider.ts ├── Entities ├── Media.ts ├── Like.ts ├── Hashtag.ts ├── Topic.ts ├── Notification.ts ├── Tweet.ts └── User.ts ├── Validators └── IsUsername.ts ├── Readme.md ├── tsconfig.json ├── LICENSE.md ├── package.json └── tslint.json /frontend/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/home/home.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/explore/explore.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/tweet/tweet.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/followers/followers.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/settings/settings.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/follower/follower.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/followings/followings.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/topics-form/topics-form.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/settings/settings.component.html: -------------------------------------------------------------------------------- 1 |

In progress

2 | -------------------------------------------------------------------------------- /database.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typetron/example-twitter-clone/HEAD/database.sqlite -------------------------------------------------------------------------------- /frontend/src/app/layouts/auth/auth.component.scss: -------------------------------------------------------------------------------- 1 | .auth-container { 2 | height: 70%; 3 | } 4 | -------------------------------------------------------------------------------- /public/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typetron/example-twitter-clone/HEAD/public/cover.jpg -------------------------------------------------------------------------------- /tests/avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typetron/example-twitter-clone/HEAD/tests/avatar.jpg -------------------------------------------------------------------------------- /tests/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typetron/example-twitter-clone/HEAD/tests/cover.jpg -------------------------------------------------------------------------------- /public/avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typetron/example-twitter-clone/HEAD/public/avatar.jpg -------------------------------------------------------------------------------- /frontend/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /public/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typetron/example-twitter-clone/HEAD/public/screenshot1.png -------------------------------------------------------------------------------- /public/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typetron/example-twitter-clone/HEAD/public/screenshot2.png -------------------------------------------------------------------------------- /frontend/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typetron/example-twitter-clone/HEAD/frontend/src/favicon.ico -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": [ 4 | "./**/*.ts" 5 | ] 6 | } 7 | 8 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/likes/likes.component.scss: -------------------------------------------------------------------------------- 1 | app-tweet { 2 | border-top: 24px solid #f0f2f5; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/tweets/tweets.component.scss: -------------------------------------------------------------------------------- 1 | app-tweet { 2 | border-top: 24px solid #f0f2f5; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/notifications/notifications.component.scss: -------------------------------------------------------------------------------- 1 | app-notification { 2 | box-shadow: 0 0 10px #0000001a; 3 | } 4 | -------------------------------------------------------------------------------- /Models/Media.ts: -------------------------------------------------------------------------------- 1 | import { Field, Model } from '@Typetron/Models' 2 | 3 | export class Media extends Model { 4 | @Field() 5 | path: string 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/tweets-and-replies/tweets-and-replies.component.scss: -------------------------------------------------------------------------------- 1 | app-tweet { 2 | border-top: 24px solid #f0f2f5; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/tweet-form/tweet-form.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | 4 | textarea { 5 | font-size: 110%; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/spin/spin.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | justify-content: center; 4 | 5 | [nz-icon] { 6 | font-size: 30px; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/explore/explore.component.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | databaseDriver = sqlite 2 | database = database.sqlite 3 | # databaseHost = host 4 | # databaseUser = host-user 5 | # databasePassword = host-user-password 6 | # database = host-database-name 7 | -------------------------------------------------------------------------------- /Models/Topic.ts: -------------------------------------------------------------------------------- 1 | import { Field, Model } from '@Typetron/Models' 2 | 3 | export class Topic extends Model { 4 | @Field() 5 | id: number 6 | 7 | @Field() 8 | name: string 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/app/pages/register/register.component.scss: -------------------------------------------------------------------------------- 1 | .form { 2 | max-width: 300px; 3 | } 4 | 5 | .form-margin { 6 | margin-bottom: 16px; 7 | } 8 | 9 | .form-button { 10 | width: 100%; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/spin/spin.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/edit-form/edit-form.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | ::ng-deep { 3 | .ant-avatar-string { 4 | transform: translateX(-50%) !important; 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Models/Like.ts: -------------------------------------------------------------------------------- 1 | import { Field, Model } from '@Typetron/Models' 2 | import { User } from './User' 3 | 4 | export class Like extends Model { 5 | @Field() 6 | id: number 7 | 8 | @Field() 9 | user: User 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/tweet/tweet.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /app.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | import 'reflect-metadata' 3 | import 'source-map-support/register' 4 | import '@Typetron/Support' 5 | import { Application } from '@Typetron/Framework' 6 | 7 | export const appBuilder = Application.create(__dirname) 8 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/notification/notification.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | padding: 16px; 3 | display: block; 4 | background-color: #fff; 5 | 6 | .icon { 7 | font-size: 30px; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/app/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api.service' 2 | export * from './app.service' 3 | export * from './auth.service' 4 | export * from './user.service' 5 | export * from './notification.service' 6 | export * from './tweet.service' 7 | -------------------------------------------------------------------------------- /Forms/TopicsForm.ts: -------------------------------------------------------------------------------- 1 | import { Field, Form, Rules } from '@Typetron/Forms' 2 | import { Required } from '@Typetron/Validation' 3 | 4 | export class TopicsForm extends Form { 5 | @Field() 6 | @Rules(Required) 7 | topics: number[] = [] 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/tweet/tweet.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | width: 100%; 3 | padding: 16px; 4 | display: block; 5 | background-color: #fff; 6 | 7 | .buttons * { 8 | font-size: 16px; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/likes/likes.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /Services/AppErrorHandler.ts: -------------------------------------------------------------------------------- 1 | import { ErrorHandler, Request } from '@Typetron/Router/Http' 2 | 3 | export class AppErrorHandler extends ErrorHandler { 4 | 5 | handle(error: Error, request?: Request) { 6 | return super.handle(error, request); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/app/layouts/auth/auth.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core' 2 | 3 | @Component({ 4 | selector: 'app-auth', 5 | templateUrl: './auth.component.html', 6 | styleUrls: ['./auth.component.scss'] 7 | }) 8 | export class AuthComponent {} 9 | -------------------------------------------------------------------------------- /frontend/src/app/services/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs' 2 | import { Injectable } from '@angular/core' 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class AppService { 8 | 9 | scroll$ = new Subject() 10 | 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/tweets-and-replies/tweets-and-replies.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/spin/spin.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core' 2 | 3 | @Component({ 4 | selector: 'app-spin', 5 | templateUrl: './spin.component.html', 6 | styleUrls: ['./spin.component.scss'] 7 | }) 8 | export class SpinComponent {} 9 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { appBuilder } from 'App/app' 2 | import { AppConfig } from '@Typetron/Framework' 3 | 4 | appBuilder.then(app => { 5 | app.startServer() 6 | const config = app.get(AppConfig) 7 | console.log(`Typetron app started at: http://localhost:${config.port}`) 8 | }) 9 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension": [ 3 | "ts" 4 | ], 5 | "require": [ 6 | "reflect-metadata", 7 | "ts-node/register", 8 | "tsconfig-paths/register", 9 | "source-map-support/register", 10 | "@typetron/framework/Support" 11 | ], 12 | "spec": "tests/**/*Test.ts" 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html', 6 | styleUrls: ['./app.component.scss'] 7 | }) 8 | export class AppComponent { 9 | title = 'frontend'; 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/app/pages/login/login.component.scss: -------------------------------------------------------------------------------- 1 | .login-form { 2 | max-width: 300px; 3 | } 4 | 5 | .login-form-margin { 6 | margin-bottom: 16px; 7 | } 8 | 9 | .login-form-forgot { 10 | float: right; 11 | } 12 | 13 | .login-form-button { 14 | width: 100%; 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .idea 4 | .nyc_output 5 | coverage 6 | .env 7 | database-local.sqlite 8 | database-local2.sqlite 9 | public/* 10 | frontend-old 11 | !public/index.html 12 | !public/avatar.jpg 13 | !public/cover.jpg 14 | !public/screenshot1.png 15 | !public/screenshot2.png 16 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/explore/explore.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core' 2 | 3 | @Component({ 4 | selector: 'app-explore', 5 | templateUrl: './explore.component.html', 6 | styleUrls: ['./explore.component.scss'] 7 | }) 8 | export class ExploreComponent { 9 | 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/home/home.component.html: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | 8 |
9 | 10 | -------------------------------------------------------------------------------- /Forms/LoginForm.ts: -------------------------------------------------------------------------------- 1 | import { Field, Form, Rules } from '@Typetron/Forms' 2 | import { Required } from '@Typetron/Validation' 3 | 4 | export class LoginForm extends Form { 5 | 6 | @Field() 7 | @Rules(Required) 8 | username: string 9 | 10 | @Field() 11 | @Rules(Required) 12 | password: string 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/app/layouts/auth/auth.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Tweetee

5 |
6 | 7 | 8 |
9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/.nycrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "check-coverage": true, 3 | "lines": 80, 4 | "statements": 80, 5 | "functions": 80, 6 | "branches": 80, 7 | "include": [ 8 | "**/*.ts" 9 | ], 10 | "exclude": [ 11 | "node_modules", 12 | "test" 13 | ], 14 | "reporter": [ 15 | "lcov", 16 | "text-summary" 17 | ], 18 | "all": true 19 | } 20 | -------------------------------------------------------------------------------- /config/auth.ts: -------------------------------------------------------------------------------- 1 | import { AuthConfig } from '@Typetron/Framework' 2 | import { User } from 'App/Entities/User' 3 | 4 | export default new AuthConfig({ 5 | duration: 3600, 6 | entity: User, 7 | signature: process.env.APP_SECRET || '696?sX}Fqp,|3/$txG)5PJSVoAnCj|pW03ytqLbNX/9o,Q)5O>z4x3AV7aIDlbd', 8 | saltRounds: 12, 9 | }) 10 | -------------------------------------------------------------------------------- /Forms/TweetForm.ts: -------------------------------------------------------------------------------- 1 | import { Field, Form, Rules } from '@Typetron/Forms' 2 | import { Required } from '@Typetron/Validation' 3 | import { File } from '@Typetron/Storage' 4 | 5 | export class TweetForm extends Form { 6 | 7 | @Field() 8 | @Rules(Required) 9 | content: string 10 | 11 | @Field() 12 | media: File | File[] = [] 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core' 2 | import { Subject } from 'rxjs' 3 | 4 | @Component({ 5 | selector: 'app-home', 6 | templateUrl: './home.component.html', 7 | styleUrls: ['./home.component.scss'] 8 | }) 9 | export class HomeComponent { 10 | update$ = new Subject() 11 | } 12 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tweetee 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Controllers/Http/TopicsController.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@Typetron/Router' 2 | import { Topic as TopicModel } from 'App/Models/Topic' 3 | import { Topic } from 'App/Entities/Topic' 4 | 5 | @Controller('topics') 6 | export class TopicsController { 7 | 8 | @Get() 9 | async get() { 10 | return TopicModel.from(Topic.get()) 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/theme.less: -------------------------------------------------------------------------------- 1 | // Custom Theming for NG-ZORRO 2 | // For more information: https://ng.ant.design/docs/customize-theme/en 3 | @import "../node_modules/ng-zorro-antd/ng-zorro-antd.less"; 4 | 5 | // Override less variables to here 6 | // View all variables: https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/components/style/themes/default.less 7 | 8 | // @primary-color: #1890ff; 9 | -------------------------------------------------------------------------------- /Providers/AppProvider.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '@Typetron/Framework' 2 | import { ErrorHandlerInterface } from '@Typetron/Router/Http' 3 | import { AppErrorHandler } from 'App/Services/AppErrorHandler' 4 | 5 | export class AppProvider extends Provider { 6 | 7 | async register() { 8 | this.app.set(ErrorHandlerInterface, this.app.get(AppErrorHandler)) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/app/shared/pipes/first-error.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core' 2 | 3 | @Pipe({ 4 | name: 'firstError' 5 | }) 6 | export class FirstErrorPipe implements PipeTransform { 7 | 8 | // tslint:disable-next-line:no-any 9 | transform(errors: Record | null): string { 10 | return Object.values(errors ?? {})[0] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/followers/followers.component.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts", 10 | "src/polyfills.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/followings/followings.component.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Entities/Media.ts: -------------------------------------------------------------------------------- 1 | import { BelongsTo, Column, Entity, Options, PrimaryColumn, Relation } from '@Typetron/Database' 2 | import { Tweet } from './Tweet' 3 | 4 | @Options({ 5 | table: 'media' 6 | }) 7 | export class Media extends Entity { 8 | @PrimaryColumn() 9 | id: number 10 | 11 | @Column() 12 | path: string 13 | 14 | @Relation(() => Tweet, 'media') 15 | tweet: BelongsTo 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/settings/settings.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core' 2 | 3 | @Component({ 4 | selector: 'app-settings', 5 | templateUrl: './settings.component.html', 6 | styleUrls: ['./settings.component.scss'] 7 | }) 8 | export class SettingsComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit(): void { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /Models/Notification.ts: -------------------------------------------------------------------------------- 1 | import { Field, Model } from '@Typetron/Models' 2 | import { User } from './User' 3 | import { Tweet } from './Tweet' 4 | 5 | export class Notification extends Model { 6 | @Field() 7 | id: number 8 | 9 | @Field() 10 | type: 'follow' | 'like' | 'reply' | 'retweet' | 'mention' 11 | 12 | @Field() 13 | notifiers: User[] = [] 14 | 15 | @Field() 16 | tweet: Tweet 17 | } 18 | -------------------------------------------------------------------------------- /Validators/IsUsername.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '@Typetron/Validation' 2 | 3 | export class IsUsername extends Rule { 4 | identifier = 'isUsername' 5 | 6 | passes(attribute: string, value: string): boolean { 7 | return Boolean((value ?? '').match(/^[0-9a-zA-Z_]+$/)) 8 | } 9 | 10 | message(attribute: string): string { 11 | return `The username can only contain numbers, letters and '_'` 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/app/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core' 2 | import { CommonModule } from '@angular/common' 3 | import { FirstErrorPipe } from './pipes/first-error.pipe' 4 | 5 | @NgModule({ 6 | declarations: [ 7 | FirstErrorPipe 8 | ], 9 | exports: [ 10 | FirstErrorPipe 11 | ], 12 | imports: [ 13 | CommonModule 14 | ] 15 | }) 16 | export class SharedModule {} 17 | -------------------------------------------------------------------------------- /tests/App/Controllers/AuthControllerTest.ts: -------------------------------------------------------------------------------- 1 | import { suite, test } from '@testdeck/mocha' 2 | import { expect } from 'chai' 3 | import { TestCase } from 'Tests/TestCase' 4 | import { Http } from '@Typetron/Router/Http' 5 | 6 | @suite 7 | class AuthControllerTest extends TestCase { 8 | 9 | @test 10 | async login() { 11 | const response = await this.post('login') 12 | expect(response.status).to.be.equal(Http.Status.UNPROCESSABLE_ENTITY) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import '@Typetron/Support' 3 | import { enableProdMode } from '@angular/core' 4 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic' 5 | 6 | import { AppModule } from './app/app.module' 7 | import { environment } from './environments/environment' 8 | 9 | if (environment.production) { 10 | enableProdMode() 11 | } 12 | 13 | platformBrowserDynamic().bootstrapModule(AppModule) 14 | .catch(err => console.error(err)) 15 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/tweets/tweets.component.html: -------------------------------------------------------------------------------- 1 | 2 |

3 | There are no tweets here 4 |

5 |

6 | Subscribe to some topics or post a few tweets. 7 |

8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/follower/follower.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core' 2 | import { User } from '@Data/Models/User' 3 | import { environment } from '../../../../environments/environment' 4 | 5 | @Component({ 6 | selector: 'app-follower', 7 | templateUrl: './follower.component.html', 8 | styleUrls: ['./follower.component.scss'] 9 | }) 10 | export class FollowerComponent { 11 | 12 | imgPath = environment.apiUrl 13 | 14 | @Input() user!: User 15 | @Input() authUser!: User 16 | } 17 | -------------------------------------------------------------------------------- /Entities/Like.ts: -------------------------------------------------------------------------------- 1 | import { BelongsTo, CreatedAt, Entity, Options, PrimaryColumn, Relation } from '@Typetron/Database' 2 | import { Tweet } from 'App/Entities/Tweet' 3 | import { User } from 'App/Entities/User' 4 | 5 | @Options({ 6 | table: 'likes' 7 | }) 8 | export class Like extends Entity { 9 | @PrimaryColumn() 10 | id: number 11 | 12 | @Relation(() => Tweet, 'likes') 13 | tweet: BelongsTo 14 | 15 | @Relation(() => User, 'likes') 16 | user: BelongsTo 17 | 18 | @CreatedAt() 19 | createdAt: Date 20 | } 21 | -------------------------------------------------------------------------------- /tests/App/Controllers/HomeControllerTest.ts: -------------------------------------------------------------------------------- 1 | import { suite, test } from '@testdeck/mocha' 2 | import { expect } from 'chai' 3 | import { TestCase } from 'Tests/TestCase' 4 | 5 | @suite 6 | class HomeControllerTest extends TestCase { 7 | 8 | async before() { 9 | await super.before() 10 | await this.actingAs(await this.createUser()) 11 | } 12 | 13 | @test 14 | async showsLatestTweets() { 15 | const response = await this.get('tweets') 16 | expect(response.body).to.be.instanceOf(Array) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Forms/UserForm.ts: -------------------------------------------------------------------------------- 1 | import { Field, Form, Rules } from '@Typetron/Forms' 2 | import { File } from '@Typetron/Storage' 3 | import { Required } from '@Typetron/Validation' 4 | import { IsUsername } from 'App/Validators/IsUsername' 5 | 6 | export class UserForm extends Form { 7 | @Field() 8 | @Rules(Required) 9 | name: string 10 | 11 | @Field() 12 | @Rules(Required, IsUsername) 13 | username: string 14 | 15 | @Field() 16 | bio?: string 17 | 18 | @Field() 19 | photo?: File 20 | 21 | @Field() 22 | cover?: File 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/notifications/notifications.component.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |

There are no notifications yet!

12 | -------------------------------------------------------------------------------- /Entities/Hashtag.ts: -------------------------------------------------------------------------------- 1 | import { BelongsTo, BelongsToMany, Column, Entity, Options, PrimaryColumn, Relation } from '@Typetron/Database' 2 | import { Topic } from 'App/Entities/Topic' 3 | import { Tweet } from 'App/Entities/Tweet' 4 | 5 | @Options({ 6 | table: 'hashtags' 7 | }) 8 | export class Hashtag extends Entity { 9 | @PrimaryColumn() 10 | id: number 11 | 12 | @Column() 13 | name: string 14 | 15 | @Relation(() => Topic, 'hashtags') 16 | topic: BelongsTo 17 | 18 | @Relation(() => Tweet, 'hashtags') 19 | tweets: BelongsToMany 20 | } 21 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Twitter clone app made with Typetron 2 | 3 | ![Home screen](public/screenshot1.png) 4 | ![Profile page](public/screenshot1.png) 5 | 6 | This project was created as a result of going through 7 | the [Twitter clone app tutorial](https://typetron.org/tutorials/twitter-clone/) presented on 8 | the [Typetron](http://typetron.org/) website. 9 | 10 | ## Getting started 11 | 12 | ```shell script 13 | npm install 14 | npm start 15 | ``` 16 | 17 | ```shell script 18 | cd frontend 19 | npm install 20 | npm start 21 | ``` 22 | 23 | Open [http://localhost:4200](http://localhost:4200) to check the app. 24 | -------------------------------------------------------------------------------- /Providers/RoutingProvider.ts: -------------------------------------------------------------------------------- 1 | import { AppConfig, Provider, RootDir } from '@Typetron/Framework' 2 | import { Router } from '@Typetron/Router' 3 | import { Inject } from '@Typetron/Container' 4 | 5 | export class RoutingProvider extends Provider { 6 | directory = 'Controllers' 7 | 8 | @Inject() 9 | appConfig: AppConfig 10 | 11 | @Inject() 12 | router: Router 13 | 14 | @Inject() 15 | rootDir: RootDir 16 | 17 | register() { 18 | this.router.middleware = this.appConfig.middleware || [] 19 | 20 | this.router.loadControllers(this.rootDir + '/' + this.directory) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Forms/RegisterForm.ts: -------------------------------------------------------------------------------- 1 | import { Field, Form, Rules } from '@Typetron/Forms' 2 | import { Email, MinLength, Required } from '@Typetron/Validation' 3 | import { IsUsername } from 'App/Validators/IsUsername' 4 | 5 | export class RegisterForm extends Form { 6 | 7 | @Field() 8 | @Rules(Required, Email) 9 | email: string 10 | 11 | @Field() 12 | @Rules(Required, IsUsername) 13 | username: string 14 | 15 | @Field() 16 | @Rules(Required, MinLength(6)) 17 | password: string 18 | 19 | @Field() 20 | @Rules(Required('Password confirmation is required')) 21 | passwordConfirmation: string 22 | } 23 | -------------------------------------------------------------------------------- /Entities/Topic.ts: -------------------------------------------------------------------------------- 1 | import { BelongsToMany, Column, Entity, HasMany, Options, PrimaryColumn, Relation } from '@Typetron/Database' 2 | import { User } from 'App/Entities/User' 3 | import { Hashtag } from 'App/Entities/Hashtag' 4 | 5 | @Options({ 6 | table: 'topics' 7 | }) 8 | export class Topic extends Entity { 9 | @PrimaryColumn() 10 | id: number 11 | 12 | @Column() 13 | name: string 14 | 15 | @Relation(() => User, 'topics') 16 | enthusiasts: BelongsToMany // `followers` can be used as well but it will be confused with user.followers 17 | 18 | @Relation(() => Hashtag, 'topic') 19 | hashtags: HasMany 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/app/services/notification.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core' 2 | import { ApiService } from './api.service' 3 | import { Notification } from '@Data/Models/Notification' 4 | import { HttpClient } from '@angular/common/http' 5 | 6 | @Injectable({ 7 | providedIn: 'root' 8 | }) 9 | export class NotificationService extends ApiService { 10 | 11 | constructor(http: HttpClient) { 12 | super(http) 13 | } 14 | 15 | list(): Promise { 16 | return this.get('notifications') 17 | } 18 | 19 | read(): Promise { 20 | return this.post('notifications/read') 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/likes/likes.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core' 2 | import { Tweet } from '@Data/Models/Tweet' 3 | import { TweetService } from 'Services' 4 | 5 | @Component({ 6 | selector: 'app-likes', 7 | templateUrl: './likes.component.html', 8 | styleUrls: ['./likes.component.scss'] 9 | }) 10 | export class LikesComponent implements OnInit { 11 | 12 | tweets: Tweet[] = [] 13 | 14 | constructor( 15 | private tweetService: TweetService 16 | ) { } 17 | 18 | async ngOnInit(): Promise { 19 | this.tweets = await this.tweetService.getTweets() 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /Models/User.ts: -------------------------------------------------------------------------------- 1 | import { Field, FieldMany, Model } from '@Typetron/Models' 2 | import { Topic } from './Topic' 3 | 4 | export class User extends Model { 5 | @Field() 6 | id: number 7 | 8 | @Field() 9 | username: string 10 | 11 | @Field() 12 | name: string 13 | 14 | @Field() 15 | photo: string 16 | 17 | @Field() 18 | cover: string 19 | 20 | @Field() 21 | bio?: string 22 | 23 | @Field() 24 | followersCount?: number 25 | 26 | @Field() 27 | followingCount?: number 28 | 29 | @FieldMany(User) 30 | followers?: User[] 31 | 32 | @FieldMany(User) 33 | following?: User[] 34 | 35 | @FieldMany(Topic) 36 | topics?: Topic[] 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/profile.component.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | position: relative; 3 | font-size: 110%; 4 | } 5 | 6 | nz-tabset ::ng-deep { 7 | nz-tabs-nav.ant-tabs-nav { 8 | background: #fff; 9 | margin: 0; 10 | } 11 | 12 | .ant-tabs-nav-list { 13 | width: 100%; 14 | 15 | > div { 16 | width: 100%; 17 | justify-content: center; 18 | } 19 | } 20 | } 21 | 22 | .cover { 23 | width: 100%; 24 | height: 200px; 25 | background-size: cover; 26 | background-position: center; 27 | } 28 | 29 | .profile-photo { 30 | position: absolute; 31 | top: 150px; 32 | width: 100%; 33 | left: 24px; 34 | } 35 | -------------------------------------------------------------------------------- /config/app.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-default-export */ 2 | import { AppConfig, DatabaseProvider } from '@Typetron/Framework' 3 | import { RoutingProvider } from 'App/Providers/RoutingProvider' 4 | import { AppProvider } from 'App/Providers/AppProvider' 5 | import { CorsMiddleware } from '@Typetron/Framework/Middleware' 6 | 7 | export default new AppConfig({ 8 | port: 8000, 9 | environment: 'development', 10 | middleware: [ 11 | CorsMiddleware 12 | ], 13 | providers: [ 14 | AppProvider, 15 | DatabaseProvider, 16 | RoutingProvider, 17 | ], 18 | staticAssets: [ 19 | { 20 | url: '.*', 21 | path: 'public' 22 | } 23 | ] 24 | }) 25 | 26 | -------------------------------------------------------------------------------- /config/database.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseConfig } from '@Typetron/Framework' 2 | import { MysqlDriver, SqliteDriver } from '@Typetron/Database' 3 | 4 | export default new DatabaseConfig({ 5 | entities: './Entities', 6 | synchronizeSchema: true, 7 | migrationsDirectory: 'migrations', 8 | driver: process.env.databaseDriver ?? 'sqlite', 9 | 10 | drivers: { 11 | sqlite: () => new SqliteDriver(process.env.database ?? 'database.sqlite'), 12 | mysql: () => new MysqlDriver({ 13 | host: process.env.databaseHost, 14 | user: process.env.databaseUser, 15 | password: process.env.databasePassword, 16 | database: process.env.database, 17 | }), 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/tweets-and-replies/tweets-and-replies.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core' 2 | import { Tweet } from '@Data/Models/Tweet' 3 | import { TweetService } from 'Services' 4 | 5 | @Component({ 6 | selector: 'app-tweets-and-replies', 7 | templateUrl: './tweets-and-replies.component.html', 8 | styleUrls: ['./tweets-and-replies.component.scss'] 9 | }) 10 | export class TweetsAndRepliesComponent implements OnInit { 11 | 12 | tweets: Tweet[] = [] 13 | 14 | constructor( 15 | private tweetService: TweetService 16 | ) { } 17 | 18 | async ngOnInit(): Promise { 19 | this.tweets = await this.tweetService.getTweets() 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /Models/Tweet.ts: -------------------------------------------------------------------------------- 1 | import { Field, FieldMany, Model } from '@Typetron/Models' 2 | import { User } from './User' 3 | import { Media } from './Media' 4 | import { Like } from './Like' 5 | 6 | export class Tweet extends Model { 7 | @Field() 8 | id: number 9 | 10 | @Field() 11 | content: string 12 | 13 | @Field() 14 | user: User 15 | 16 | @Field() 17 | likesCount = 0 18 | 19 | @FieldMany(Like) 20 | likes: Like[] = [] 21 | 22 | @Field() 23 | retweetsCount = 0 24 | 25 | @Field() 26 | replyParent?: Tweet 27 | 28 | @Field() 29 | retweetParent?: Tweet 30 | 31 | @Field() 32 | repliesCount = 0 33 | 34 | @FieldMany(Media) 35 | media: Media[] = [] 36 | 37 | @Field() 38 | createdAt: Date 39 | } 40 | -------------------------------------------------------------------------------- /frontend/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.io/guide/browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | last 1 Chrome version 12 | last 1 Firefox version 13 | last 2 Edge major versions 14 | last 2 Safari major versions 15 | last 2 iOS major versions 16 | Firefox ESR 17 | not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. 18 | -------------------------------------------------------------------------------- /frontend/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false, 7 | apiUrl: 'http://localhost:8000' 8 | }; 9 | 10 | /* 11 | * For easier debugging in development mode, you can import the following file 12 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 13 | * 14 | * This import should be commented out in production mode because it will have a negative impact 15 | * on performance if an error is thrown. 16 | */ 17 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 18 | -------------------------------------------------------------------------------- /frontend/src/app/services/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core' 2 | import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router' 3 | import { AuthService } from './auth.service' 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class AuthGuard implements CanActivate { 9 | 10 | constructor(private authService: AuthService, private router: Router) {} 11 | 12 | async canActivate( 13 | next: ActivatedRouteSnapshot, 14 | state: RouterStateSnapshot 15 | ): Promise { 16 | const loggedIn = Boolean(this.authService.loadUser()) 17 | if (!loggedIn) { 18 | await this.router.navigate(['/login']) 19 | return false 20 | } 21 | return true 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/app/services/home.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core' 2 | import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router' 3 | import { AuthService } from './auth.service' 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class HomeGuard implements CanActivate { 9 | 10 | constructor(private authService: AuthService, private router: Router) {} 11 | 12 | async canActivate( 13 | next: ActivatedRouteSnapshot, 14 | state: RouterStateSnapshot 15 | ): Promise { 16 | const loggedIn = Boolean(this.authService.loadUser()) 17 | 18 | if (loggedIn) { 19 | await this.router.navigate(['/home']) 20 | return false 21 | } 22 | return true 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | 16 | # IDEs and editors 17 | /.idea 18 | .project 19 | .classpath 20 | .c9/ 21 | *.launch 22 | .settings/ 23 | *.sublime-workspace 24 | 25 | # IDE - VSCode 26 | .vscode/* 27 | !.vscode/settings.json 28 | !.vscode/tasks.json 29 | !.vscode/launch.json 30 | !.vscode/extensions.json 31 | .history/* 32 | 33 | # misc 34 | /.sass-cache 35 | /connect.lock 36 | /coverage 37 | /libpeerconnection.log 38 | npm-debug.log 39 | yarn-error.log 40 | testem.log 41 | /typings 42 | 43 | # System Files 44 | .DS_Store 45 | Thumbs.db 46 | -------------------------------------------------------------------------------- /frontend/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/testing' 4 | import { getTestBed } from '@angular/core/testing' 5 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing' 6 | 7 | declare const require: { 8 | context(path: string, deep?: boolean, filter?: RegExp): { 9 | keys(): string[]; 10 | (id: string): T; 11 | }; 12 | } 13 | 14 | // First, initialize the Angular testing environment. 15 | getTestBed().initTestEnvironment( 16 | BrowserDynamicTestingModule, 17 | platformBrowserDynamicTesting() 18 | ); 19 | // Then we find all the tests. 20 | const context = require.context('./', true, /\.spec\.ts$/); 21 | // And load the modules. 22 | context.keys().map(context); 23 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/notification/notification.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 12 | 13 |

14 | {{notification.title}} 15 |

16 |

17 | {{tweet.content}} 18 |

19 |
20 |
21 | 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2018", 5 | "experimentalDecorators": true, 6 | "emitDecoratorMetadata": true, 7 | "esModuleInterop": true, 8 | "sourceMap": true, 9 | "strict": true, 10 | "strictPropertyInitialization": false, 11 | "outDir": "dist", 12 | "baseUrl": ".", 13 | "moduleResolution": "node", 14 | "lib": [ 15 | "esnext" 16 | ], 17 | "types": [ 18 | "reflect-metadata" 19 | ], 20 | "paths": { 21 | "App/*": [ 22 | "*" 23 | ], 24 | "Tests/*": [ 25 | "tests/*" 26 | ], 27 | "@Typetron/*": [ 28 | "node_modules/@typetron/framework/*" 29 | ], 30 | "@Data/Models/*": [ 31 | "Models/*" 32 | ] 33 | } 34 | }, 35 | "include": [ 36 | "**/*.ts" 37 | ], 38 | "exclude": [ 39 | "test", 40 | "frontend", 41 | "node_modules" 42 | ] 43 | } 44 | 45 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/base-page/base-page.component.scss: -------------------------------------------------------------------------------- 1 | nz-sider { 2 | height: 100%; 3 | 4 | [nz-menu] { 5 | margin-top: 24px; 6 | 7 | [nz-menu-item], nz-badge { 8 | font-size: 18px; 9 | font-weight: bold; 10 | 11 | [nz-icon] { 12 | font-size: 20px; 13 | } 14 | } 15 | 16 | [nz-menu-item] { 17 | margin-bottom: 24px; 18 | 19 | } 20 | } 21 | } 22 | 23 | .inner-layout { 24 | padding: 0 24px; 25 | } 26 | 27 | nz-content { 28 | min-height: 280px; 29 | } 30 | 31 | .logo { 32 | margin-left: 10px; 33 | 34 | img { 35 | width: 50px; 36 | } 37 | 38 | span { 39 | position: relative; 40 | font-weight: bold; 41 | font-size: 24px; 42 | left: -9px; 43 | top: 8px; 44 | color: #7444c0; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/TestCase.ts: -------------------------------------------------------------------------------- 1 | import { TestCase as BaseTestCase } from '@Typetron/Testing/TestCase' 2 | import { Application, AuthConfig } from '@Typetron/Framework' 3 | import * as path from 'path' 4 | import { Crypt } from '@Typetron/Encryption' 5 | import { User } from 'App/Entities/User' 6 | import * as dotenv from 'dotenv' 7 | 8 | dotenv.config({path: 'test/.env'}) 9 | 10 | export class TestCase extends BaseTestCase { 11 | 12 | async bootstrapApp() { 13 | return await Application.create(path.join(__dirname, '..')) 14 | } 15 | 16 | async createUser(overrides: Partial = {}) { 17 | return await User.create({ 18 | username: String.randomAlphaNum(10), 19 | email: String.randomAlphaNum(10), 20 | password: await this.app.get(Crypt).hash(String.randomAlphaNum(10), this.app.get(AuthConfig).saltRounds), 21 | ...overrides 22 | }) 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/topics-form/topics-form.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
  • 4 |
      5 | 6 | 9 | 12 | 13 |
    14 | {{ item.name }} 15 |
  • 16 |
    17 |
    18 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/notification/notification.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core' 2 | import { User } from '@Data/Models/User' 3 | import { environment } from '../../../../environments/environment' 4 | import { BaseNotificationTemplate } from '../../models' 5 | import { Router } from '@angular/router' 6 | 7 | @Component({ 8 | selector: 'app-notification', 9 | templateUrl: './notification.component.html', 10 | styleUrls: ['./notification.component.scss'] 11 | }) 12 | export class NotificationComponent { 13 | @Input() notification!: BaseNotificationTemplate 14 | 15 | imgPath = environment.apiUrl 16 | color = `rgba(${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)}, 0.2)` 17 | 18 | constructor(private router: Router) {} 19 | 20 | async goToUser(user: User): Promise { 21 | await this.router.navigate(['/', user.username]) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Entities/Notification.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BelongsTo, 3 | BelongsToMany, 4 | Column, 5 | CreatedAt, 6 | Entity, 7 | Options, 8 | PrimaryColumn, 9 | Relation, 10 | UpdatedAt 11 | } from '@Typetron/Database' 12 | import { User } from 'App/Entities/User' 13 | import { Tweet } from 'App/Entities/Tweet' 14 | 15 | @Options({ 16 | table: 'notifications' 17 | }) 18 | export class Notification extends Entity { 19 | @PrimaryColumn() 20 | id: number 21 | 22 | @Column() 23 | type: 'follow' | 'like' | 'reply' | 'retweet' | 'mention' 24 | 25 | @Relation(() => User, 'notifications') 26 | user: BelongsTo 27 | 28 | @Relation(() => User, 'activity') 29 | notifiers: BelongsToMany 30 | 31 | @Relation(() => Tweet, 'notifications') 32 | tweet: BelongsTo 33 | 34 | @Column() 35 | readAt: Date 36 | 37 | @CreatedAt() 38 | createdAt: Date 39 | 40 | @UpdatedAt() 41 | updatedAt: Date 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/app/pages/login/login.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core' 2 | import { Router } from '@angular/router' 3 | import { AuthService } from 'Services' 4 | import { LoginForm } from '@Data/Forms/LoginForm' 5 | import { FormBuilder, isValid } from '../../util' 6 | 7 | @Component({ 8 | selector: 'app-login', 9 | templateUrl: './login.component.html', 10 | styleUrls: ['./login.component.scss'] 11 | }) 12 | export class LoginComponent { 13 | 14 | form = FormBuilder.build(LoginForm) 15 | loading = false 16 | 17 | constructor( 18 | private router: Router, 19 | private authService: AuthService 20 | ) {} 21 | 22 | async login(): Promise { 23 | if (!isValid(this.form)) { 24 | return 25 | } 26 | this.loading = true 27 | await this.authService.login(this.form.value).catch(() => this.loading = false) 28 | await this.router.navigate(['/home']) 29 | this.loading = false 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/followers/followers.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core' 2 | import { ActivatedRoute } from '@angular/router' 3 | import { User } from '@Data/Models/User' 4 | import { AuthService, UserService } from 'Services' 5 | 6 | @Component({ 7 | selector: 'app-followers', 8 | templateUrl: './followers.component.html', 9 | styleUrls: ['./followers.component.scss'] 10 | }) 11 | export class FollowersComponent implements OnInit { 12 | followers: User[] = [] 13 | user?: User 14 | 15 | constructor( 16 | private route: ActivatedRoute, 17 | public auth: AuthService, 18 | private userService: UserService 19 | ) { } 20 | 21 | async ngOnInit(): Promise { 22 | [ 23 | this.user, 24 | this.followers 25 | ] = await Promise.all([ 26 | this.userService.getUser(this.route.snapshot.params.username), 27 | this.userService.followers(this.route.snapshot.params.username) 28 | ]) 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/followings/followings.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core' 2 | import { ActivatedRoute } from '@angular/router' 3 | import { User } from '@Data/Models/User' 4 | import { AuthService, UserService } from 'Services' 5 | 6 | @Component({ 7 | selector: 'app-followings', 8 | templateUrl: './followings.component.html', 9 | styleUrls: ['./followings.component.scss'] 10 | }) 11 | export class FollowingsComponent implements OnInit { 12 | following: User[] = [] 13 | user?: User 14 | 15 | constructor( 16 | private route: ActivatedRoute, 17 | public auth: AuthService, 18 | private userService: UserService 19 | ) { } 20 | 21 | async ngOnInit(): Promise { 22 | [ 23 | this.user, 24 | this.following 25 | ] = await Promise.all([ 26 | this.userService.getUser(this.route.snapshot.params.username), 27 | this.userService.following(this.route.snapshot.params.username) 28 | ]) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/app/pages/register/register.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core' 2 | import { Router } from '@angular/router' 3 | import { AuthService } from 'Services' 4 | import { RegisterForm } from '@Data/Forms/RegisterForm' 5 | import { FormBuilder, isValid } from '../../util' 6 | 7 | @Component({ 8 | selector: 'app-register', 9 | templateUrl: './register.component.html', 10 | styleUrls: ['./register.component.scss'] 11 | }) 12 | export class RegisterComponent { 13 | 14 | form = FormBuilder.build(RegisterForm) 15 | loading = false 16 | 17 | constructor( 18 | private router: Router, 19 | private authService: AuthService 20 | ) {} 21 | 22 | async register(): Promise { 23 | if (!isValid(this.form)) { 24 | return 25 | } 26 | 27 | this.loading = true 28 | await this.authService.register(this.form.value).finally(() => this.loading = false) 29 | this.loading = true 30 | await this.router.navigate(['/home']) 31 | this.loading = false 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/app/pages/login/login.component.html: -------------------------------------------------------------------------------- 1 | 22 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/topics-form/topics-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core' 2 | import { NzModalRef } from 'ng-zorro-antd/modal' 3 | import { UserService } from 'Services' 4 | import { Topic } from '@Data/Models/Topic' 5 | import { TopicsForm } from '@Data/Forms/TopicsForm' 6 | import { FormBuilder } from '../../../../util' 7 | 8 | @Component({ 9 | selector: 'app-topics-form', 10 | templateUrl: './topics-form.component.html', 11 | styleUrls: ['./topics-form.component.scss'] 12 | }) 13 | export class TopicsFormComponent implements OnInit { 14 | 15 | @Input() user!: number 16 | 17 | topics: Topic[] = [] 18 | form = FormBuilder.build(TopicsForm) 19 | 20 | constructor( 21 | public modal: NzModalRef, 22 | private userService: UserService, 23 | ) {} 24 | 25 | async ngOnInit(): Promise { 26 | this.topics = await this.userService.allTopics() 27 | const userTopics = await this.userService.topics() 28 | this.form.patchValue({ 29 | topics: userTopics.pluck('id') 30 | }) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2019] [Ionel-Cristian Lupu] 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. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@typetron/typetron", 3 | "description": "Modern Node.js framework for backend apps written in Typescript", 4 | "license": "MIT", 5 | "version": "0.0.1", 6 | "author": { 7 | "email": "ionel@typetron.org", 8 | "name": "Ionel Lupu" 9 | }, 10 | "scripts": { 11 | "start": "ts-node-dev -r tsconfig-paths/register -r dotenv/config --respawn index.ts", 12 | "prod": "ts-node -r tsconfig-paths/register -r dotenv/config index.ts", 13 | "cli": "typetron routes", 14 | "frontend": "cd frontend && ng serve", 15 | "test": "mocha" 16 | }, 17 | "dependencies": { 18 | "@typetron/framework": "0.4.0-rc9", 19 | "@types/dotenv": "^8.2.0", 20 | "dotenv": "^16.3.1", 21 | "reflect-metadata": "^0.1.13", 22 | "ts-node": "^10.9.1", 23 | "tsconfig-paths": "^4.2.0", 24 | "typescript": "5.2.2" 25 | }, 26 | "devDependencies": { 27 | "@testdeck/mocha": "^0.3.3", 28 | "@types/chai": "^4.3.9", 29 | "@types/mocha": "^10.0.3", 30 | "@types/node": "^20.8.7", 31 | "chai": "^4.3.10", 32 | "mocha": "^10.2.0", 33 | "nyc": "^15.1.0", 34 | "source-map-support": "^0.5.21", 35 | "ts-node-dev": "^2.0.0", 36 | "tslint": "^6.1.3" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Frontend2 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 12.1.3. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a 24 | package that implements end-to-end testing capabilities. 25 | 26 | ## Further help 27 | 28 | To get more help on the Angular CLI use `ng help` or go check out 29 | the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 30 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/tweet/tweet.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core' 2 | import { environment } from '../../../../environments/environment' 3 | import { NzModalService } from 'ng-zorro-antd/modal' 4 | import { ActivatedRoute } from '@angular/router' 5 | import { Tweet } from '@Data/Models/Tweet' 6 | import { TweetService } from 'Services' 7 | 8 | @Component({ 9 | selector: 'app-tweet-page', 10 | templateUrl: './tweet.component.html', 11 | styleUrls: ['./tweet.component.scss'] 12 | }) 13 | export class TweetComponent implements OnInit { 14 | tweet?: Tweet 15 | imgPath = environment.apiUrl 16 | loading = true 17 | 18 | constructor( 19 | private modal: NzModalService, 20 | private tweetService: TweetService, 21 | private route: ActivatedRoute 22 | ) { } 23 | 24 | async ngOnInit(): Promise { 25 | this.route.params.subscribe(async (params) => { 26 | this.loading = true 27 | await this.load(params.id) 28 | }) 29 | } 30 | 31 | async load(id: number): Promise { 32 | this.tweet = await this.tweetService.getTweet(id).finally(() => this.loading = false) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Controllers/Http/NotificationsController.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Middleware, Post } from '@Typetron/Router' 2 | import { AuthUser } from '@Typetron/Framework/Auth' 3 | import { User } from 'App/Entities/User' 4 | import { AuthMiddleware } from '@Typetron/Framework/Middleware' 5 | import { Notification as NotificationModel } from 'App/Models/Notification' 6 | import { Notification } from 'App/Entities/Notification' 7 | 8 | @Controller('notifications') 9 | @Middleware(AuthMiddleware) 10 | export class NotificationsController { 11 | 12 | @AuthUser() 13 | user: User 14 | 15 | @Get() 16 | async get() { 17 | const notifications = await Notification 18 | .with('notifiers', 'tweet') 19 | .where('userId', this.user.id) 20 | .orderBy('createdAt') 21 | .get() 22 | 23 | return NotificationModel.from(notifications) 24 | } 25 | 26 | @Get('unread') 27 | async unread() { 28 | return await Notification.where('user', this.user.id).whereNull('readAt').count() 29 | } 30 | 31 | @Post('read') 32 | async markAllAsRead() { 33 | await Notification.where('user', this.user.id).whereNull('readAt').update('readAt', new Date()) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/follower/follower.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 |

    11 | 12 | {{user.name}} @{{user.username}} 13 | 14 |

    15 |
    16 | {{user.bio?.limit( 150 )}} 17 |
    18 |
    19 | 20 | 21 | 24 | 27 | 28 | 29 |
    30 |
    31 | 32 | 33 | This is you 34 | 35 | -------------------------------------------------------------------------------- /Entities/Tweet.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BelongsTo, 3 | BelongsToMany, 4 | Column, 5 | CreatedAt, 6 | Entity, 7 | HasMany, 8 | Options, 9 | PrimaryColumn, 10 | Relation 11 | } from '@Typetron/Database' 12 | import { User } from './User' 13 | import { Like } from './Like' 14 | import { Media } from './Media' 15 | import { Notification } from 'App/Entities/Notification' 16 | import { Hashtag } from 'App/Entities/Hashtag' 17 | 18 | @Options({ 19 | table: 'tweets' 20 | }) 21 | export class Tweet extends Entity { 22 | @PrimaryColumn() 23 | id: number 24 | 25 | @Column() 26 | content: string 27 | 28 | @Relation(() => Media, 'tweet') 29 | media: HasMany 30 | 31 | @Relation(() => User, 'tweets') 32 | user: BelongsTo 33 | 34 | @Relation(() => Like, 'tweet') 35 | likes: HasMany 36 | 37 | @Relation(() => Tweet, 'replies') 38 | replyParent: BelongsTo 39 | 40 | @Relation(() => Tweet, 'retweets') 41 | retweetParent: BelongsTo 42 | 43 | @Relation(() => Tweet, 'replyParent') 44 | replies: HasMany 45 | 46 | @Relation(() => Tweet, 'retweetParent') 47 | retweets: HasMany 48 | 49 | @Relation(() => Notification, 'tweet') 50 | notifications: HasMany 51 | 52 | @Relation(() => Hashtag, 'tweets') 53 | hashtags: BelongsToMany 54 | 55 | @CreatedAt() 56 | createdAt: Date 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core' 2 | import { RouterModule, Routes } from '@angular/router' 3 | import { AuthGuard } from './services/auth.guard' 4 | import { LoginComponent } from './pages/login/login.component' 5 | import { RegisterComponent } from './pages/register/register.component' 6 | import { AuthComponent } from './layouts/auth/auth.component' 7 | import { HomeGuard } from './services/home.guard' 8 | 9 | const routes: Routes = [ 10 | { 11 | path: '', 12 | component: AuthComponent, 13 | canActivate: [HomeGuard], 14 | children: [ 15 | { 16 | path: 'login', 17 | component: LoginComponent 18 | }, 19 | { 20 | path: 'register', 21 | component: RegisterComponent 22 | }, 23 | { 24 | path: '', 25 | pathMatch: 'full', 26 | redirectTo: 'login', 27 | }, 28 | ] 29 | }, 30 | { 31 | path: '', 32 | canActivate: [AuthGuard], 33 | loadChildren: () => import('./dashboard/dashboard.module').then(m => m.DashboardModule), 34 | } 35 | // { 36 | // path: '**', 37 | // pathMatch: 'full', 38 | // redirectTo: '/home', 39 | // }, 40 | ] 41 | 42 | @NgModule({ 43 | imports: [RouterModule.forRoot(routes)], 44 | exports: [RouterModule] 45 | }) 46 | export class AppRoutingModule {} 47 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend2", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "export NODE_OPTIONS=--openssl-legacy-provider; ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@angular/animations": "~12.1.0-", 14 | "@angular/common": "~12.1.0-", 15 | "@angular/compiler": "~12.1.0-", 16 | "@angular/core": "~12.1.0-", 17 | "@angular/forms": "~12.1.0-", 18 | "@angular/platform-browser": "~12.1.0-", 19 | "@angular/platform-browser-dynamic": "~12.1.0-", 20 | "@angular/router": "~12.1.0-", 21 | "@typetron/framework": "^0.3.5", 22 | "ng-zorro-antd": "^12.0.1", 23 | "rxjs": "~6.6.0", 24 | "tslib": "^2.2.0", 25 | "zone.js": "~0.11.4" 26 | }, 27 | "devDependencies": { 28 | "@angular-devkit/build-angular": "~12.1.3", 29 | "@angular/cli": "~12.1.3", 30 | "@angular/compiler-cli": "~12.1.0-", 31 | "@types/jasmine": "~3.8.0", 32 | "@types/node": "^12.11.1", 33 | "jasmine-core": "~3.8.0", 34 | "karma": "~6.3.0", 35 | "karma-chrome-launcher": "~3.1.0", 36 | "karma-coverage": "~2.0.3", 37 | "karma-jasmine": "~4.0.0", 38 | "karma-jasmine-html-reporter": "~1.7.0", 39 | "typescript": "~4.3.2" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/App/Controllers/TweetControllerTest.ts: -------------------------------------------------------------------------------- 1 | import { suite, test } from '@testdeck/mocha' 2 | import { expect } from 'chai' 3 | import { TestCase } from 'Tests/TestCase' 4 | import { Tweet } from 'App/Entities/Tweet' 5 | 6 | @suite 7 | class TweetControllerTest extends TestCase { 8 | 9 | async before() { 10 | await super.before() 11 | await this.actingAs(await this.createUser()) 12 | } 13 | 14 | @test 15 | async createsTweet() { 16 | const response = await this.post('tweets.tweet', { 17 | content: 'this is a tweet test' 18 | }) 19 | expect(response.body).to.deep.include({ 20 | content: 'this is a tweet test', 21 | }) 22 | } 23 | 24 | @test 25 | async repliesToTweet() { 26 | const tweet = await Tweet.create({content: 'tweet'}) as Tweet 27 | const response = await this.post(['tweets.reply', {Tweet: tweet.id}], { 28 | content: 'this is a reply' 29 | }) 30 | expect(response.body).to.deep.include({ 31 | content: 'this is a reply', 32 | }) 33 | } 34 | 35 | @test 36 | async likesTweet() { 37 | const tweet = await Tweet.create({ 38 | content: 'some tweet' 39 | }) 40 | const response = await this.post(['tweets.like', {Tweet: tweet.id}]) 41 | const content = response.body as Tweet 42 | expect(content).to.deep.include({ 43 | id: tweet.id 44 | }) 45 | expect(content.likes).to.have.length(1) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitReturns": false, 10 | "noFallthroughCasesInSwitch": true, 11 | "strictPropertyInitialization": false, 12 | "sourceMap": true, 13 | "declaration": false, 14 | "downlevelIteration": true, 15 | "experimentalDecorators": true, 16 | "moduleResolution": "node", 17 | "importHelpers": true, 18 | "target": "es2017", 19 | "module": "es2020", 20 | "lib": [ 21 | "es2018", 22 | "dom" 23 | ], 24 | "paths": { 25 | "Services": [ 26 | "./src/app/services" 27 | ], 28 | "App/Validators/*": [ 29 | "../Validators/*" 30 | ], 31 | "@Typetron/*": [ 32 | "./node_modules/@typetron/framework/*" 33 | ], 34 | "@Data/Models/*": [ 35 | "../Models/*" 36 | ], 37 | "@Data/Forms/*": [ 38 | "../Forms/*" 39 | ] 40 | } 41 | }, 42 | "angularCompilerOptions": { 43 | "enableI18nLegacyMessageIdFormat": false, 44 | "strictInjectionParameters": true, 45 | "strictInputAccessModifiers": true, 46 | "strictTemplates": true 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/base-page/base-page.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core' 2 | import { Router } from '@angular/router' 3 | import { Topic } from '@Data/Models/Topic' 4 | import { interval, Subject } from 'rxjs' 5 | import { startWith, takeUntil } from 'rxjs/operators' 6 | import { AppService, AuthService, UserService } from 'Services' 7 | 8 | @Component({ 9 | selector: 'app-base-page', 10 | templateUrl: './base-page.component.html', 11 | styleUrls: ['./base-page.component.scss'] 12 | }) 13 | export class BasePageComponent implements OnInit, OnDestroy { 14 | topics: Topic[] = [] 15 | private destroy$ = new Subject() 16 | 17 | constructor( 18 | public authService: AuthService, 19 | public appService: AppService, 20 | public userService: UserService, 21 | private router: Router, 22 | ) { } 23 | 24 | async ngOnInit(): Promise { 25 | 26 | interval(10000).pipe( 27 | startWith(0), 28 | takeUntil(this.destroy$), 29 | ).subscribe(async () => { 30 | await this.userService.getUnreadNotifications() 31 | }) 32 | this.topics = await this.userService.allTopics() 33 | } 34 | 35 | async logout(): Promise { 36 | this.authService.logout() 37 | await this.router.navigate(['login']) 38 | } 39 | 40 | addScrollEvent($event: Event): void { 41 | this.appService.scroll$.next($event) 42 | } 43 | 44 | ngOnDestroy(): void { 45 | this.destroy$.next() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Controllers/Http/AuthController.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post } from '@Typetron/Router' 2 | import { RegisterForm } from 'App/Forms/RegisterForm' 3 | import { User } from 'App/Entities/User' 4 | import { User as UserModel } from 'App/Models/User' 5 | import { LoginForm } from 'App/Forms/LoginForm' 6 | import { Inject } from '@Typetron/Container' 7 | import { Auth } from '@Typetron/Framework/Auth' 8 | import { AuthConfig } from '@Typetron/Framework' 9 | 10 | @Controller() 11 | export class AuthController { 12 | 13 | @Inject() 14 | auth: Auth 15 | 16 | @Inject() 17 | authConfig: AuthConfig 18 | 19 | @Post('register') 20 | async register(form: RegisterForm) { 21 | const user = await User.where('email', form.email).orWhere('username', form.username).first() 22 | if (user) { 23 | throw new Error('User already exists') 24 | } 25 | 26 | if (form.password !== form.passwordConfirmation) { 27 | throw new Error('Passwords don\'t match') 28 | } 29 | 30 | const newUser = await this.auth.register(form.email, form.password) 31 | newUser.username = form.username 32 | await newUser.save() 33 | 34 | return UserModel.from(newUser) 35 | } 36 | 37 | @Post('login') 38 | async login(form: LoginForm) { 39 | const token = await this.auth.login(form.username, form.password) 40 | const user = await this.auth.user() 41 | await user.loadCount('followers', 'following') 42 | return { 43 | token: token, 44 | user: UserModel.from(user), 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Entities/User.ts: -------------------------------------------------------------------------------- 1 | import { BelongsToMany, BelongsToManyOptions, Column, HasMany, Options, Relation } from '@Typetron/Database' 2 | import { User as Authenticatable } from '@Typetron/Framework/Auth' 3 | import { Tweet } from 'App/Entities/Tweet' 4 | import { Like } from 'App/Entities/Like' 5 | import { Notification } from 'App/Entities/Notification' 6 | import { Topic } from 'App/Entities/Topic' 7 | 8 | @Options({ 9 | table: 'users' 10 | }) 11 | export class User extends Authenticatable { 12 | @Column() 13 | name: string 14 | 15 | @Column() 16 | username: string 17 | 18 | @Column() 19 | bio: string 20 | 21 | @Column() 22 | photo: string 23 | 24 | @Column() 25 | cover: string 26 | 27 | @Relation(() => Like, 'user') 28 | likes: HasMany 29 | 30 | @Relation(() => Tweet, 'user') 31 | tweets: HasMany 32 | 33 | @Relation(() => Notification, 'user') 34 | notifications: HasMany 35 | 36 | @Relation(() => Notification, 'notifiers') 37 | activity: BelongsToMany 38 | 39 | @Relation(() => Topic, 'enthusiasts') 40 | topics: BelongsToMany 41 | 42 | @Relation(() => User, 'following') 43 | @BelongsToManyOptions({ 44 | table: 'followers', 45 | column: 'followerId', 46 | foreignColumn: 'followingId' 47 | }) 48 | followers: BelongsToMany 49 | 50 | @Relation(() => User, 'followers') 51 | @BelongsToManyOptions({ 52 | table: 'followers', 53 | column: 'followingId', 54 | foreignColumn: 'followerId' 55 | }) 56 | following: BelongsToMany 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/app/services/tweet.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core' 2 | import { HttpClient } from '@angular/common/http' 3 | import { Tweet } from '@Data/Models/Tweet' 4 | import { ApiService } from './api.service' 5 | import { toFormData } from '../util' 6 | import { TweetForm } from '@Data/Forms/TweetForm' 7 | 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class TweetService extends ApiService { 12 | 13 | constructor(http: HttpClient) { 14 | super(http) 15 | } 16 | 17 | getTweet(id: number): Promise { 18 | return this.get(`tweets/${id}`) 19 | } 20 | 21 | getTweets(page = 1): Promise { 22 | return this.get(``, { 23 | params: { 24 | page: page.toString(), 25 | } 26 | }) 27 | } 28 | 29 | tweet(form: TweetForm): Promise { 30 | return this.post('tweets', toFormData(form)) 31 | } 32 | 33 | reply(parent: number, form: TweetForm): Promise { 34 | return this.post(`tweets/${parent}/reply`, toFormData(form)) 35 | } 36 | 37 | retweet(parent: number, form: TweetForm): Promise { 38 | return this.post(`tweets/${parent}/retweet`, toFormData(form)) 39 | } 40 | 41 | remove(id: number): Promise { 42 | return super.delete(`tweets/${id}`) 43 | } 44 | 45 | toggleLike(id: number): Promise { 46 | return super.post(`tweets/${id}/like`) 47 | } 48 | 49 | replies(id: number): Promise { 50 | return super.get(`tweets/${id}/replies`) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /frontend/src/app/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core' 2 | import { ApiService } from './api.service' 3 | import { HttpClient } from '@angular/common/http' 4 | import { User } from '@Data/Models/User' 5 | import { BehaviorSubject } from 'rxjs' 6 | import { LoginForm } from '@Data/Forms/LoginForm' 7 | import { RegisterForm } from '@Data/Forms/RegisterForm' 8 | 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class AuthService extends ApiService { 13 | 14 | user$ = new BehaviorSubject(undefined) 15 | 16 | constructor(http: HttpClient) { 17 | super(http) 18 | } 19 | 20 | loadUser(): User | undefined { 21 | const userJSON = localStorage.getItem('user') 22 | const user = userJSON ? JSON.parse(userJSON) : undefined 23 | this.user$.next(user) 24 | return user 25 | } 26 | 27 | user(): User | undefined { 28 | return this.user$.value 29 | } 30 | 31 | setUser(user: User): void { 32 | localStorage.setItem('user', JSON.stringify(user)) 33 | this.user$.next(user) 34 | } 35 | 36 | async login(form: LoginForm): Promise { 37 | const response = await this.http.post<{token: string, user: User}>(this.getEndpoint('login'), form).toPromise() 38 | 39 | this.setUser(response.user) 40 | localStorage.setItem('token', response.token) 41 | } 42 | 43 | register(form: RegisterForm): Promise { 44 | return this.http.post(this.getEndpoint('register'), form).toPromise() 45 | } 46 | 47 | logout(): void { 48 | localStorage.removeItem('user') 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/App/Controllers/UserControllerTest.ts: -------------------------------------------------------------------------------- 1 | import { suite, test } from '@testdeck/mocha' 2 | import { expect } from 'chai' 3 | import { TestCase } from 'Tests/TestCase' 4 | import { File } from '@Typetron/Storage' 5 | import { User } from 'App/Entities/User' 6 | import { User as UserModel } from '@Data/Models/User' 7 | 8 | @suite 9 | class UserControllerTest extends TestCase { 10 | private user: User 11 | 12 | async before() { 13 | await super.before() 14 | await this.actingAs(this.user = await this.createUser()) 15 | } 16 | 17 | @test 18 | async updatesUser() { 19 | const photo = new File('avatar.jpg') 20 | photo.directory = 'tests' 21 | photo.saved = true 22 | const cover = new File('cover.jpg') 23 | cover.directory = 'tests' 24 | cover.saved = true 25 | const response = await this.put('users.update', { 26 | name: 'Joe Joe', 27 | bio: `joe's bio`, 28 | username: 'joe', 29 | photo, 30 | cover, 31 | }) 32 | expect(response.body).to.deep.include({ 33 | username: 'joe', 34 | name: 'Joe Joe', 35 | bio: `joe's bio`, 36 | }) 37 | } 38 | 39 | @test 40 | async canFollowUser() { 41 | const user = await this.createUser({ 42 | name: 'Joe Joeeeee', 43 | bio: `joe's biooooo`, 44 | }) 45 | 46 | const response = await this.post(['users.follow', {User: user.id}]) 47 | expect(response.body).to.deep.include({ 48 | username: this.user.username, 49 | id: this.user.id, 50 | }) 51 | expect(response.body.following).to.have.length(1) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/app/pages/register/register.component.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 33 | Already have and account? Login here! 34 |
    35 | -------------------------------------------------------------------------------- /frontend/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function ( config ) { 5 | config.set( { 6 | basePath : '', 7 | frameworks : ['jasmine', '@angular-devkit/build-angular'], 8 | plugins : [ 9 | require( 'karma-jasmine' ), 10 | require( 'karma-chrome-launcher' ), 11 | require( 'karma-jasmine-html-reporter' ), 12 | require( 'karma-coverage' ), 13 | require( '@angular-devkit/build-angular/plugins/karma' ) 14 | ], 15 | client : { 16 | jasmine : { 17 | // you can add configuration options for Jasmine here 18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 19 | // for example, you can disable the random execution with `random: false` 20 | // or set a specific seed with `seed: 4321` 21 | }, 22 | clearContext : false // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | jasmineHtmlReporter : { 25 | suppressAll : true // removes the duplicated traces 26 | }, 27 | coverageReporter : { 28 | dir : require( 'path' ).join( __dirname, './coverage/frontend2' ), 29 | subdir : '.', 30 | reporters : [ 31 | { type : 'html' }, 32 | { type : 'text-summary' } 33 | ] 34 | }, 35 | reporters : ['progress', 'kjhtml'], 36 | port : 9876, 37 | colors : true, 38 | logLevel : config.LOG_INFO, 39 | autoWatch : true, 40 | browsers : ['Chrome'], 41 | singleRun : false, 42 | restartOnFileChange : true 43 | } ); 44 | }; 45 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/edit-form/edit-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core' 2 | import { NzModalRef } from 'ng-zorro-antd/modal' 3 | import { User } from '@Data/Models/User' 4 | import { environment } from '../../../../../environments/environment' 5 | import { UserForm } from '@Data/Forms/UserForm' 6 | import { NzUploadFile } from 'ng-zorro-antd/upload' 7 | import { FormBuilder } from '../../../../util' 8 | 9 | @Component({ 10 | selector: 'app-edit-form', 11 | templateUrl: './edit-form.component.html', 12 | styleUrls: ['./edit-form.component.scss'] 13 | }) 14 | export class EditFormComponent implements OnInit { 15 | 16 | @Input() user!: User 17 | 18 | imgPath = environment.apiUrl 19 | form = FormBuilder.build(UserForm) 20 | files = { 21 | cover: '', 22 | photo: '', 23 | } 24 | 25 | constructor( 26 | public modal: NzModalRef, 27 | ) {} 28 | 29 | ngOnInit(): void { 30 | this.form.patchValue({...this.user, cover: undefined, photo: undefined}) 31 | this.files.cover = this.user.cover ? `${environment.apiUrl}/${this.user.cover}` : '' 32 | this.files.photo = this.user.photo ? `${environment.apiUrl}/${this.user.photo}` : '' 33 | } 34 | 35 | beforeUpload(field: 'cover' | 'photo'): (file: NzUploadFile, files: NzUploadFile[]) => boolean { 36 | return (file: NzUploadFile, files: NzUploadFile[]) => { 37 | const reader = new FileReader() 38 | reader.onload = (event) => { 39 | this.files[field] = event.target?.result as string 40 | this.form.patchValue({ 41 | [field]: file 42 | }) 43 | } 44 | reader.readAsDataURL(file as unknown as File) // convert to base64 string 45 | return false 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser' 2 | import { NgModule } from '@angular/core' 3 | 4 | import { AppRoutingModule } from './app-routing.module' 5 | import { AppComponent } from './app.component' 6 | import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http' 7 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations' 8 | import { en_US, NZ_I18N } from 'ng-zorro-antd/i18n' 9 | import { registerLocaleData } from '@angular/common' 10 | import en from '@angular/common/locales/en' 11 | import { NzMessageModule } from 'ng-zorro-antd/message' 12 | import { HttpInterceptor } from './services/http.interceptor' 13 | import { LoginComponent } from './pages/login/login.component' 14 | import { RegisterComponent } from './pages/register/register.component' 15 | import { NzButtonModule } from 'ng-zorro-antd/button' 16 | import { NzFormModule } from 'ng-zorro-antd/form' 17 | import { FormsModule, ReactiveFormsModule } from '@angular/forms' 18 | import { NzInputModule } from 'ng-zorro-antd/input' 19 | import { AuthComponent } from './layouts/auth/auth.component' 20 | import { SharedModule } from './shared/shared.module' 21 | 22 | registerLocaleData(en) 23 | 24 | @NgModule({ 25 | declarations: [ 26 | AppComponent, 27 | AuthComponent, 28 | LoginComponent, 29 | RegisterComponent, 30 | ], 31 | imports: [ 32 | BrowserModule, 33 | AppRoutingModule, 34 | HttpClientModule, 35 | NzMessageModule, 36 | BrowserAnimationsModule, 37 | NzButtonModule, 38 | NzFormModule, 39 | ReactiveFormsModule, 40 | NzInputModule, 41 | SharedModule, 42 | FormsModule 43 | ], 44 | providers: [ 45 | {provide: NZ_I18N, useValue: en_US}, 46 | { 47 | provide: HTTP_INTERCEPTORS, useClass: HttpInterceptor, multi: true 48 | } 49 | ], 50 | bootstrap: [AppComponent] 51 | }) 52 | export class AppModule {} 53 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/tweet-form/tweet-form.component.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
    16 | 19 |
    20 |
    21 |
    22 |
    23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
    40 | -------------------------------------------------------------------------------- /frontend/src/app/util.ts: -------------------------------------------------------------------------------- 1 | import type { AbstractControl, ValidatorFn } from '@angular/forms' 2 | import { FormControl, FormGroup } from '@angular/forms' 3 | import type { Form, FormField } from '@Typetron/Forms' 4 | import { Constructor } from '@Typetron/Support' 5 | 6 | export function toFormData(data: object): FormData { 7 | const form = new FormData() 8 | buildFormData(form, data) 9 | return form 10 | } 11 | 12 | export function buildFormData(formData: FormData, data: object | undefined | null, parentKey?: string): void { 13 | if (data && typeof data === 'object' && !(data instanceof Date) && !(data instanceof File) && !(data instanceof Blob)) { 14 | Object.keys(data).forEach(key => { 15 | buildFormData(formData, data[key as keyof object], parentKey ? `${parentKey}` : key) 16 | }) 17 | } else { 18 | if (data !== undefined && data !== null) { 19 | formData.append(parentKey as string, data as Blob) 20 | } 21 | // const value = data !== null ? '' : data 22 | // 23 | // formData.append(parentKey, value as string) 24 | } 25 | } 26 | 27 | export function isValid(form: FormGroup): boolean { 28 | Object.values(form.controls).forEach(control => { 29 | control.markAsDirty() 30 | control.updateValueAndValidity() 31 | }) 32 | 33 | return form.valid 34 | } 35 | 36 | export class FormBuilder { 37 | static build(form: typeof Form & Constructor): FormGroup { 38 | const controls: Record = {} 39 | const formFields = Object.values(form.fields()) as FormField[] 40 | const instance = new (form as unknown as Constructor)() 41 | Object.values(formFields).forEach(field => { 42 | controls[field.name] = new FormControl( 43 | instance[field.name as keyof Form], 44 | {validators: this.getValidators(field)} 45 | ) 46 | }) 47 | return new FormGroup(controls) 48 | } 49 | 50 | private static getValidators(field: FormField): ValidatorFn { 51 | return control => field.validate(control.value) as unknown as ValidatorFn 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/dashboard-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core' 2 | import { RouterModule, Routes } from '@angular/router' 3 | import { HomeComponent } from './pages/home/home.component' 4 | import { BasePageComponent } from './components/base-page/base-page.component' 5 | import { ExploreComponent } from './pages/explore/explore.component' 6 | import { NotificationsComponent } from './pages/notifications/notifications.component' 7 | import { SettingsComponent } from './pages/settings/settings.component' 8 | import { ProfileComponent } from './pages/profile/profile.component' 9 | import { FollowingsComponent } from './pages/followings/followings.component' 10 | import { FollowersComponent } from './pages/followers/followers.component' 11 | import { TweetComponent } from './pages/tweet/tweet.component' 12 | 13 | const routes: Routes = [ 14 | { 15 | path: '', 16 | component: BasePageComponent, 17 | children: [ 18 | { 19 | path: '', 20 | redirectTo: 'home' 21 | }, 22 | { 23 | path: 'home', 24 | component: HomeComponent 25 | }, 26 | { 27 | path: 'explore', 28 | component: ExploreComponent 29 | }, 30 | { 31 | path: 'notifications', 32 | component: NotificationsComponent 33 | }, 34 | { 35 | path: 'settings', 36 | component: SettingsComponent 37 | }, 38 | { 39 | path: ':username', 40 | component: ProfileComponent 41 | }, 42 | { 43 | path: ':username/following', 44 | component: FollowingsComponent 45 | }, 46 | { 47 | path: ':username/followers', 48 | component: FollowersComponent 49 | }, 50 | { 51 | path: 'tweet/:id', 52 | component: TweetComponent 53 | } 54 | ] 55 | }, 56 | ] 57 | 58 | @NgModule({ 59 | imports: [RouterModule.forChild(routes)], 60 | exports: [RouterModule] 61 | }) 62 | export class DashboardRoutingModule {} 63 | -------------------------------------------------------------------------------- /frontend/src/app/services/http.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpErrorResponse, 3 | HttpEvent, 4 | HttpHandler, 5 | HttpInterceptor as BaseHttpInterceptor, 6 | HttpRequest 7 | } from '@angular/common/http' 8 | import { Injectable } from '@angular/core' 9 | import { catchError, distinctUntilChanged } from 'rxjs/operators' 10 | import { Observable, Subject, throwError } from 'rxjs' 11 | import { Router } from '@angular/router' 12 | import { NzMessageService } from 'ng-zorro-antd/message' 13 | import { AuthService } from './auth.service' 14 | 15 | @Injectable() 16 | export class HttpInterceptor implements BaseHttpInterceptor { 17 | 18 | private error$ = new Subject() 19 | 20 | constructor( 21 | private router: Router, 22 | private authService: AuthService, 23 | private message: NzMessageService, 24 | ) { 25 | this.error$.pipe(distinctUntilChanged()).subscribe(message => { 26 | this.message.error(message) 27 | }) 28 | } 29 | 30 | intercept(request: HttpRequest, next: HttpHandler): Observable> { 31 | request.headers.set('Accept-Type', 'application/json') 32 | 33 | return next.handle(request).pipe( 34 | catchError((errorResponse: HttpErrorResponse) => { 35 | console.log('HTTP Error ', errorResponse) 36 | if (errorResponse.status === 401) { 37 | this.authService.logout() 38 | this.router.navigateByUrl('/login') 39 | } 40 | if (errorResponse.status === 422) { 41 | this.handleValidationErrors(errorResponse.error) 42 | } else { 43 | this.error$.next(errorResponse.error.message) 44 | } 45 | 46 | return throwError(errorResponse) 47 | }) 48 | ) 49 | } 50 | 51 | private handleValidationErrors(error: {message: Record>}): void { 52 | Object.keys(error.message).forEach(formFieldKey => { 53 | const errors = error.message[formFieldKey] 54 | Object.values(errors).forEach(formError => { 55 | this.error$.next(formError) 56 | }) 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /frontend/src/app/services/api.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http' 2 | import { environment } from '../../environments/environment' 3 | 4 | interface HttpOptions { 5 | headers?: HttpHeaders | { 6 | [header: string]: string | string[]; 7 | } 8 | observe?: 'body' 9 | params?: HttpParams | { 10 | [param: string]: string | string[]; 11 | } 12 | reportProgress?: boolean 13 | responseType?: 'json' 14 | withCredentials?: boolean 15 | } 16 | 17 | export class ApiService { 18 | 19 | constructor( 20 | protected http: HttpClient 21 | ) {} 22 | 23 | getSessionToken(): string | undefined { 24 | return localStorage.getItem('token') || undefined 25 | } 26 | 27 | get(path: string, options: HttpOptions = {}): Promise { 28 | return this.http.get(this.getEndpoint(path), { 29 | params: options.params, 30 | headers: this.getHeaders(options.headers) 31 | }).toPromise() 32 | } 33 | 34 | post(path: string, data?: object, options?: HttpOptions): Promise { 35 | return this.http.post(this.getEndpoint(path), data, { 36 | params: options?.params, 37 | headers: this.getHeaders(options?.headers || {}) 38 | }).toPromise() 39 | } 40 | 41 | delete(path: string, headers?: object): Promise { 42 | return this.http.delete(this.getEndpoint(path), { 43 | headers: this.getHeaders(headers || {}) 44 | }).toPromise() 45 | } 46 | 47 | patch(path: string, data?: object, headers?: object): Promise { 48 | return this.http.patch(this.getEndpoint(path), data, { 49 | headers: this.getHeaders(headers || {}) 50 | }).toPromise() 51 | } 52 | 53 | put(path: string, data?: object, headers?: object): Promise { 54 | return this.http.put(this.getEndpoint(path), data, { 55 | headers: this.getHeaders(headers || {}) 56 | }).toPromise() 57 | } 58 | 59 | getEndpoint(path: string): string { 60 | return `${environment.apiUrl}/${path}` 61 | } 62 | 63 | getHeaders(headers: object = {}): HttpHeaders { 64 | return new HttpHeaders({ 65 | ...headers, 66 | Authorization: `Bearer ${this.getSessionToken()}` 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/tweets/tweets.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core' 2 | import { AppService, TweetService, UserService } from 'Services' 3 | import { Tweet } from '@Data/Models/Tweet' 4 | import { filter } from 'rxjs/operators' 5 | import { Subject } from 'rxjs' 6 | 7 | @Component({ 8 | selector: 'app-tweets', 9 | templateUrl: './tweets.component.html', 10 | styleUrls: ['./tweets.component.scss'] 11 | }) 12 | export class TweetsComponent implements OnInit { 13 | 14 | @Input() username?: string 15 | @Input() explore = false 16 | @Input() update$ = new Subject() 17 | 18 | tweets: Tweet[] = [] 19 | page = 1 20 | noMoreTweets = false 21 | loading = true 22 | 23 | constructor( 24 | private tweetService: TweetService, 25 | private userService: UserService, 26 | private appService: AppService, 27 | ) { } 28 | 29 | async ngOnInit(): Promise { 30 | this.update$.subscribe(async () => { 31 | this.reset() 32 | await this.load() 33 | }) 34 | await this.load() 35 | 36 | this.appService.scroll$.pipe( 37 | filter(event => { 38 | const target = event.target as HTMLElement 39 | return !this.loading && target.scrollTop >= target.scrollHeight - target.clientHeight - 100 40 | }) 41 | ).subscribe(async () => { 42 | this.page++ 43 | await this.load() 44 | }) 45 | } 46 | 47 | reset(): void { 48 | this.page = 1 49 | this.noMoreTweets = false 50 | this.tweets = [] 51 | } 52 | 53 | async load(): Promise { 54 | if (this.noMoreTweets) { 55 | return 56 | } 57 | this.loading = true 58 | let tweets: Tweet[] 59 | if (this.explore) { 60 | tweets = await this.userService.explore(this.page).finally(() => this.loading = false) 61 | } else { 62 | if (this.username) { 63 | tweets = await this.userService.getTweets(this.page, this.username).finally(() => this.loading = false) 64 | } else { 65 | tweets = await this.tweetService.getTweets(this.page).finally(() => this.loading = false) 66 | } 67 | } 68 | this.noMoreTweets = !tweets.length 69 | this.tweets.push(...tweets) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/notifications/notifications.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit, Type } from '@angular/core' 2 | import { Notification } from '@Data/Models/Notification' 3 | import { NotificationService, UserService } from 'Services' 4 | import { Subject } from 'rxjs' 5 | import { filter, startWith, takeUntil } from 'rxjs/operators' 6 | import { Router } from '@angular/router' 7 | import { 8 | BaseNotificationTemplate, 9 | FollowNotification, 10 | LikeNotification, 11 | MentionNotification, 12 | ReplyNotification, 13 | RetweetNotification 14 | } from '../../models' 15 | 16 | @Component({ 17 | selector: 'app-notifications', 18 | templateUrl: './notifications.component.html', 19 | styleUrls: ['./notifications.component.scss'] 20 | }) 21 | export class NotificationsComponent implements OnInit, OnDestroy { 22 | 23 | loading = true 24 | 25 | types: Record> = { 26 | follow: FollowNotification, 27 | reply: ReplyNotification, 28 | like: LikeNotification, 29 | retweet: RetweetNotification, 30 | mention: MentionNotification, 31 | } 32 | 33 | notifications: BaseNotificationTemplate[] = [] 34 | 35 | private destroy$ = new Subject() 36 | 37 | constructor( 38 | private notificationService: NotificationService, 39 | private userService: UserService, 40 | private router: Router, 41 | ) { } 42 | 43 | async ngOnInit(): Promise { 44 | this.loading = true 45 | 46 | this.userService.unreadNotifications$ 47 | .pipe(startWith(1), takeUntil(this.destroy$), filter(value => value > 0)) 48 | .subscribe(async () => { 49 | await this.load() 50 | }) 51 | } 52 | 53 | ngOnDestroy(): void { 54 | this.destroy$.next() 55 | } 56 | 57 | async redirect(notification: BaseNotificationTemplate): Promise { 58 | await this.router.navigateByUrl(notification.url) 59 | } 60 | 61 | private async load(): Promise { 62 | const notifications = await this.notificationService.list().finally(() => this.loading = false) 63 | this.notifications = notifications.map(notification => new this.types[notification.type](notification)) 64 | await this.notificationService.read() 65 | await this.userService.getUnreadNotifications() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/models.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@Data/Models/User' 2 | import { Notification } from '@Data/Models/Notification' 3 | import { Tweet } from '@Data/Models/Tweet' 4 | 5 | export interface NotificationTemplate { 6 | icon: string 7 | title: string 8 | users: User[] 9 | tweet?: Tweet 10 | } 11 | 12 | export abstract class BaseNotificationTemplate implements NotificationTemplate { 13 | abstract icon: string 14 | abstract titleSuffix: string 15 | 16 | constructor(public notification: Notification) {} 17 | 18 | get title(): string { 19 | const users = this.notification.notifiers 20 | return users.length === 1 21 | ? `${users[0].name} ${this.titleSuffix}` 22 | : users.length === 2 23 | ? `${users[0].name} and ${users[1].name} ${this.titleSuffix}` 24 | : `${users[0].name} and ${users.length - 1} others ${this.titleSuffix}` 25 | } 26 | 27 | get users(): User[] { 28 | return this.notification.notifiers 29 | } 30 | 31 | get tweet(): Tweet { 32 | return this.notification.tweet 33 | } 34 | 35 | abstract get url(): string 36 | } 37 | 38 | export class LikeNotification extends BaseNotificationTemplate implements NotificationTemplate { 39 | icon = 'like' 40 | titleSuffix = 'liked your tweet' 41 | 42 | get url(): string { 43 | return `/tweet/${this.tweet.id}` 44 | } 45 | } 46 | 47 | export class RetweetNotification extends BaseNotificationTemplate implements NotificationTemplate { 48 | icon = 'retweet' 49 | titleSuffix = 'retweeted your tweet' 50 | 51 | get url(): string { 52 | return `/tweet/${this.tweet.id}` 53 | } 54 | } 55 | 56 | export class ReplyNotification extends BaseNotificationTemplate implements NotificationTemplate { 57 | icon = 'comment' 58 | titleSuffix = 'replied on your tweet' 59 | 60 | get url(): string { 61 | return `/tweet/${this.tweet.id}` 62 | } 63 | } 64 | 65 | export class FollowNotification extends BaseNotificationTemplate implements NotificationTemplate { 66 | icon = 'user' 67 | titleSuffix = 'followed you' 68 | 69 | get url(): string { 70 | return `/${this.users[0].username}` 71 | } 72 | } 73 | 74 | export class MentionNotification extends BaseNotificationTemplate implements NotificationTemplate { 75 | icon = 'user' 76 | titleSuffix = 'mentioned you' 77 | 78 | get url(): string { 79 | return `/tweet/${this.tweet.id}` 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/tweet-form/tweet-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 2 | // import { FormBuilder } from '@angular/forms' 3 | import { Tweet } from '@Data/Models/Tweet' 4 | import { TweetService } from 'Services' 5 | import { TweetForm } from '@Data/Forms/TweetForm' 6 | import { FormBuilder, isValid } from '../../../util' 7 | import { NzUploadFile } from 'ng-zorro-antd/upload' 8 | 9 | @Component({ 10 | selector: 'app-tweet-form', 11 | templateUrl: './tweet-form.component.html', 12 | styleUrls: ['./tweet-form.component.scss'] 13 | }) 14 | export class TweetFormComponent implements OnInit { 15 | 16 | form = FormBuilder.build(TweetForm) 17 | 18 | media: string[] = [] 19 | 20 | loading = false 21 | 22 | @Input() showParent = true 23 | @Input() replyParent?: Tweet 24 | @Input() retweetParent?: Tweet 25 | @Input() placeholder = `What's new?` 26 | @Output() tweeted = new EventEmitter() 27 | 28 | constructor( 29 | private tweetService: TweetService 30 | ) {} 31 | 32 | ngOnInit(): void { 33 | } 34 | 35 | async tweet(): Promise { 36 | if (!isValid(this.form)) { 37 | return 38 | } 39 | 40 | this.loading = true 41 | let tweet: Tweet 42 | if (this.replyParent) { 43 | tweet = await this.tweetService.reply(this.replyParent.id, this.form.value).finally(() => {this.loading = false}) 44 | } else if (this.retweetParent) { 45 | tweet = await this.tweetService.retweet(this.retweetParent.id, this.form.value).finally(() => {this.loading = false}) 46 | } else { 47 | tweet = await this.tweetService.tweet(this.form.value).finally(() => {this.loading = false}) 48 | } 49 | this.tweeted.emit(tweet) 50 | this.media = [] 51 | this.form.reset() 52 | } 53 | 54 | beforeUpload(): (file: NzUploadFile) => boolean { 55 | return (file: NzUploadFile) => { 56 | this.addMedia(file as unknown as File) 57 | return false 58 | } 59 | } 60 | 61 | addMedia(media: File): void { 62 | this.form.controls.media.value.push(media) 63 | const fileURL = URL.createObjectURL(media) 64 | this.media.push(fileURL) 65 | } 66 | 67 | removeMedia(index: number): void { 68 | this.form.controls.media.value.remove(this.form.controls.media.value[index]) 69 | this.media.remove(this.media[index]) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Controllers/Http/HomeController.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Middleware, Query } from '@Typetron/Router' 2 | import { Tweet } from 'App/Entities/Tweet' 3 | import { Tweet as TweetModel } from 'App/Models/Tweet' 4 | import { AuthMiddleware } from '@Typetron/Framework/Middleware' 5 | import { User } from 'App/Entities/User' 6 | import { AuthUser } from '@Typetron/Framework/Auth' 7 | import { Hashtag } from 'App/Entities/Hashtag' 8 | 9 | @Controller() 10 | @Middleware(AuthMiddleware) 11 | export class HomeController { 12 | 13 | @AuthUser() 14 | user: User 15 | 16 | @Get() 17 | async tweets(@Query('page') page: number = 1, @Query('limit') limit: number = 10) { 18 | const followings = await this.user.following.get() 19 | const tweets = this.getTweetsQuery(page, limit).whereIn('userId', followings.pluck('id').concat(this.user.id)) 20 | 21 | return TweetModel.from(tweets.get()) 22 | } 23 | 24 | @Get('explore') 25 | async explore(@Query('page') page: number = 1, @Query('limit') limit: number = 10) { 26 | await this.user.load('topics') 27 | const userHashtags = await Hashtag.whereIn('topic', this.user.topics.items.pluck('id')).get() 28 | const tweets = this.getTweetsQuery(page, limit) 29 | if (userHashtags.length) { 30 | tweets.whereIn( 31 | 'id', 32 | query => query.table('hashtags_tweets').select('tweetId').whereIn('hashTagId', userHashtags.pluck('id')) 33 | ) 34 | } 35 | 36 | return TweetModel.from(tweets.get()) 37 | } 38 | 39 | @Get(':username/tweets') 40 | async userTweets(username: string, @Query('page') page: number = 1, @Query('limit') limit: number = 10) { 41 | const user = await User.where('username', username).first() 42 | 43 | if (!user) { 44 | throw new Error('User not found') 45 | } 46 | 47 | const tweets = this.getTweetsQuery(page, limit).where('userId', user.id) 48 | 49 | return TweetModel.from(tweets.get()) 50 | } 51 | 52 | getTweetsQuery(page: number, limit: number) { 53 | return Tweet 54 | .with( 55 | 'user', 56 | 'media', 57 | 'replyParent.user', 58 | 'retweetParent.user', 59 | ['likes', query => query.where('userId', this.user.id)] 60 | ) 61 | .withCount('likes', 'replies', 'retweets') 62 | .orderBy('createdAt', 'DESC') 63 | .limit((page - 1) * limit, limit) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /frontend/src/app/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core' 2 | import { HttpClient } from '@angular/common/http' 3 | import { User } from '@Data/Models/User' 4 | import { toFormData } from '../util' 5 | import { Topic } from '@Data/Models/Topic' 6 | import { TopicsForm } from '@Data/Forms/TopicsForm' 7 | import { BehaviorSubject } from 'rxjs' 8 | import { ApiService } from './api.service' 9 | import { Tweet } from '@Data/Models/Tweet' 10 | import { UserForm } from '@Data/Forms/UserForm' 11 | 12 | @Injectable({ 13 | providedIn: 'root' 14 | }) 15 | export class UserService extends ApiService { 16 | 17 | unreadNotifications$ = new BehaviorSubject(0) 18 | 19 | constructor(http: HttpClient) { 20 | super(http) 21 | } 22 | 23 | async edit(form: UserForm): Promise { 24 | return this.put('users', toFormData(form)) 25 | } 26 | 27 | async getUser(username: string): Promise { 28 | return this.get(`users/${username}`) 29 | } 30 | 31 | async followers(username: string): Promise { 32 | return this.get(`users/${username}/followers`) 33 | } 34 | 35 | async following(username: string): Promise { 36 | return this.get(`users/${username}/following`) 37 | } 38 | 39 | async follow(id: number): Promise { 40 | return this.post(`users/${id}/follow`) 41 | } 42 | 43 | async unfollow(id: number): Promise { 44 | return this.post(`users/${id}/unfollow`) 45 | } 46 | 47 | async allTopics(): Promise { 48 | return this.get(`topics`) 49 | } 50 | 51 | async topics(): Promise { 52 | return this.get(`users/topics`) 53 | } 54 | 55 | async saveTopics(form: TopicsForm): Promise { 56 | return this.post(`users/topics`, form) 57 | } 58 | 59 | async getUnreadNotifications(): Promise { 60 | this.unreadNotifications$.next(await this.get('notifications/unread')) 61 | } 62 | 63 | explore(page = 1): Promise { 64 | return this.get(`explore`, { 65 | params: { 66 | page: page.toString(), 67 | } 68 | }) 69 | } 70 | 71 | getTweets(page = 1, username: string): Promise { 72 | const params: Record = { 73 | page: page.toString(), 74 | } 75 | if (username) { 76 | params.username = username 77 | } 78 | return this.get(`${username}/tweets`, {params}) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Typetron 7 | 51 | 52 | 53 |
    54 | 61 |
    62 | 63 |

    64 | 67 | 68 |

    69 | 81 |
    82 | 85 |
    86 | 87 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/edit-form/edit-form.component.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 | Cover 4 | 5 | 6 | 7 | 8 | 9 |
    10 |
    11 | 12 | Photo 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | Name 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | Username 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | Bio 46 | 47 | 48 | 49 | 50 |
    51 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "arrow-return-shorthand": true, 4 | "callable-types": true, 5 | "class-name": true, 6 | "comment-format": [ 7 | true, 8 | "check-space" 9 | ], 10 | "curly": true, 11 | "deprecation": { 12 | "severity": "warn" 13 | }, 14 | "eofline": false, 15 | "forin": true, 16 | "ban-types": true, 17 | "only-arrow-functions": false, 18 | "import-blacklist": [ 19 | true, 20 | "rxjs/Rx" 21 | ], 22 | "import-spacing": true, 23 | "no-any": true, 24 | "no-unsafe-any": true, 25 | "indent": [ 26 | true, 27 | "spaces" 28 | ], 29 | "label-position": true, 30 | "max-line-length": [ 31 | false, 32 | 140 33 | ], 34 | "member-access": false, 35 | "no-arg": true, 36 | "no-bitwise": true, 37 | "no-console": [ 38 | true, 39 | "debug", 40 | "info", 41 | "time", 42 | "timeEnd", 43 | "trace" 44 | ], 45 | "no-construct": true, 46 | "no-debugger": true, 47 | "no-duplicate-super": true, 48 | "no-empty": false, 49 | "no-empty-interface": true, 50 | "no-eval": true, 51 | "no-inferrable-types": [ 52 | true, 53 | "ignore-params" 54 | ], 55 | "no-misused-new": true, 56 | "no-non-null-assertion": true, 57 | "no-redundant-jsdoc": true, 58 | "no-shadowed-variable": true, 59 | "no-string-literal": false, 60 | "no-string-throw": true, 61 | "no-switch-case-fall-through": true, 62 | "no-trailing-whitespace": false, 63 | "no-unnecessary-initializer": true, 64 | "no-unused-expression": true, 65 | "no-use-before-declare": true, 66 | "no-var-keyword": true, 67 | "object-literal-sort-keys": false, 68 | "one-line": [ 69 | true, 70 | "check-open-brace", 71 | "check-catch", 72 | "check-else", 73 | "check-whitespace" 74 | ], 75 | "prefer-const": true, 76 | "quotemark": [ 77 | true, 78 | "single" 79 | ], 80 | "radix": true, 81 | "semicolon": [ 82 | false, 83 | "always" 84 | ], 85 | "triple-equals": [ 86 | true, 87 | "allow-null-check" 88 | ], 89 | "typedef-whitespace": [ 90 | true, 91 | { 92 | "call-signature": "nospace", 93 | "index-signature": "nospace", 94 | "parameter": "nospace", 95 | "property-declaration": "nospace", 96 | "variable-declaration": "nospace" 97 | } 98 | ], 99 | "unified-signatures": true, 100 | "variable-name": false, 101 | "whitespace": [ 102 | true, 103 | "check-branch", 104 | "check-decl", 105 | "check-operator", 106 | "check-separator", 107 | "check-type" 108 | ], 109 | "no-output-on-prefix": true, 110 | "use-input-property-decorator": true, 111 | "use-output-property-decorator": true, 112 | "use-host-property-decorator": true, 113 | "no-input-rename": true, 114 | "no-output-rename": true, 115 | "use-life-cycle-interface": true, 116 | "use-pipe-transform-interface": true, 117 | "component-class-suffix": true, 118 | "directive-class-suffix": true 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /frontend/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** 22 | * IE11 requires the following for NgClass support on SVG elements 23 | */ 24 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 25 | 26 | /** 27 | * Web Animations `@angular/platform-browser/animations` 28 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 29 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 30 | */ 31 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 32 | 33 | /** 34 | * By default, zone.js will patch all possible macroTask and DomEvents 35 | * user can disable parts of macroTask/DomEvents patch by setting following flags 36 | * because those flags need to be set before `zone.js` being loaded, and webpack 37 | * will put import in the top of bundle, so user need to create a separate file 38 | * in this directory (for example: zone-flags.ts), and put the following flags 39 | * into that file, and then add the following code before importing zone.js. 40 | * import './zone-flags'; 41 | * 42 | * The flags allowed in zone-flags.ts are listed here. 43 | * 44 | * The following flags will work for all browsers. 45 | * 46 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 47 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 48 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 49 | * 50 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 51 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 52 | * 53 | * (window as any).__Zone_enable_cross_context_check = true; 54 | * 55 | */ 56 | 57 | /*************************************************************************************************** 58 | * Zone JS is required by default for Angular itself. 59 | */ 60 | import 'zone.js' // Included with Angular CLI. 61 | 62 | /*************************************************************************************************** 63 | * APPLICATION IMPORTS 64 | */ 65 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/profile.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core' 2 | import { EditFormComponent } from './edit-form/edit-form.component' 3 | import { NzModalService } from 'ng-zorro-antd/modal' 4 | import { environment } from '../../../../environments/environment' 5 | import { ActivatedRoute } from '@angular/router' 6 | import { User } from '@Data/Models/User' 7 | import { TopicsFormComponent } from './topics-form/topics-form.component' 8 | import { AuthService, UserService } from 'Services' 9 | import { isValid } from '../../../util' 10 | 11 | @Component({ 12 | selector: 'app-profile', 13 | templateUrl: './profile.component.html', 14 | styleUrls: ['./profile.component.scss'] 15 | }) 16 | export class ProfileComponent implements OnInit { 17 | 18 | imgPath = environment.apiUrl 19 | user?: User 20 | loading = true 21 | 22 | constructor( 23 | private modal: NzModalService, 24 | private userService: UserService, 25 | public authService: AuthService, 26 | private route: ActivatedRoute 27 | ) { } 28 | 29 | async ngOnInit(): Promise { 30 | this.route.params.subscribe(async (params) => { 31 | this.user = undefined 32 | this.loading = true 33 | await this.load(params.username) 34 | }) 35 | } 36 | 37 | async load(username: string): Promise { 38 | this.user = await this.userService.getUser(username).finally(() => this.loading = false) 39 | } 40 | 41 | showEditModal(): void { 42 | this.modal.create({ 43 | nzTitle: 'Edit profile', 44 | nzContent: EditFormComponent, 45 | nzWidth: 700, 46 | nzComponentParams: { 47 | user: this.user 48 | }, 49 | nzOnOk: async (modal) => { 50 | if (!isValid(modal.form)) { 51 | return false 52 | } 53 | 54 | try { 55 | const user = await this.userService.edit(modal.form.value) 56 | this.authService.setUser(this.user = user) 57 | } catch { 58 | return false 59 | } 60 | } 61 | }) 62 | } 63 | 64 | showTopicsModal(user: User): void { 65 | this.modal.create({ 66 | nzTitle: user.id === this.authService.user()?.id ? 'Edit topics' : 'Topics', 67 | nzContent: TopicsFormComponent, 68 | nzComponentParams: { 69 | user: user.id 70 | }, 71 | nzOnOk: async (modal) => { 72 | await this.userService.saveTopics(modal.form.value) 73 | // this.authService.setUser(user) 74 | } 75 | }) 76 | } 77 | 78 | async follow(user: User): Promise { 79 | await this.userService.follow(user.id) 80 | await this.load(user.username) 81 | } 82 | 83 | async unfollow(user: User): Promise { 84 | await this.userService.unfollow(user.id) 85 | await this.load(user.username) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /frontend/src/styles.scss: -------------------------------------------------------------------------------- 1 | $small-spacing: 8px; 2 | $medium-spacing: 16px; 3 | $large-spacing: 24px; 4 | 5 | $space: ( 6 | small: $small-spacing, 7 | medium: $medium-spacing, 8 | large: $large-spacing, 9 | ); 10 | 11 | html { 12 | overflow: hidden !important; 13 | } 14 | 15 | body { 16 | //overflow-y: scroll; 17 | height: 100vh; 18 | } 19 | 20 | 21 | @each $key, $val in $space { 22 | .margin-#{$key} { 23 | margin: $val; 24 | } 25 | .margin-y-#{$key} { 26 | margin-top: $val; 27 | margin-bottom: $val; 28 | } 29 | .margin-x-#{$key} { 30 | margin-left: $val; 31 | margin-right: $val; 32 | } 33 | .margin-l-#{$key} { 34 | margin-left: $val; 35 | } 36 | .margin-t-#{$key} { 37 | margin-top: $val; 38 | } 39 | .margin-r-#{$key} { 40 | margin-right: $val; 41 | } 42 | .margin-b-#{$key} { 43 | margin-bottom: $val; 44 | } 45 | .padding-#{$key} { 46 | padding: $val; 47 | } 48 | .padding-y-#{$key} { 49 | padding-top: $val; 50 | padding-bottom: $val; 51 | } 52 | .padding-x-#{$key} { 53 | padding-left: $val; 54 | padding-right: $val; 55 | } 56 | .padding-l-#{$key} { 57 | padding-left: $val; 58 | } 59 | .padding-t-#{$key} { 60 | padding-top: $val; 61 | } 62 | .padding-r-#{$key} { 63 | padding-right: $val; 64 | } 65 | .padding-b-#{$key} { 66 | padding-bottom: $val; 67 | } 68 | } 69 | 70 | .no-margin { 71 | margin: 0; 72 | } 73 | 74 | .no-padding { 75 | padding: 0; 76 | } 77 | 78 | input:focus, 79 | select:focus, 80 | textarea:focus, 81 | a:focus, 82 | [nz-menu] li:focus, 83 | button:focus { 84 | outline: none; 85 | } 86 | 87 | .text-center { 88 | text-align: center; 89 | } 90 | 91 | .page-header { 92 | padding: 12px; 93 | //display: flex; 94 | //align-items: center; 95 | background-color: #fff; 96 | 97 | h2.page-title { 98 | margin-bottom: 0; 99 | } 100 | } 101 | 102 | app-spin { 103 | margin: 16px 0; 104 | } 105 | 106 | 107 | .content { 108 | background-color: #fff; 109 | } 110 | 111 | .tweet-carousel { 112 | height: 300px; 113 | max-width: 620px; 114 | width: 100%; 115 | user-select: none; 116 | 117 | *[nz-carousel-content] { 118 | background-repeat: no-repeat; 119 | background-size: cover; 120 | background-position: center; 121 | } 122 | } 123 | 124 | .clickable { 125 | cursor: pointer; 126 | 127 | &[ng-reflect-router-link]:focus { 128 | outline: none; 129 | } 130 | } 131 | 132 | 133 | .full-width { 134 | width: 100%; 135 | } 136 | 137 | .full-height { 138 | height: 100%; 139 | } 140 | 141 | .flex-no-wrap { 142 | flex-wrap: nowrap; 143 | } 144 | 145 | .avatar { 146 | vertical-align: middle; 147 | color: #888; 148 | background-size: cover; 149 | background-position: center; 150 | } 151 | 152 | .highlight-tweet { 153 | border-radius: 5px; 154 | border: 1px solid #f1f1f1 155 | } 156 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/base-page/base-page.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |

    Topics

    61 |

    {{topic.name}}

    62 |
    63 | 64 |
    65 |
    66 | 67 |
    68 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/tweet/tweet.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core' 2 | import { Tweet } from '@Data/Models/Tweet' 3 | import { NzMessageService } from 'ng-zorro-antd/message' 4 | import { environment } from '../../../../environments/environment' 5 | import { NzModalService } from 'ng-zorro-antd/modal' 6 | import { TweetFormComponent } from '../tweet-form/tweet-form.component' 7 | import { AuthService, TweetService } from 'Services' 8 | 9 | @Component({ 10 | selector: 'app-tweet', 11 | templateUrl: './tweet.component.html', 12 | styleUrls: ['./tweet.component.scss'] 13 | }) 14 | export class TweetComponent implements OnChanges { 15 | imgPath = environment.apiUrl 16 | 17 | @Input() tweet!: Tweet 18 | @Input() showReplyForm = false 19 | @Input() footer = true 20 | @Output() delete = new EventEmitter() 21 | @Output() tweeted = new EventEmitter() 22 | 23 | color = `rgba(${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)}, 0.2)` 24 | 25 | replies: Tweet[] = [] 26 | 27 | constructor( 28 | private tweetService: TweetService, 29 | private message: NzMessageService, 30 | public auth: AuthService, 31 | private modal: NzModalService, 32 | ) { 33 | } 34 | 35 | get likedByCurrentUser(): boolean { 36 | return !!this.tweet.likes.find(item => item.user.id === this.auth.user()?.id) 37 | } 38 | 39 | async remove(): Promise { 40 | await this.tweetService.remove(this.tweet.id) 41 | this.message.success('Tweet deleted') 42 | this.delete.emit() 43 | } 44 | 45 | async toggleLike(): Promise { 46 | const tweet = await this.tweetService.toggleLike(this.tweet.id) 47 | Object.assign(this.tweet, tweet) 48 | } 49 | 50 | async retweet(): Promise { 51 | const modal = this.modal.create({ 52 | nzTitle: 'Retweet', 53 | nzWidth: 650, 54 | nzContent: TweetFormComponent, 55 | nzComponentParams: { 56 | retweetParent: { 57 | ...this.tweet, 58 | replyParent: undefined, 59 | retweetParent: undefined, 60 | }, 61 | placeholder: 'Retweet comment' 62 | }, 63 | nzFooter: null 64 | }) 65 | modal.componentInstance?.tweeted.subscribe(tweet => { 66 | this.modal.closeAll() 67 | this.tweeted.emit(tweet) 68 | }) 69 | } 70 | 71 | async reply(): Promise { 72 | const modal = this.modal.create({ 73 | nzTitle: 'Reply', 74 | nzWidth: 650, 75 | nzContent: TweetFormComponent, 76 | nzComponentParams: { 77 | replyParent: { 78 | ...this.tweet, 79 | replyParent: undefined, 80 | retweetParent: undefined, 81 | }, 82 | placeholder: 'Write your reply' 83 | }, 84 | nzFooter: null, 85 | }) 86 | 87 | modal.componentInstance?.tweeted.subscribe(tweet => { 88 | this.modal.closeAll() 89 | this.tweeted.emit(tweet) 90 | }) 91 | } 92 | 93 | async ngOnChanges(changes: SimpleChanges): Promise { 94 | this.tweet = changes.tweet.currentValue 95 | await this.load() 96 | } 97 | 98 | async load(): Promise { 99 | if (this.showReplyForm) { 100 | this.replies = await this.tweetService.replies(this.tweet.id) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Controllers/Http/UsersController.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Middleware, Post, Put } from '@Typetron/Router' 2 | import { Inject } from '@Typetron/Container' 3 | import { AuthUser } from '@Typetron/Framework/Auth' 4 | import { User } from 'App/Entities/User' 5 | import { AuthMiddleware } from '@Typetron/Framework/Middleware' 6 | import { UserForm } from 'App/Forms/UserForm' 7 | import { User as UserModel } from 'App/Models/User' 8 | import { Storage } from '@Typetron/Storage' 9 | import { TopicsForm } from 'App/Forms/TopicsForm' 10 | import { Notification } from 'App/Entities/Notification' 11 | import { Query } from '@Typetron/Database' 12 | 13 | @Controller('users') 14 | @Middleware(AuthMiddleware) 15 | export class UsersController { 16 | 17 | @AuthUser() 18 | user: User 19 | 20 | @Inject() 21 | storage: Storage 22 | 23 | @Get(':username/followers') 24 | async followers(username: string) { 25 | const user = await User.where('username', username).first() 26 | 27 | if (!user) { 28 | throw new Error('User not found') 29 | } 30 | 31 | const users = await User 32 | .whereIn('id', Query.table('followers').select('followerId').where('followingId', user.id)) 33 | .with(['followers', query => query.where('followerId', this.user.id)]) 34 | .get() 35 | 36 | return UserModel.from(users) 37 | } 38 | 39 | @Get(':username/following') 40 | async following(username: string) { 41 | const user = await User.where('username', username).first() 42 | 43 | if (!user) { 44 | throw new Error('User not found') 45 | } 46 | 47 | const users = await User 48 | .whereIn('id', Query.table('followers').select('followingId').where('followerId', user.id)) 49 | .with(['followers', query => query.where('followerId', this.user.id)]) 50 | .get() 51 | 52 | return UserModel.from(users) 53 | } 54 | 55 | @Put() 56 | async update(form: UserForm) { 57 | if (form.photo) { 58 | await this.storage.delete(`public/${this.user.photo}`) 59 | form.photo = await this.storage.save(form.photo, 'public') 60 | } 61 | if (form.cover) { 62 | await this.storage.delete(`public/${this.user.cover}`) 63 | form.cover = await this.storage.save(form.cover, 'public') 64 | } 65 | await this.user.save(form) 66 | await this.user.loadCount('followers', 'following') 67 | return UserModel.from(this.user) 68 | } 69 | 70 | @Post(':User/follow') 71 | async follow(userToFollow: User) { 72 | await this.user.following.add(userToFollow.id) 73 | 74 | const notification = await Notification.firstOrCreate({ 75 | type: 'follow', 76 | user: userToFollow, 77 | readAt: undefined 78 | }) 79 | 80 | if (!await notification.notifiers.has(this.user.id)) { 81 | await notification.notifiers.add(this.user.id) 82 | } 83 | 84 | return UserModel.from(this.user) 85 | } 86 | 87 | @Post(':User/unfollow') 88 | async unfollow(userToUnfollow: User) { 89 | await this.user.following.remove(userToUnfollow.id) 90 | return UserModel.from(this.user) 91 | } 92 | 93 | @Get('topics') 94 | async getTopics() { 95 | return this.user.topics.get() 96 | } 97 | 98 | @Post('topics') 99 | async setTopics(form: TopicsForm) { 100 | await this.user.topics.sync(...form.topics) 101 | } 102 | 103 | @Get(':username') 104 | async get(username: string) { 105 | const user = await User 106 | .withCount('followers', 'following') 107 | .with(['followers', query => query.where('followerId', this.user.id)]) 108 | .where('username', username) 109 | .first() 110 | if (!user) { 111 | throw new Error('User not found') 112 | } 113 | return UserModel.from(user) 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/components/tweet/tweet.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |

    {{tweet.user?.name}}

    21 | @{{tweet.user?.username}} 22 |
    23 | - 24 | {{tweet.createdAt | date: 'dd MMMM y HH:mm'}} 25 |
    26 | 27 | 30 | 31 |
    32 | 33 | 34 |

    35 | {{tweet.content}} 36 |

    37 |
    38 |
    39 |
    40 | 41 | 42 |
    43 |
    44 |
    45 |
    46 | 47 | 50 | 53 | 57 | 58 |
    59 | 60 |
    61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
      76 |
    • 77 | Delete 78 |
    • 79 |
    80 |
    81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/pages/profile/profile.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
    4 |
    7 | 8 | 9 | 10 | 11 | 12 | 20 | 21 | 22 | 31 | 32 | 33 | 37 | 38 | 39 | 43 | 44 | 45 | 46 | 47 |

    {{user.name}}

    48 |

    @{{user.username}}

    49 |

    {{user.bio}}

    50 | 51 | 52 | {{user.followingCount || 0}} Following 53 | 54 | 55 | {{user.followersCount || 0}} Followers 56 | 57 | 58 |
    59 | 60 | 61 | 62 | Tweets 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 |
    84 | -------------------------------------------------------------------------------- /frontend/src/app/dashboard/dashboard.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core' 2 | import { CommonModule } from '@angular/common' 3 | 4 | import { DashboardRoutingModule } from './dashboard-routing.module' 5 | import { HomeComponent } from './pages/home/home.component' 6 | import { TweetComponent as TweetComponentPage } from './pages/tweet/tweet.component' 7 | import { BasePageComponent } from './components/base-page/base-page.component' 8 | import { TweetComponent } from './components/tweet/tweet.component' 9 | import { ExploreComponent } from './pages/explore/explore.component' 10 | import { NotificationsComponent } from './pages/notifications/notifications.component' 11 | import { ProfileComponent } from './pages/profile/profile.component' 12 | import { SettingsComponent } from './pages/settings/settings.component' 13 | import { SpinComponent } from './components/spin/spin.component' 14 | import { NotificationComponent } from './components/notification/notification.component' 15 | import { TweetsComponent } from './pages/profile/tweets/tweets.component' 16 | import { TweetsAndRepliesComponent } from './pages/profile/tweets-and-replies/tweets-and-replies.component' 17 | import { LikesComponent } from './pages/profile/likes/likes.component' 18 | import { EditFormComponent } from './pages/profile/edit-form/edit-form.component' 19 | import { ReactiveFormsModule } from '@angular/forms' 20 | import { NzLayoutModule } from 'ng-zorro-antd/layout' 21 | import { NzMenuModule } from 'ng-zorro-antd/menu' 22 | import { NzFormModule } from 'ng-zorro-antd/form' 23 | import { NzTabsModule } from 'ng-zorro-antd/tabs' 24 | import { NzTypographyModule } from 'ng-zorro-antd/typography' 25 | import { NzButtonModule } from 'ng-zorro-antd/button' 26 | import { NzSpinModule } from 'ng-zorro-antd/spin' 27 | import { NzGridModule } from 'ng-zorro-antd/grid' 28 | import { NzBreadCrumbModule } from 'ng-zorro-antd/breadcrumb' 29 | import { NzDividerModule } from 'ng-zorro-antd/divider' 30 | import { NzUploadModule } from 'ng-zorro-antd/upload' 31 | import { NzIconModule } from 'ng-zorro-antd/icon' 32 | import { NzAvatarModule } from 'ng-zorro-antd/avatar' 33 | import { NzModalModule } from 'ng-zorro-antd/modal' 34 | import { NzInputModule } from 'ng-zorro-antd/input' 35 | import { NzDropDownModule } from 'ng-zorro-antd/dropdown' 36 | import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm' 37 | import { FollowersComponent } from './pages/followers/followers.component' 38 | import { FollowingsComponent } from './pages/followings/followings.component' 39 | import { TweetFormComponent } from './components/tweet-form/tweet-form.component' 40 | import { TopicsFormComponent } from './pages/profile/topics-form/topics-form.component' 41 | import { NzListModule } from 'ng-zorro-antd/list' 42 | import { NzToolTipModule } from 'ng-zorro-antd/tooltip' 43 | import { NzPipesModule } from 'ng-zorro-antd/pipes' 44 | import { NzCarouselModule } from 'ng-zorro-antd/carousel' 45 | import { NzBadgeModule } from 'ng-zorro-antd/badge' 46 | import { NzCardModule } from 'ng-zorro-antd/card' 47 | import { FollowerComponent } from './components/follower/follower.component' 48 | import { SharedModule } from '../shared/shared.module' 49 | 50 | @NgModule({ 51 | declarations: [ 52 | HomeComponent, 53 | BasePageComponent, 54 | TweetComponent, 55 | TweetFormComponent, 56 | ExploreComponent, 57 | NotificationsComponent, 58 | ProfileComponent, 59 | SettingsComponent, 60 | SpinComponent, 61 | NotificationComponent, 62 | TweetsComponent, 63 | TweetsAndRepliesComponent, 64 | LikesComponent, 65 | EditFormComponent, 66 | FollowersComponent, 67 | FollowingsComponent, 68 | TweetComponentPage, 69 | TopicsFormComponent, 70 | FollowerComponent 71 | ], 72 | imports: [ 73 | CommonModule, 74 | DashboardRoutingModule, 75 | NzLayoutModule, 76 | NzMenuModule, 77 | NzBreadCrumbModule, 78 | NzGridModule, 79 | NzIconModule, 80 | NzDividerModule, 81 | NzAvatarModule, 82 | NzTypographyModule, 83 | NzSpinModule, 84 | NzButtonModule, 85 | NzTabsModule, 86 | NzFormModule, 87 | ReactiveFormsModule, 88 | NzInputModule, 89 | NzModalModule, 90 | NzUploadModule, 91 | NzDropDownModule, 92 | NzPopconfirmModule, 93 | NzListModule, 94 | NzToolTipModule, 95 | NzPipesModule, 96 | NzCarouselModule, 97 | NzBadgeModule, 98 | NzCardModule, 99 | SharedModule, 100 | ] 101 | }) 102 | export class DashboardModule {} 103 | -------------------------------------------------------------------------------- /frontend/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "cli": { 4 | "analytics": false 5 | }, 6 | "version": 1, 7 | "newProjectRoot": "projects", 8 | "projects": { 9 | "frontend2": { 10 | "projectType": "application", 11 | "schematics": { 12 | "@schematics/angular:component": { 13 | "style": "scss" 14 | }, 15 | "@schematics/angular:application": { 16 | "strict": true 17 | } 18 | }, 19 | "root": "", 20 | "sourceRoot": "src", 21 | "prefix": "app", 22 | "architect": { 23 | "build": { 24 | "builder": "@angular-devkit/build-angular:browser", 25 | "options": { 26 | "outputPath": "dist/frontend2", 27 | "index": "src/index.html", 28 | "main": "src/main.ts", 29 | "polyfills": "src/polyfills.ts", 30 | "tsConfig": "tsconfig.app.json", 31 | "inlineStyleLanguage": "scss", 32 | "assets": [ 33 | "src/favicon.ico", 34 | "src/assets", 35 | { 36 | "glob": "**/*", 37 | "input": "./node_modules/@ant-design/icons-angular/src/inline-svg/", 38 | "output": "/assets/" 39 | } 40 | ], 41 | "styles": [ 42 | "src/theme.less", 43 | "src/styles.scss" 44 | ], 45 | "scripts": [] 46 | }, 47 | "configurations": { 48 | "production": { 49 | "budgets": [ 50 | { 51 | "type": "initial", 52 | "maximumWarning": "500kb", 53 | "maximumError": "1mb" 54 | }, 55 | { 56 | "type": "anyComponentStyle", 57 | "maximumWarning": "2kb", 58 | "maximumError": "4kb" 59 | } 60 | ], 61 | "fileReplacements": [ 62 | { 63 | "replace": "src/environments/environment.ts", 64 | "with": "src/environments/environment.prod.ts" 65 | } 66 | ], 67 | "outputHashing": "all" 68 | }, 69 | "development": { 70 | "buildOptimizer": false, 71 | "optimization": false, 72 | "vendorChunk": true, 73 | "extractLicenses": false, 74 | "sourceMap": true, 75 | "namedChunks": true 76 | } 77 | }, 78 | "defaultConfiguration": "production" 79 | }, 80 | "serve": { 81 | "builder": "@angular-devkit/build-angular:dev-server", 82 | "configurations": { 83 | "production": { 84 | "browserTarget": "frontend2:build:production" 85 | }, 86 | "development": { 87 | "browserTarget": "frontend2:build:development" 88 | } 89 | }, 90 | "defaultConfiguration": "development" 91 | }, 92 | "extract-i18n": { 93 | "builder": "@angular-devkit/build-angular:extract-i18n", 94 | "options": { 95 | "browserTarget": "frontend2:build" 96 | } 97 | }, 98 | "test": { 99 | "builder": "@angular-devkit/build-angular:karma", 100 | "options": { 101 | "main": "src/test.ts", 102 | "polyfills": "src/polyfills.ts", 103 | "tsConfig": "tsconfig.spec.json", 104 | "karmaConfig": "karma.conf.js", 105 | "inlineStyleLanguage": "scss", 106 | "assets": [ 107 | "src/favicon.ico", 108 | "src/assets" 109 | ], 110 | "styles": [ 111 | "src/styles.scss" 112 | ], 113 | "scripts": [] 114 | } 115 | } 116 | } 117 | } 118 | }, 119 | "defaultProject": "frontend2" 120 | } 121 | -------------------------------------------------------------------------------- /Controllers/Http/TweetsController.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Delete, Get, Middleware, Post } from '@Typetron/Router' 2 | import { Tweet } from 'App/Entities/Tweet' 3 | import { TweetForm } from 'App/Forms/TweetForm' 4 | import { User } from 'App/Entities/User' 5 | import { AuthMiddleware } from '@Typetron/Framework/Middleware' 6 | import { AuthUser } from '@Typetron/Framework/Auth' 7 | import { Like } from 'App/Entities/Like' 8 | import { Tweet as TweetModel } from '@Data/Models/Tweet' 9 | import { Inject } from '@Typetron/Container' 10 | import { File, Storage } from '@Typetron/Storage' 11 | import { Http, HttpError } from '@Typetron/Router/Http' 12 | import { Notification } from 'App/Entities/Notification' 13 | import { Hashtag } from 'App/Entities/Hashtag' 14 | import { Media } from 'App/Entities/Media' 15 | import { EntityObject, ID } from '@Typetron/Database' 16 | 17 | @Controller('tweets') 18 | @Middleware(AuthMiddleware) 19 | export class TweetsController { 20 | 21 | @AuthUser() 22 | user: User 23 | 24 | @Inject() 25 | storage: Storage 26 | 27 | @Get(':Tweet') 28 | async get(tweet: Tweet) { 29 | await tweet.loadCount('likes', 'replies', 'retweets') 30 | await tweet.load( 31 | 'user', 32 | 'media', 33 | 'replyParent.user', 34 | 'retweetParent.user', 35 | ['likes', query => query.where('userId', this.user.id)] 36 | ) 37 | return TweetModel.from(tweet) 38 | } 39 | 40 | @Get(':id/replies') 41 | async replies(tweet: number) { 42 | const replies = await Tweet 43 | .with('user', 'media') 44 | .withCount('likes', 'replies', 'retweets') 45 | .where('replyParentId', tweet) 46 | .get() 47 | return TweetModel.from(replies) 48 | } 49 | 50 | @Post() 51 | async tweet(form: TweetForm) { 52 | return TweetModel.from(this.createTweet(form)) 53 | } 54 | 55 | @Post(':Tweet/reply') 56 | async reply(parent: Tweet, form: TweetForm) { 57 | const tweet = await this.createTweet(form, {replyParent: parent}) 58 | 59 | await this.addTweetNotification(tweet, parent, 'reply') 60 | 61 | return TweetModel.from(tweet) 62 | } 63 | 64 | @Post(':Tweet/retweet') 65 | async retweet(parent: Tweet, form: TweetForm) { 66 | const tweet = await this.createTweet(form, {retweetParent: parent}) 67 | 68 | await this.addTweetNotification(tweet, parent, 'reply') 69 | 70 | return TweetModel.from(tweet) 71 | } 72 | 73 | @Post(':Tweet/like') 74 | async like(tweet: Tweet) { 75 | let notification: Notification | undefined 76 | /** 77 | * Check to see if the tweet's user is not its author because 78 | * we don't want to send a notification to its author 79 | */ 80 | if (tweet.user.get()?.id !== this.user.id) { 81 | notification = await Notification.firstOrCreate({ 82 | type: 'like', 83 | user: tweet.user.get(), 84 | readAt: undefined, 85 | tweet 86 | }) 87 | } 88 | 89 | const like = await Like.firstOrNew({tweet, user: this.user}) 90 | if (like.exists) { 91 | await like.delete() 92 | await notification?.notifiers.remove(this.user.id) 93 | } else { 94 | await like.save() 95 | await notification?.notifiers.add(this.user.id) 96 | } 97 | 98 | await tweet.loadCount('likes', 'replies', 'retweets') 99 | await tweet.load('media', 'likes', 'user', 'retweetParent.user', 'replyParent.user') 100 | 101 | return TweetModel.from(tweet) 102 | } 103 | 104 | private async createTweet(form: TweetForm, additional: Partial> = {}) { 105 | const tweet = Tweet.new({...form, ...additional}) 106 | await this.user.tweets.save(tweet) 107 | 108 | if (form.media instanceof File) { 109 | form.media = [form.media] 110 | } 111 | 112 | const mediaFiles = await Promise.all( 113 | form.media.map(file => this.storage.save(file, 'public/tweets-media')) 114 | ) 115 | await tweet.media.saveMany(...mediaFiles.map(media => Media.new({path: media}))) 116 | 117 | await this.addHashTags(tweet) 118 | await this.sendMentionNotifications(tweet) 119 | 120 | await tweet.load('user') 121 | return tweet 122 | } 123 | 124 | private async addTweetNotification(tweet: Tweet, parentTweet: Tweet, type: 'reply' | 'retweet') { 125 | const parentTweetUser = parentTweet.user.get() 126 | /** 127 | * We need to create a 'reply' notification if the user that replied the tweet is not its author. 128 | */ 129 | if (parentTweetUser && parentTweetUser?.id !== this.user.id) { 130 | await this.addNotification(tweet, parentTweetUser.id, type) 131 | } 132 | } 133 | 134 | private async addHashTags(tweet: Tweet) { 135 | const hashtagsList = tweet.content.matchAll(/\B#(\w\w+)\b/gm) 136 | const hashtagsNames = Array.from(hashtagsList).map(hashtag => hashtag[1]) 137 | const hashtags = await Hashtag.whereIn('name', hashtagsNames).get() 138 | 139 | await tweet.hashtags.sync(...hashtags.pluck('id')) 140 | } 141 | 142 | private async sendMentionNotifications(tweet: Tweet) { 143 | const mentionsList = tweet.content.matchAll(/\B@(\w\w+)\b/gm) 144 | const usernames = Array.from(mentionsList).map(mention => mention[1]) 145 | const users = await User.whereIn('username', usernames).get() 146 | for (const user of users) { 147 | await this.addNotification(tweet, user.id, 'mention') 148 | } 149 | } 150 | 151 | private async addNotification(tweet: Tweet, userId: ID, type: Notification['type']) { 152 | const notification = await Notification.create({ 153 | user: userId, 154 | type, 155 | tweet 156 | }) 157 | await notification.notifiers.add(this.user.id) 158 | } 159 | 160 | @Delete(':Tweet') 161 | async delete(tweet: Tweet) { 162 | if (this.user.id !== tweet.user.get()?.id) { 163 | throw new HttpError('You are not the author of this tweet', Http.Status.UNAUTHORIZED) 164 | } 165 | await tweet.delete() 166 | return TweetModel.from(tweet) 167 | } 168 | } 169 | --------------------------------------------------------------------------------