diff --git a/package.json b/package.json index 339a638..35de632 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dependencies": { "angular2": "2.0.0-beta.7", "angular2-universal-preview": "0.55.4", - "angularfire2": "2.0.0-alpha.9", + "angularfire2": "2.0.0-alpha.13", "css": "2.2.1", "es6-promise": "3.1.2", "es6-shim": "0.33.13", @@ -47,6 +47,6 @@ "ts-node": "0.5.4", "typescript": "1.7.5", "typescript-node": "0.1.3", - "typings": "^0.6.8" + "typings": "^0.6.9" } } diff --git a/src/css/core/_header.scss b/src/css/core/_header.scss index 9975a06..2fd01e3 100755 --- a/src/css/core/_header.scss +++ b/src/css/core/_header.scss @@ -58,4 +58,8 @@ margin: 0; color: #FFF; text-decoration: none; + padding-right: 20px; + &:last-child { + padding-right: 0; + } } diff --git a/src/index.ng2.html b/src/index.ng2.html index f548b20..f663a97 100644 --- a/src/index.ng2.html +++ b/src/index.ng2.html @@ -17,6 +17,8 @@ + + diff --git a/src/main-server.ts b/src/main-server.ts index 45338fa..9024049 100644 --- a/src/main-server.ts +++ b/src/main-server.ts @@ -7,6 +7,7 @@ import { ng2engine, REQUEST_URL } from 'angular2-universal-preview/dist/server'; +import {FIREBASE_PROVIDERS} from 'angularfire2'; import {provide} from 'angular2/core'; import {APP_BASE_HREF, ROUTER_PROVIDERS} from 'angular2/router'; @@ -32,6 +33,7 @@ app.use('/', (req, res) => { res.render('index', { App, providers: [ ROUTER_PROVIDERS, SERVER_LOCATION_PROVIDERS, + FIREBASE_PROVIDERS, provide(REQUEST_URL, {useValue: req.originalUrl}), provide(APP_BASE_HREF, {useValue: `http://localhost:3000${req.baseUrl}`}), provide(REQUEST_URL, {useValue: 'http://localhost:3000'}), diff --git a/src/shared-providers.ts b/src/shared-providers.ts index ef52dda..e0e4ab3 100644 --- a/src/shared-providers.ts +++ b/src/shared-providers.ts @@ -1,14 +1,24 @@ import {provide} from 'angular2/core'; -import {FIREBASE_PROVIDERS, defaultFirebase} from 'angularfire2'; +import { + defaultFirebase, + firebaseAuthConfig, + AuthMethods, + AuthProviders +} from 'angularfire2'; import {AuthService} from './worker/services/Auth'; import {Backend, BackendConfig} from './worker/services/Backend'; import {QuestionService} from './worker/services/QuestionService'; - +import {AnswerService} from './worker/services/AnswerService'; export const SHARED_PROVIDERS = [ AuthService, QuestionService, - FIREBASE_PROVIDERS, - defaultFirebase('answers-mobile.firebaseio.com') + AnswerService, + defaultFirebase('answers-mobile.firebaseio.com'), + firebaseAuthConfig({ + method: AuthMethods.Redirect, + provider: AuthProviders.Github + }) ]; + diff --git a/src/shared/ckeditor_renderer.ts b/src/shared/ckeditor_renderer.ts new file mode 100644 index 0000000..5b15074 --- /dev/null +++ b/src/shared/ckeditor_renderer.ts @@ -0,0 +1,37 @@ +import {RenderService} from './render_service'; +import {Injectable, ElementRef, Injector} from 'angular2/core'; + +declare var CKEDITOR: any; + +@RenderService({ + methods: [{ + name: 'init', + args: [ElementRef], + returnType: boolean + }, { + name: 'destroy', + args: [ElementRef] + }] +}) +@Injectable() +export class CKEditorRenderer { + private _editors = new Map(); + // NB: For now all RenderServices MUST have a public injector member + // This is used by the RenderService annotation to inject things like the the MessageBroker + constructor (public injector: Injector) {} + init (elem): Promise { + let editor = CKEDITOR.replace(elem); + editor.on("change", (e) => { + elem.value = e.editor.getData(); + let event = new Event('change'); + elem.dispatchEvent(event); + }); + this._editors.set(elem, editor); + } + + destroy(elem) { + let editor = this._editors.get(elem); + editor.destroy(); + this._editors.delete(elem); + } +} diff --git a/src/shared/render_service.ts b/src/shared/render_service.ts new file mode 100644 index 0000000..a3a1ab0 --- /dev/null +++ b/src/shared/render_service.ts @@ -0,0 +1,100 @@ +import { + FnArg, + ClientMessageBroker, + ClientMessageBrokerFactory, + ServiceMessageBrokerFactory, + UiArguments, + PRIMITIVE +} from 'angular2/platform/worker_app'; +import {RenderStoreObject} from 'angular2/src/web_workers/shared/serializer'; +import {ElementRef, ElementRef_} from 'angular2/src/core/linker/element_ref'; + +declare var WorkerGlobalScope; + +let num = 1; +let obj = {}; +let serializationMap = {}; +serializationMap[ElementRef.toString()] = RenderStoreObject; +serializationMap[obj.constructor.toString()] = PRIMITIVE; +serializationMap[[].constructor.toString()] = PRIMITIVE; +serializationMap[false.constructor.toString()] = PRIMITIVE; +serializationMap["".constructor.toString()] = PRIMITIVE; +serializationMap[num.constructor.toString()] = PRIMITIVE; + +export interface MethodMetadata { + name: string, + args: [Function], + returnType?: Function +} + +export interface RenderServiceMetadata { + methods: [MethodMetadata] +} + +export function RenderService (metadata: RenderServiceMetadata) { + return function(target) { + let channelName = target.toString() + if (isWorker()) { + let state = { + channelName: channelName, + broker: null + }; + var nativeMethods = {}; + for (var i = 0; i < metadata.methods.length; i++) { + let method = metadata.methods[i]; + patchMethod(target, method, state); + } + } else { + target.prototype.ngStartListening = function() { + let brokerFactory = this.injector.get(ServiceMessageBrokerFactory); + let broker = brokerFactory.createMessageBroker(channelName, true); + for (var i = 0; i < metadata.methods.length; i++) { + let methodInfo = metadata.methods[i]; + let methodName = methodInfo.name; + let args = metadata.methods[i].args.map((v) => + serializationMap[v.toString()] ? serializationMap[v.toString()] : v); + broker.registerMethod(methodName, args, + target.prototype[methodName].bind(this), methodInfo.returnType); + } + } + } + } +} + +function patchMethod(target: any, methodInfo: MethodMetadata, state: ServiceState) { + target.prototype[methodInfo.name] = function() { + if (state.broker == null) { + let brokerFactory: ClientMessageBrokerFactory = + this.injector.get(ClientMessageBrokerFactory); + state.broker = brokerFactory.createMessageBroker(state.channelName, true); + } + let broker = state.broker; + let args = []; + for (var i = 0; i < arguments.length; i++) { + let val = arguments[i]; + if (val) { + let type = methodInfo.args[i]; + if (type == ElementRef) { + val = val.nativeElement; + } + if (serializationMap[type.toString()]) { + type = serializationMap[type.toString()]; + } + args.push(new FnArg(val, type)); + } else { + // val may be null, undefined, etc... + args.push(val, PRIMITIVE); + } + } + broker.runOnService(new UiArguments(methodInfo.name, args), methodInfo.returnType); + } +} + +function isWorker() { + return typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; +} + +interface ServiceState { + channelName: string; + broker: ClientMessageBroker +} diff --git a/src/ui/main_ui.ts b/src/ui/main_ui.ts index ac56238..e3ef5f6 100644 --- a/src/ui/main_ui.ts +++ b/src/ui/main_ui.ts @@ -7,11 +7,22 @@ import { } from 'angular2/platform/worker_render'; import {platform, provide} from 'angular2/core'; import {BOOTSTRAP_CHANNEL} from '../shared/channels'; +import {CKEditorRenderer} from '../shared/ckeditor_renderer'; +import { + defaultFirebase, +} from 'angularfire2'; +import {WORKER_RENDER_FIREBASE_PROVIDERS} from 'angularfire2/angularfire2_worker_render'; +// Need to import from providers directly +// until https://github.com/angular/angularfire2/pull/111 is merged +import {MessageBasedFirebaseAuth} from 'angularfire2/providers/web_workers/ui/auth'; let appRef = platform([WORKER_RENDER_PLATFORM]) .application([ + WORKER_RENDER_FIREBASE_PROVIDERS, + defaultFirebase('answers-mobile.firebaseio.com'), WORKER_RENDER_APPLICATION, WORKER_RENDER_ROUTER, + CKEditorRenderer, provide(WORKER_SCRIPT, {useValue: '/loader.js'}) ]); @@ -22,3 +33,7 @@ bus.from(BOOTSTRAP_CHANNEL).subscribe((message: string) => { ( window).preboot.complete(); } }); + +// Need to manually call start until https://github.com/angular/angular/issues/7420 is implemented +appRef.injector.get(MessageBasedFirebaseAuth).start(); +appRef.injector.get(CKEditorRenderer).ngStartListening(); diff --git a/src/worker/components/AnswerList.ts b/src/worker/components/AnswerList.ts new file mode 100644 index 0000000..5c3083c --- /dev/null +++ b/src/worker/components/AnswerList.ts @@ -0,0 +1,32 @@ +import {Component, Input} from 'angular2/core'; +import {ROUTER_DIRECTIVES} from 'angular2/router'; +import {Answer} from '../services/AnswerService'; + +const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', + 'September', 'October', 'November', 'December']; + +@Component({ + selector: 'answer-list', + template: ` +
+

{{ answer.text }}

+

Answered by {{ answer.username }} on {{ timestampToDate (answer.timestamp) }}.

+
+ `, + directives: [ROUTER_DIRECTIVES], + styles: [` + .username { + font-size: 12pt; + text-align: right; + } + `] +}) +export class AnswerList { + @Input() answers: Answer[]; + + timestampToDate (timestamp: number): string { + let date = new Date(timestamp); + let month = months[date.getMonth()]; + return `${month} ${date.getDate()}`; + } +} diff --git a/src/worker/components/CreateAnswer.ts b/src/worker/components/CreateAnswer.ts new file mode 100644 index 0000000..6807530 --- /dev/null +++ b/src/worker/components/CreateAnswer.ts @@ -0,0 +1,52 @@ +import {Component, Input, ChangeDetectionStrategy} from 'angular2/core'; +import {AnswerService, Answer} from '../services/AnswerService'; +import {FirebaseAuth} from 'angularfire2'; + +import * as Firebase from 'firebase'; + +@Component({ + selector: 'create-answer', + template: ` +
+ +

You must login to answer this question.

+
+ +

Add Answer

+
+ +
+ +
+
+ `, + styles: [ + `.new-answer-container { + display: flex; + flex-direction: column + }` + ] +}) +export class CreateAnswer { + @Input() questionId: string; + newAnswer: Answer = { + timestamp: Firebase.ServerValue.TIMESTAMP, + text: '', + uid: '', + username: '' + }; + + constructor(private _answerService: AnswerService, public auth: FirebaseAuth){ + this.auth.subscribe ((authData) => { + if (authData != null) { + this.newAnswer.uid = authData.uid; + this.newAnswer.username = authData.github.username; + } + }); + } + + addAnswer(){ + this._answerService.addAnswer(this.questionId, this.newAnswer); + this.newAnswer.text = ''; + } +} diff --git a/src/worker/components/header.ts b/src/worker/components/header.ts index 15318c7..1009d16 100644 --- a/src/worker/components/header.ts +++ b/src/worker/components/header.ts @@ -1,6 +1,7 @@ -import {Component} from 'angular2/core'; +import {Component, ChangeDetectionStrategy} from 'angular2/core'; import {Nav} from '../services/Nav'; import {ROUTER_DIRECTIVES} from 'angular2/router'; +import {FirebaseAuth} from 'angularfire2'; @Component({ selector: 'app-header', @@ -8,15 +9,30 @@ import {ROUTER_DIRECTIVES} from 'angular2/router'; toggle_nav Angular Answers + Login + Logout New Question `, + changeDetection: ChangeDetectionStrategy.OnPush, directives: [ROUTER_DIRECTIVES] }) export class AppHeader { - constructor(private nav: Nav){ - + constructor(private nav: Nav, public auth: FirebaseAuth){ + this.auth.subscribe((data) => { + console.log('auth data', data); + }); } openSideNav(){ this.nav.open() } + + login() { + this.auth.login() + .then(() => console.log("Success!"), + (err) => console.error(err)); + } + + logout() { + this.auth.logout(); + } } diff --git a/src/worker/containers/createQuestion.ts b/src/worker/containers/createQuestion.ts index 39d96de..60c05ab 100644 --- a/src/worker/containers/createQuestion.ts +++ b/src/worker/containers/createQuestion.ts @@ -1,34 +1,46 @@ import {Component} from 'angular2/core'; import {QuestionService} from '../services/QuestionService'; import {Router, RouteConfig} from 'angular2/router' +import {FirebaseAuth} from 'angularfire2'; +import {CKEditorDirective} from '../directives/ckeditor_directive'; @Component({ selector: 'create-question-container', template: ` -
-

Ask a new question

-
+
+ +

Please login to continue.

+
+ +

Ask a new question

+
+ + + - - - -
- +
+ +
- `, styles: [ `.new-question-container { display: flex; flex-direction: column }` - ] + ], + directives: [CKEditorDirective] }) export class CreateQuestionContainer { - newQuestion = {}; - constructor(private questionService:QuestionService, private router:Router){} - addQuestion(){ + newQuestion: any = {}; + constructor(private questionService:QuestionService, private router:Router, + public auth: FirebaseAuth) {} + addQuestion() { this.questionService.addQuestion(this.newQuestion); this.router.navigate(['../Questions']); } + + questionTextChanged(val: string) { + this.newQuestion.text = val; + } } diff --git a/src/worker/containers/questionDetail.ts b/src/worker/containers/questionDetail.ts index c8237a9..d70bfd3 100644 --- a/src/worker/containers/questionDetail.ts +++ b/src/worker/containers/questionDetail.ts @@ -1,6 +1,9 @@ import {Component, ChangeDetectionStrategy} from 'angular2/core'; import {QuestionService} from '../services/QuestionService'; import {RouteParams} from 'angular2/router'; +import {AnswerList} from '../components/AnswerList'; +import {CreateAnswer} from '../components/CreateAnswer'; +import {AnswerService} from '../services/AnswerService'; @Component({ selector: 'question-detail-container', @@ -9,12 +12,21 @@ import {RouteParams} from 'angular2/router';

{{ (question | async)?.title }}

{{ (question | async)?.text }}

+ + `, - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + directives: [AnswerList, CreateAnswer] }) export class QuestionDetailContainer { question: any; - constructor(private questionService:QuestionService, params:RouteParams){ - this.question = questionService.getQuestionById(params.get('id')); + id: string; + answers: any; + + constructor(private _questionService:QuestionService, private _answerService: AnswerService, + params:RouteParams){ + this.id = params.get('id'); + this.question = this._questionService.getQuestionById(this.id); + this.answers = this._answerService.getAnswersByQuestionId(this.id); } } diff --git a/src/worker/containers/questions.ts b/src/worker/containers/questions.ts index 7a0da67..d9412e1 100644 --- a/src/worker/containers/questions.ts +++ b/src/worker/containers/questions.ts @@ -1,13 +1,15 @@ import {Component, ChangeDetectionStrategy} from 'angular2/core'; import {Nav} from '../services/Nav'; import {QuestionService} from '../services/QuestionService'; -import {QuestionList} from '../components/QuestionList' +import {QuestionList} from '../components/QuestionList'; +import {FirebaseAuth} from 'angularfire2'; @Component({ selector: 'home-container', template: `
-

Recent Questions

+

Recent Questions

+

Hi {{(auth | async).github.displayName}}, here are some recent questions

`, @@ -15,5 +17,5 @@ import {QuestionList} from '../components/QuestionList' changeDetection: ChangeDetectionStrategy.OnPush }) export class QuestionsContainer { - constructor(private questionService:QuestionService){} + constructor(private questionService:QuestionService, public auth: FirebaseAuth){} } diff --git a/src/worker/directives/ckeditor_directive.ts b/src/worker/directives/ckeditor_directive.ts new file mode 100644 index 0000000..6ee7a1b --- /dev/null +++ b/src/worker/directives/ckeditor_directive.ts @@ -0,0 +1,21 @@ +import {Directive, OnInit, OnDestroy, ElementRef} from 'angular2/core'; +import {CKEditorRenderer} from '../../shared/ckeditor_renderer'; + +@Directive ({ + selector: '[ckeditor]', + providers: [CKEditorRenderer] +}) +export class CKEditorDirective implements OnInit, OnDestroy { + constructor(private _renderer: CKEditorRenderer, private _elem: ElementRef) {} + + ngOnInit() { + this._renderer.init(this._elem); + } + + ngOnDestroy() { + console.log("destroying", this._elem); + // TODO: There is a race with destroy where the element can be removed from the + // store (I believe only on the render side) before the destroy method has executed there. + //this._renderer.destroy(this._elem); + } +} diff --git a/src/worker/main_worker.ts b/src/worker/main_worker.ts index e698648..7d6de96 100644 --- a/src/worker/main_worker.ts +++ b/src/worker/main_worker.ts @@ -8,6 +8,7 @@ import {platform, provide, ComponentRef, Injector} from 'angular2/core'; import {APP_BASE_HREF} from 'angular2/router'; import {BOOTSTRAP_CHANNEL} from '../shared/channels'; import {SHARED_PROVIDERS} from '../shared-providers'; +import {WORKER_APP_FIREBASE_PROVIDERS} from 'angularfire2/angularfire2_worker_app'; import {App} from './app/app'; @@ -15,6 +16,7 @@ platform([WORKER_APP_PLATFORM]) .asyncApplication(null, [ WORKER_APP_ROUTER, WORKER_APP_APPLICATION, + WORKER_APP_FIREBASE_PROVIDERS, provide(APP_BASE_HREF, {useValue: '/'}), SHARED_PROVIDERS ]).then((appRef) => appRef.bootstrap(App).then((compRef: ComponentRef) => { diff --git a/src/worker/services/AnswerService.ts b/src/worker/services/AnswerService.ts new file mode 100644 index 0000000..9b17259 --- /dev/null +++ b/src/worker/services/AnswerService.ts @@ -0,0 +1,37 @@ +import {AngularFire, FirebaseListObservable} from 'angularfire2'; +import {Injectable} from 'angular2/core'; +const ANSWERS_PATH = '/answers'; + +@Injectable() +export class AnswerService { + private _answersList: {[key: string]: FirebaseListObservable} = {}; + + constructor (private _angularFire: AngularFire) {} + + getAnswersByQuestionId(id:string) { + return this._lookupByQuestionId (id) + .map(l => l.map(v => v.val())); + } + + addAnswer(questionId: string, newAnswer: any) { + this._lookupByQuestionId(questionId).add(newAnswer); + } + + private _lookupByQuestionId(id: string): FirebaseListObservable { + let observable: FirebaseListObservable = null; + if (this._answersList[id]) + observable = this._answersList[id]; + else { + observable = this._angularFire.list(ANSWERS_PATH + `/${id}`, {preserveSnapshot: true}); + this._answersList[id] = observable; + } + return observable; + } +} + +export interface Answer { + text: string; + uid: string; + timestamp: number; + username: string; +}