├── .bowerrc
├── .gitignore
├── LICENSE
├── README.md
├── bower.json
├── config.xml
├── gulpfile.js
├── hooks
├── README.md
└── after_prepare
│ └── 010_add_platform_class.js
├── ionic.project
├── package.json
├── release-android.sh
└── www
├── app
├── app.coffee
├── commom
│ ├── directives
│ │ ├── elasticFooter.directive.coffee
│ │ ├── externalLink.directive.coffee
│ │ └── focusOn.directive.coffee
│ ├── filters
│ │ ├── fixLink.filter.coffee
│ │ ├── prefixUrl.filter.coffee
│ │ ├── tabLabel.filter.coffee
│ │ └── toMarkdown.filter.coffee
│ └── services
│ │ ├── auth.service.coffee
│ │ ├── focus.service.coffee
│ │ ├── message.service.coffee
│ │ ├── settings.service.coffee
│ │ ├── storage.service.coffee
│ │ ├── toast.service.coffee
│ │ ├── topic.service.coffee
│ │ └── user.service.coffee
├── configs
│ ├── constant.coffee
│ ├── rest.config.coffee
│ └── router.config.coffee
├── main
│ ├── login-modal.html
│ ├── main.controller.coffee
│ └── main.html
├── messages
│ ├── messageTile.directive.coffee
│ ├── messageTile.html
│ ├── messages.controller.coffee
│ └── messages.html
├── replies
│ ├── replies.controller.coffee
│ ├── replies.html
│ ├── reply-preview-modal.html
│ ├── replyTile.directive.coffee
│ └── replyTile.html
├── scss
│ ├── _action-sheet.scss
│ ├── _button.scss
│ ├── _footer.scss
│ ├── _icon.scss
│ ├── _main.scss
│ ├── _popover.scss
│ └── app.bundle.scss
├── settings
│ ├── settings.controller.coffee
│ └── settings.html
├── topic
│ ├── topic.controller.coffee
│ └── topic.html
├── topics
│ ├── more-popover.html
│ ├── new-topic-modal.html
│ ├── topics.controller.coffee
│ ├── topics.html
│ ├── topicsList.directive.coffee
│ └── topicsList.html
└── user
│ ├── user.controller.coffee
│ └── user.html
├── img
└── ionic.png
└── index.html
/.bowerrc:
--------------------------------------------------------------------------------
1 | {
2 | "directory": "www/lib"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | plugins/
3 | platforms/
4 | www/lib
5 | www/**/*.js
6 | www/**/*.css
7 | npm-debug.log
8 | resources/
9 | *.keystore
10 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 ryan
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Ionic Nodeclub App
2 |
3 | 一个基于 [Ionic框架](http://ionicframework.com/) 的 hybrid HTML5 app, 使用 coffeescript 和 sass 开发
4 |
5 | 数据源/API 使用的是开源社区系统 [Nodeclub](https://github.com/cnodejs/nodeclub/)
6 |
7 | [online demo](http://ouzhenkun.com/ionic-nodeclub) 或者 [下载安装](http://fir.im/ionic)
8 |
9 |
10 | ## Getting started
11 |
12 | ### 在浏览器上运行
13 | - `npm install -g cordova`
14 | - `npm install -g ionic`
15 | - `npm install -g gulp`
16 | - `cd ionic-nodeclub`
17 | - `npm install`
18 | - `bower install`
19 | - `gulp build` : 编译 Javascript 和 css
20 | - `ionic serve` : 启动应用在浏览器运行
21 |
22 |
--------------------------------------------------------------------------------
/bower.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ionic-nodeclub",
3 | "private": true,
4 | "dependencies": {
5 | "ionic": "driftyco/ionic-bower#1.0.0",
6 | "restangular": "~1.5.1",
7 | "angular-moment": "~0.9.2",
8 | "angular-elastic": "~2.4.2",
9 | "to-markdown": "~0.0.3",
10 | "angular-markdown-directive": "~0.3.1",
11 | "ngCordova": "~0.1.14-alpha"
12 | },
13 | "devDependencies": {},
14 | "overrides": {
15 | "angular": {
16 | "ignore": true
17 | },
18 | "angular-animate": {
19 | "ignore": true
20 | },
21 | "angular-sanitize": {
22 | "ignore": true
23 | },
24 | "angular-ui-router": {
25 | "ignore": true
26 | },
27 | "ionic": {
28 | "ignore": true
29 | },
30 | "moment": {
31 | "main": [
32 | "min/moment-with-locales.js"
33 | ]
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Ionic Nodeclub
4 |
5 | 一个Nodeclub开源社区系统的手机客户端
6 |
7 |
8 | Zhenkun Ou
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | var gulp = require('gulp')
2 | var sass = require('gulp-sass')
3 | var coffee = require('gulp-coffee')
4 | var rename = require('gulp-rename')
5 | var concat = require('gulp-concat')
6 | var minifyCss = require('gulp-minify-css')
7 | var bowerFiles = require("main-bower-files")
8 |
9 | var paths = {
10 | sass : ['./www/app/scss/*.scss'],
11 | coffee: ['./www/app/**/*.coffee'],
12 | bower : ['./www/lib/*.js']
13 | }
14 |
15 | var handleError = function(error) {
16 | console.error(error.toString())
17 | this.emit('end')
18 | }
19 |
20 | gulp.task('sass', function(done) {
21 | gulp.src('./www/app/scss/app.bundle.scss')
22 | .pipe(sass().on('error', handleError))
23 | .pipe(rename({ extname: '.css' }))
24 | .pipe(minifyCss({ keepSpecialComments: 0 }))
25 | .pipe(concat('app.css'))
26 | .pipe(gulp.dest('./www/css'))
27 | .on('end', done)
28 | })
29 |
30 | gulp.task('coffee', function(done) {
31 | gulp.src(paths.coffee)
32 | .pipe(coffee({ bare: true }).on('error', handleError))
33 | .pipe(concat('app.js'))
34 | .pipe(gulp.dest('./www/js'))
35 | .on('end', done)
36 | })
37 |
38 | gulp.task('bower', function(done) {
39 | console.log(bowerFiles({ filter: /.js$/ }))
40 | gulp.src(bowerFiles({ filter: /.js$/ }))
41 | .pipe(concat('vender.js'))
42 | .pipe(gulp.dest('./www/js'))
43 | .on('end', done)
44 | })
45 |
46 | gulp.task('watch', function() {
47 | gulp.watch(paths.sass, ['sass'])
48 | gulp.watch(paths.coffee, ['coffee'])
49 | gulp.watch(paths.bower, ['vender'])
50 | })
51 |
52 | gulp.task('build', ['sass', 'coffee', 'bower'])
53 |
54 |
--------------------------------------------------------------------------------
/hooks/README.md:
--------------------------------------------------------------------------------
1 |
21 | # Cordova Hooks
22 |
23 | This directory may contain scripts used to customize cordova commands. This
24 | directory used to exist at `.cordova/hooks`, but has now been moved to the
25 | project root. Any scripts you add to these directories will be executed before
26 | and after the commands corresponding to the directory name. Useful for
27 | integrating your own build systems or integrating with version control systems.
28 |
29 | __Remember__: Make your scripts executable.
30 |
31 | ## Hook Directories
32 | The following subdirectories will be used for hooks:
33 |
34 | after_build/
35 | after_compile/
36 | after_docs/
37 | after_emulate/
38 | after_platform_add/
39 | after_platform_rm/
40 | after_platform_ls/
41 | after_plugin_add/
42 | after_plugin_ls/
43 | after_plugin_rm/
44 | after_plugin_search/
45 | after_prepare/
46 | after_run/
47 | after_serve/
48 | before_build/
49 | before_compile/
50 | before_docs/
51 | before_emulate/
52 | before_platform_add/
53 | before_platform_rm/
54 | before_platform_ls/
55 | before_plugin_add/
56 | before_plugin_ls/
57 | before_plugin_rm/
58 | before_plugin_search/
59 | before_prepare/
60 | before_run/
61 | before_serve/
62 | pre_package/ <-- Windows 8 and Windows Phone only.
63 |
64 | ## Script Interface
65 |
66 | All scripts are run from the project's root directory and have the root directory passes as the first argument. All other options are passed to the script using environment variables:
67 |
68 | * CORDOVA_VERSION - The version of the Cordova-CLI.
69 | * CORDOVA_PLATFORMS - Comma separated list of platforms that the command applies to (e.g.: android, ios).
70 | * CORDOVA_PLUGINS - Comma separated list of plugin IDs that the command applies to (e.g.: org.apache.cordova.file, org.apache.cordova.file-transfer)
71 | * CORDOVA_HOOK - Path to the hook that is being executed.
72 | * CORDOVA_CMDLINE - The exact command-line arguments passed to cordova (e.g.: cordova run ios --emulate)
73 |
74 | If a script returns a non-zero exit code, then the parent cordova command will be aborted.
75 |
76 |
77 | ## Writing hooks
78 |
79 | We highly recommend writting your hooks using Node.js so that they are
80 | cross-platform. Some good examples are shown here:
81 |
82 | [http://devgirl.org/2013/11/12/three-hooks-your-cordovaphonegap-project-needs/](http://devgirl.org/2013/11/12/three-hooks-your-cordovaphonegap-project-needs/)
83 |
84 |
--------------------------------------------------------------------------------
/hooks/after_prepare/010_add_platform_class.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | // Add Platform Class
4 | // v1.0
5 | // Automatically adds the platform class to the body tag
6 | // after the `prepare` command. By placing the platform CSS classes
7 | // directly in the HTML built for the platform, it speeds up
8 | // rendering the correct layout/style for the specific platform
9 | // instead of waiting for the JS to figure out the correct classes.
10 |
11 | var fs = require('fs');
12 | var path = require('path');
13 |
14 | var rootdir = process.argv[2];
15 |
16 | function addPlatformBodyTag(indexPath, platform) {
17 | // add the platform class to the body tag
18 | try {
19 | var platformClass = 'platform-' + platform;
20 | var cordovaClass = 'platform-cordova platform-webview';
21 |
22 | var html = fs.readFileSync(indexPath, 'utf8');
23 |
24 | var bodyTag = findBodyTag(html);
25 | if(!bodyTag) return; // no opening body tag, something's wrong
26 |
27 | if(bodyTag.indexOf(platformClass) > -1) return; // already added
28 |
29 | var newBodyTag = bodyTag;
30 |
31 | var classAttr = findClassAttr(bodyTag);
32 | if(classAttr) {
33 | // body tag has existing class attribute, add the classname
34 | var endingQuote = classAttr.substring(classAttr.length-1);
35 | var newClassAttr = classAttr.substring(0, classAttr.length-1);
36 | newClassAttr += ' ' + platformClass + ' ' + cordovaClass + endingQuote;
37 | newBodyTag = bodyTag.replace(classAttr, newClassAttr);
38 |
39 | } else {
40 | // add class attribute to the body tag
41 | newBodyTag = bodyTag.replace('>', ' class="' + platformClass + ' ' + cordovaClass + '">');
42 | }
43 |
44 | html = html.replace(bodyTag, newBodyTag);
45 |
46 | fs.writeFileSync(indexPath, html, 'utf8');
47 |
48 | process.stdout.write('add to body class: ' + platformClass + '\n');
49 | } catch(e) {
50 | process.stdout.write(e);
51 | }
52 | }
53 |
54 | function findBodyTag(html) {
55 | // get the body tag
56 | try{
57 | return html.match(/
])(.*?)>/gi)[0];
58 | }catch(e){}
59 | }
60 |
61 | function findClassAttr(bodyTag) {
62 | // get the body tag's class attribute
63 | try{
64 | return bodyTag.match(/ class=["|'](.*?)["|']/gi)[0];
65 | }catch(e){}
66 | }
67 |
68 | if (rootdir) {
69 |
70 | // go through each of the platform directories that have been prepared
71 | var platforms = (process.env.CORDOVA_PLATFORMS ? process.env.CORDOVA_PLATFORMS.split(',') : []);
72 |
73 | for(var x=0; x
26 |
27 | amMoment.changeLocale('zh-cn')
28 |
29 | $ionicPlatform.ready ->
30 | # Hide the accessory bar by default (remove this to show the accessory bar above the keyboard
31 | # for form inputs)
32 | if window.cordova
33 | $cordovaKeyboard.hideAccessoryBar(true)
34 |
35 | if window.StatusBar
36 | $cordovaStatusBar.style(0)
37 |
38 | #
39 | # 我要实现按两次返回按钮才能退出APP
40 | #
41 | isExitApp = false
42 | $ionicPlatform.registerBackButtonAction ->
43 | # 我要先返回上一个页面,如果有的话
44 | if backView = $ionicHistory.backView()
45 | return backView.go()
46 |
47 | if isExitApp
48 | return ionic.Platform.exitApp()
49 |
50 | # 我提示用户再按一次返回按钮会退出APP
51 | toast '再按一次退出'
52 |
53 | # 我标记这段时间,用户正准备退出APP
54 | isExitApp = true
55 | $timeout ->
56 | isExitApp = false
57 | , config.TOAST_SHORT_DELAY
58 |
59 | , IONIC_BACK_PRIORITY.view+1
60 |
61 | # 我在修复浏览器刷新/后退导致'nav button'显示异常的BUG
62 | $rootScope.$on '$stateChangeStart', (event, toState) ->
63 | # 通过设置historyRoot可以让'nav button'的状态重置
64 | if toState.historyRoot
65 | $ionicHistory.nextViewOptions
66 | historyRoot: true
67 |
68 | #
69 | # 我在初始化rootScope的变量
70 | #
71 | $rootScope.me = null
72 |
73 | $rootScope.$on 'auth.userUpdated', (event, user) ->
74 | $rootScope.me = user
75 | messageService.refreshUnreadCount()
76 |
77 | $rootScope.$on 'auth.userLogout', ->
78 | $rootScope.me = null
79 | userService.reset()
80 | topicService.reset()
81 | messageService.reset()
82 |
83 | authService.init()
84 |
85 |
--------------------------------------------------------------------------------
/www/app/commom/directives/elasticFooter.directive.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .directive 'elasticFooter', ($document, $timeout) ->
4 | restrict: 'E'
5 | controller: '$ionicHeaderBar'
6 | compile: ($element, $attrs) ->
7 | $element.addClass 'bar bar-footer elastic-footer'
8 |
9 | pre: ($scope, $element, $attrs) ->
10 |
11 | $scope.$watch (-> $element[0].className) , (value) ->
12 | isShown = value.indexOf('ng-hide') is -1
13 | isSubfooter = value.indexOf('bar-subfooter') isnt -1
14 | $scope.$hasFooter = isShown and !isSubfooter
15 | $scope.$hasSubfooter = isShown and isSubfooter
16 |
17 | $scope.$on '$destroy', ->
18 | delete $scope.$hasFooter
19 | delete $scope.$hasSubfooter
20 |
21 | $scope.$watch '$hasTabs', (val) ->
22 | $element.toggleClass('has-tabs', !!val)
23 |
24 | # update content view bottom position
25 | $scope.$watch (-> $element[0].clientHeight), (newHeight) -> $timeout ->
26 | contentQuery = $document[0].getElementsByClassName('has-footer')
27 | contentElement = angular.element(contentQuery)
28 | contentElement?.css('bottom', newHeight + 'px')
29 |
30 |
--------------------------------------------------------------------------------
/www/app/commom/directives/externalLink.directive.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .directive 'externalLink', ->
4 |
5 | (scope, element, attrs) ->
6 | element.on 'click', (event) ->
7 | target = attrs.target ? '_system'
8 | url = attrs.externalLink
9 | window.open(url, target)
10 |
11 |
--------------------------------------------------------------------------------
/www/app/commom/directives/focusOn.directive.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .directive 'focusOn', ($timeout) ->
4 |
5 | (scope, element, attrs) ->
6 | scope.$on 'focusOn', (event, name) ->
7 | $timeout ->
8 | if name is attrs.focusOn
9 | element[0].focus()
10 |
--------------------------------------------------------------------------------
/www/app/commom/filters/fixLink.filter.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | #
4 | # 基于 github.com/lanceli/cnodejs-ionic 的 'link' filter 做了修改
5 | # 具体参考这个 commit:
6 | # https://github.com/lanceli/cnodejs-ionic/commit/06e703e739dbe9e026bb17b14e27716034d4aba0
7 | #
8 | .filter 'fixLink', ($sce) ->
9 | (content) ->
10 | if _.isString(content)
11 | userLinkRegex = /href="\/user\/([\S]+)"/gi
12 | noProtocolSrcRegex = /src="\/\/([\S]+)"/gi
13 | externalLinkRegex = /href="((?!#\/app\/)[\S]+)"/gi
14 | $sce.trustAsHtml(
15 | content
16 | .replace(userLinkRegex, 'href="#/app/user/$1"')
17 | .replace(noProtocolSrcRegex, 'src="https://$1"')
18 | .replace(externalLinkRegex, "href onClick=\"window.open('$1', '_system')\"")
19 | )
20 | else
21 | content
22 |
23 |
--------------------------------------------------------------------------------
/www/app/commom/filters/prefixUrl.filter.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .filter 'prefixUrl', (API) ->
4 |
5 | (input) ->
6 | if /^http/gi.test(input)
7 | return input
8 | if /^\/\//gi.test(input)
9 | return 'https:' + input
10 | API.server + input
11 |
--------------------------------------------------------------------------------
/www/app/commom/filters/tabLabel.filter.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .filter 'tabLabel', (tabs) ->
4 |
5 | (tabValue) ->
6 | _.find(tabs, value:tabValue)?.label ? '其他'
7 |
--------------------------------------------------------------------------------
/www/app/commom/filters/toMarkdown.filter.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .filter 'toMarkdown', ->
4 |
5 | (htmlInput) ->
6 | if _.isEmpty(htmlInput) then return ''
7 | toMarkdown(htmlInput).replace(/<([^>]+)>/ig, '')
8 |
--------------------------------------------------------------------------------
/www/app/commom/services/auth.service.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .factory 'authService', (
4 | $q
5 | API
6 | toast
7 | storage
8 | $window
9 | $timeout
10 | $rootScope
11 | Restangular
12 | $ionicModal
13 | $ionicLoading
14 | $ionicPlatform
15 | IONIC_BACK_PRIORITY
16 | $cordovaBarcodeScanner
17 | ) ->
18 |
19 | # 我创建一个scope以便之后让login-modal调用
20 | mkLoginModalScope = ->
21 | angular.extend $rootScope.$new(),
22 | API: API
23 | loginModal: null
24 | canScan: $window.cordova?
25 |
26 | doLogin: (token) ->
27 | $ionicLoading.show()
28 | Restangular
29 | .all('accessToken')
30 | .post(accesstoken: token)
31 | .then (user) =>
32 | storage.set 'user', angular.extend(user, token: token)
33 | @loginModal?.hide()
34 | $ionicLoading.hide()
35 | $rootScope.$broadcast 'auth.userUpdated', user
36 | toast '登录成功,欢迎您: ' + user.loginname
37 | , (error) ->
38 | $ionicLoading.hide()
39 | toast '登录失败: ' + error?.data?.error_msg
40 |
41 | doScan: ->
42 | # 我在这里覆盖'点返回按钮关闭modal'的逻辑
43 | # 为了修复扫码的时候按了返回按钮把loginModal关掉的BUG
44 | deregisterBackButton = $ionicPlatform
45 | .registerBackButtonAction angular.noop, IONIC_BACK_PRIORITY.modal + 1
46 |
47 | # 开始扫码
48 | $cordovaBarcodeScanner
49 | .scan()
50 | .then (result) =>
51 | if !result.cancelled
52 | @doLogin(result.text)
53 | , (error) ->
54 | toast '扫码错误 ' + error
55 | .finally ->
56 | # 我在去掉上面的‘点返回按钮关闭modal’的覆盖
57 | $timeout deregisterBackButton, 500
58 |
59 | # 创建一个新的scope
60 | scope = mkLoginModalScope()
61 |
62 | # 初始化loginModal
63 | $ionicModal
64 | .fromTemplateUrl('app/main/login-modal.html', scope: scope)
65 | .then (modal) ->
66 | scope.loginModal = modal
67 |
68 | # scope销毁的时候移除loginModal
69 | scope.$on '$destroy', ->
70 | console.log 'remove login modal'
71 | scope.loginModal?.remove()
72 |
73 | #
74 | # service methods
75 | #
76 | return {
77 |
78 | # 我重新检查storage里的token是否合法
79 | init: ->
80 | if initedUser = storage.get('user')
81 | $rootScope.$broadcast 'auth.userUpdated', initedUser
82 |
83 | login: ->
84 | scope.loginModal.show()
85 |
86 | logout: ->
87 | storage.remove 'user'
88 | $rootScope.$broadcast 'auth.userLogout'
89 | toast '您已登出'
90 |
91 | # 检索authUser并执行next
92 | # 如果检索不到,弹出登录框
93 | withAuthUser: (next) ->
94 | user = storage.get 'user'
95 | if user?.token
96 | next(user)
97 | else
98 | toast '请先登录'
99 | $timeout @login, 500
100 | }
101 |
102 |
--------------------------------------------------------------------------------
/www/app/commom/services/focus.service.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .factory 'focus', ($rootScope, $timeout) ->
4 |
5 | (name) ->
6 | broadcastFocusEvent = ->
7 | $rootScope.$broadcast('focusOn', name) if !_.isEmpty(name)
8 | $timeout broadcastFocusEvent
9 |
--------------------------------------------------------------------------------
/www/app/commom/services/message.service.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .factory 'messageService', (
4 | $q
5 | storage
6 | Restangular
7 | ) ->
8 |
9 | cache = {}
10 |
11 | reset: ->
12 | cache = {}
13 |
14 | getUnreadCount: ->
15 | cache.unreadCount ? 0
16 |
17 | refreshUnreadCount: ->
18 | $q (resolve, reject) ->
19 | user = storage.get 'user'
20 | Restangular
21 | .one('message/count')
22 | .get(accesstoken: user?.token)
23 | .then (resp) ->
24 | cache.unreadCount = resp.data
25 | resolve resp.data
26 | .catch reject
27 |
28 | getMessages: (reload = false) ->
29 | $q (resolve, reject) ->
30 | user = storage.get 'user'
31 | if !reload and cache.messages
32 | return resolve cache.messages
33 | Restangular
34 | .one('messages')
35 | .get(accesstoken: user?.token)
36 | .then (resp) ->
37 | cache.messages = resp.data
38 | cache.unreadCount = resp.data?.hasnot_read_messages?.length ? 0
39 | resolve resp.data
40 | .catch reject
41 |
42 | markAllAsRead: ->
43 | user = storage.get 'user'
44 | Restangular
45 | .all('message/mark_all')
46 | .post(accesstoken: user?.token)
47 |
48 |
--------------------------------------------------------------------------------
/www/app/commom/services/settings.service.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .factory 'settings', ($window, API) ->
4 |
5 | localStorage = $window.localStorage
6 |
7 | if !localStorage.settings
8 | localStorage.settings = JSON.stringify
9 | popRepliesVisible: true
10 | latestRepliesDesc: true
11 |
12 | get: ->
13 | value = localStorage.getItem 'settings'
14 | JSON.parse(value)
15 |
16 | set: (value) ->
17 | value = JSON.stringify value
18 | localStorage.setItem('settings', value)
19 |
--------------------------------------------------------------------------------
/www/app/commom/services/storage.service.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .factory 'storage', ($window, API) ->
4 |
5 | localStorage = $window.localStorage
6 |
7 | genKey = (key) -> API.server + '/' + key
8 |
9 | get: (key) ->
10 | key = genKey key
11 | value = localStorage.getItem key
12 | JSON.parse(value)
13 |
14 | set: (key, value) ->
15 | key = genKey key
16 | value = JSON.stringify value
17 | localStorage.setItem(key, value)
18 |
19 | remove: (key) ->
20 | key = genKey key
21 | localStorage.removeItem key
22 |
23 |
--------------------------------------------------------------------------------
/www/app/commom/services/toast.service.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .factory 'toast', (
4 | config
5 | $window
6 | $timeout
7 | $ionicLoading
8 | $cordovaToast
9 | ) ->
10 |
11 | (message, duration = 'short') ->
12 |
13 | if $window.plugins?.toast?
14 | $cordovaToast.show message, duration, 'center'
15 | else
16 | # 我加$timeout, 避免同时调用$ionicLoading.hide的时候toast也被关掉
17 | if duration is 'long'
18 | duration = config.TOAST_LONG_DELAY
19 | else
20 | duration = config.TOAST_SHORT_DELAY
21 | $timeout ->
22 | $ionicLoading.show
23 | template: message
24 | duration: duration
25 | noBackdrop: true
26 |
--------------------------------------------------------------------------------
/www/app/commom/services/topic.service.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .factory 'topicService', (
4 | $q
5 | config
6 | Restangular
7 | ) ->
8 |
9 | cache = {}
10 |
11 | reset: ->
12 | cache = {}
13 |
14 | getTopics: (tab, from = 0) ->
15 | $q (resolve, reject) ->
16 | page = ~~(from / config.TOPICS_PAGE_LIMIT) + 1
17 | Restangular
18 | .one('topics')
19 | .get(page: page, limit: config.TOPICS_PAGE_LIMIT, tab: tab)
20 | .then (resp) ->
21 | newTopics = resp.data
22 | # 更新cache topics
23 | _.each newTopics, (topic) ->
24 | cache[topic.id] = topic
25 | resolve
26 | topics: newTopics
27 | hasMore: newTopics.length is config.TOPICS_PAGE_LIMIT
28 | .catch resolve
29 |
30 | postNew: (data, authUser) ->
31 | newTopic = angular.extend(accesstoken: authUser?.token, data)
32 | Restangular
33 | .all('topics')
34 | .post(newTopic)
35 |
36 | getDetail: (topicId, reload) ->
37 | $q (resolve, reject) ->
38 | if !reload and topic = cache[topicId]
39 | return resolve topic
40 | Restangular
41 | .one('topic', topicId)
42 | .get()
43 | .then (resp) ->
44 | dbTopic = resp.data
45 | cache[topicId] = dbTopic
46 | resolve dbTopic
47 | .catch reject
48 |
49 | getReplies: (topicId, reload = false) ->
50 | $q (resolve, reject) ->
51 | if !reload and topic = cache[topicId]
52 | return resolve topic if topic?.replies
53 | Restangular
54 | .one('topic', topicId)
55 | .get()
56 | .then (resp) ->
57 | dbTopic = resp.data
58 | cache[topicId] = dbTopic
59 | resolve dbTopic
60 | .catch reject
61 |
62 | sendReply: (topicId, data, authUser) ->
63 | newReply = angular.extend(accesstoken: authUser?.token, data)
64 | Restangular
65 | .one('topic', topicId)
66 | .post('replies', newReply)
67 |
68 | toggleLikeReply: (reply, authUser) ->
69 | $q (resolve, reject) ->
70 | Restangular
71 | .one('reply', reply.id)
72 | .post('ups', accesstoken: authUser?.token)
73 | .then (resp) ->
74 | switch resp.action
75 | when 'up'
76 | reply.ups.push authUser.id
77 | when 'down'
78 | _.pull(reply.ups, authUser.id)
79 | else
80 | reject(resp)
81 | resolve resp.action
82 | .catch reject
83 |
84 |
--------------------------------------------------------------------------------
/www/app/commom/services/user.service.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .factory 'userService', (
4 | $q
5 | Restangular
6 | ) ->
7 |
8 | cache = {}
9 |
10 | reset: ->
11 | cache = {}
12 |
13 | getDetail: (loginname, reload = false) ->
14 | $q (resolve, reject) ->
15 | if _.isEmpty(loginname)
16 | return reject('错误的 loginname: ' + loginname)
17 | if !reload and cacheUser = cache[loginname]
18 | return resolve cacheUser
19 | Restangular
20 | .one('user', loginname)
21 | .get()
22 | .then (resp) ->
23 | dbUser = resp.data
24 | cache[loginname] = dbUser
25 | resolve dbUser
26 | .catch reject
27 |
28 | collectTopic: (topic, authUser) ->
29 | $q (resolve, reject) ->
30 | Restangular
31 | .all('topic/collect')
32 | .post(accesstoken: authUser?.token, topic_id: topic.id)
33 | .then (resp) ->
34 | # 更新已经被cache的user信息
35 | if cacheUser = cache[authUser.loginname]
36 | cacheUser.collect_topics.push topic
37 | resolve resp
38 | .catch reject
39 |
40 | deCollectTopic: (topic, authUser) ->
41 | $q (resolve, reject) ->
42 | Restangular
43 | .all('topic/de_collect')
44 | .post(accesstoken: authUser?.token, topic_id: topic.id)
45 | .then (resp) ->
46 | # 更新已经被cache的user信息
47 | if cacheUser = cache[authUser.loginname]
48 | _.remove(cacheUser.collect_topics, id: topic.id)
49 | resolve resp
50 | .catch reject
51 |
52 |
--------------------------------------------------------------------------------
/www/app/configs/constant.coffee:
--------------------------------------------------------------------------------
1 | ALL_SERVERS_TABS =
2 | 'https://cnodejs.org': [
3 | {label: '全部', value: 'all'}
4 | {label: '精华', value: 'good'}
5 | {label: '分享', value: 'share'}
6 | {label: '问答', value: 'ask'}
7 | {label: '招聘', value: 'job'}
8 | ]
9 | 'http://ionichina.com': [
10 | {label: '全部', value: 'all'}
11 | #{label: '精华', value: 'good'} API BUG
12 | {label: '分享', value: 'share'}
13 | {label: '问答', value: 'ask'}
14 | {label: '招聘', value: 'job'}
15 | {label: '吐槽', value: 'bb'}
16 | ]
17 | ALL_SERVERS = _.keys(ALL_SERVERS_TABS)
18 | CUR_SERVER = localStorage.server ? ALL_SERVERS[0]
19 |
20 | angular.module('ionic-nodeclub')
21 |
22 | .constant 'API',
23 | allServers: ALL_SERVERS
24 | server: CUR_SERVER
25 | path: '/api/'
26 | version: 'v1'
27 |
28 | .constant 'tabs', ALL_SERVERS_TABS[CUR_SERVER]
29 |
30 | .constant 'config',
31 | TOPICS_PAGE_LIMIT: 15 # 每次加载topics为30个
32 | LATEST_REPLIES_DEFAULT_NUM: 30 # 最新回复默认显示30个
33 | POP_REPLIES_TRIGGER_NUM: 10 # 有10个回复以上才显示最赞回复
34 | POP_REPLIES_LIMIT: 3 # 最赞回复默认显示3个
35 | TOAST_SHORT_DELAY: 2000 # 短的toast显示时长为2秒
36 | TOAST_LONG_DELAY: 3500 # 长的toast显示时长为3.5秒
37 |
38 |
--------------------------------------------------------------------------------
/www/app/configs/rest.config.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .config (RestangularProvider, API) ->
4 | RestangularProvider.setBaseUrl(API.server + API.path + API.version)
5 | RestangularProvider.setRestangularFields(id: 'id')
6 |
7 | .config ($httpProvider) ->
8 | $httpProvider.interceptors.push ($q, storage) ->
9 | request: (config) ->
10 | config
11 | responseError: (rejection) ->
12 | #if rejection.status is 403
13 | #storage.remove 'user'
14 | $q.reject rejection
15 |
16 |
--------------------------------------------------------------------------------
/www/app/configs/router.config.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .config ($stateProvider, $urlRouterProvider, tabs) ->
4 |
5 | $stateProvider
6 | .state 'app',
7 | url: '/app'
8 | abstract: true
9 | templateUrl: 'app/main/main.html'
10 | controller: 'MainCtrl'
11 |
12 | .state 'app.topics',
13 | url: '/topics/:tab'
14 | historyRoot: true
15 | views:
16 | mainContent:
17 | templateUrl: 'app/topics/topics.html'
18 | controller: 'TopicsCtrl'
19 |
20 | .state 'app.topic',
21 | url: '/topic/:topicId'
22 | views:
23 | mainContent:
24 | templateUrl: 'app/topic/topic.html'
25 | controller: 'TopicCtrl'
26 |
27 | .state 'app.replies',
28 | url: '/replies/:topicId'
29 | views:
30 | mainContent:
31 | templateUrl: 'app/replies/replies.html'
32 | controller: 'RepliesCtrl'
33 |
34 | .state 'app.user',
35 | url: '/user/:loginname'
36 | views:
37 | mainContent:
38 | templateUrl: 'app/user/user.html'
39 | controller: 'UserCtrl'
40 |
41 | .state 'app.messages',
42 | url: '/messages'
43 | views:
44 | mainContent:
45 | templateUrl: 'app/messages/messages.html'
46 | controller: 'MessagesCtrl'
47 |
48 | .state 'app.settings',
49 | url: '/settings'
50 | views:
51 | mainContent:
52 | templateUrl: 'app/settings/settings.html'
53 | controller: 'SettingsCtrl'
54 |
55 | $urlRouterProvider.otherwise "/app/topics/#{tabs[0].value}"
56 |
57 |
--------------------------------------------------------------------------------
/www/app/main/login-modal.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | 登录
4 |
5 |
7 |
8 |
9 |
10 |
32 |
33 | OR
34 |
35 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/www/app/main/main.controller.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .controller 'MainCtrl', (
4 | tabs
5 | $scope
6 | ) ->
7 |
8 | angular.extend $scope,
9 | tabs: tabs
10 |
11 |
--------------------------------------------------------------------------------
/www/app/main/main.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/www/app/messages/messageTile.directive.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .directive 'messageTile', ->
4 | restrict: 'E'
5 | templateUrl: 'app/messages/messageTile.html'
6 |
--------------------------------------------------------------------------------
/www/app/messages/messageTile.html:
--------------------------------------------------------------------------------
1 |
34 |
--------------------------------------------------------------------------------
/www/app/messages/messages.controller.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .controller 'MessagesCtrl', (
4 | toast
5 | $scope
6 | $stateParams
7 | $ionicLoading
8 | messageService
9 | ) ->
10 |
11 | loadMessages = (refresh) ->
12 | $scope.loading = true
13 | messageService.getMessages refresh
14 | .then (data) ->
15 | $scope.has_read_messages = data.has_read_messages
16 | $scope.hasnot_read_messages = data.hasnot_read_messages
17 | .catch (error) ->
18 | $scope.error = error
19 | .finally ->
20 | $scope.loading = false
21 | $scope.$broadcast('scroll.refreshComplete')
22 |
23 | angular.extend $scope,
24 | has_read_messages: null
25 | hasnot_read_messages: null
26 | loading: false
27 | error: null
28 | doRefresh: ->
29 | loadMessages(refresh = true)
30 | markAsRead: ->
31 | $ionicLoading.show()
32 | messageService.markAllAsRead()
33 | .then ->
34 | $ionicLoading.hide()
35 | toast $scope.hasnot_read_messages.length + '个消息被标记为已读'
36 | loadMessages(refresh = true)
37 | .catch ->
38 | $ionicLoading.hide()
39 |
40 | loadMessages(refresh = false)
41 |
--------------------------------------------------------------------------------
/www/app/messages/messages.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
18 |
20 | 囧,出错啦...
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
31 | 无未读消息
32 |
33 |
34 |
35 |
已读消息
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
47 |
48 |
--------------------------------------------------------------------------------
/www/app/replies/replies.controller.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .controller 'RepliesCtrl', (
4 | focus
5 | toast
6 | config
7 | $scope
8 | $state
9 | $filter
10 | $window
11 | settings
12 | authService
13 | $ionicModal
14 | $stateParams
15 | topicService
16 | $ionicLoading
17 | $cordovaClipboard
18 | $ionicActionSheet
19 | $ionicScrollDelegate
20 | ) ->
21 |
22 | curSettings = settings.get()
23 |
24 | loadReplies = (refresh) ->
25 | $scope.loading = true
26 | topicService.getReplies($stateParams.topicId, refresh)
27 | .then (topic) ->
28 | $scope.topic = topic
29 | # 我设置最新的回复
30 | $scope.latestReplies =
31 | if curSettings.latestRepliesDesc
32 | topic.replies.reverse()
33 | else
34 | topic.replies
35 | # 我设置最赞的回复
36 | if curSettings.popRepliesVisible and $scope.latestReplies.length > config.POP_REPLIES_TRIGGER_NUM
37 | $scope.popReplies = _($scope.latestReplies) # 我用lodash的chaining链式调用
38 | .filter (reply) -> reply.ups.length > 0 # 我过滤出有赞的回复
39 | .sortBy (reply) -> -reply.ups.length # 我把比较赞的回复排在前面
40 | .value()
41 | .catch (error) ->
42 | $scope.error = error
43 | .finally ->
44 | $scope.loading = false
45 | $scope.$broadcast('scroll.refreshComplete')
46 |
47 | angular.extend $scope,
48 | loading: false
49 | error: null
50 | topic: null
51 | config: config
52 | nLatest: config.LATEST_REPLIES_DEFAULT_NUM
53 | replyModal: null
54 | popReplies: null
55 | allReplies: null
56 | latestReplies: null
57 | scrollDelegate: $ionicScrollDelegate.$getByHandle('replies-handle')
58 | newReply:
59 | content: ''
60 |
61 | doRefresh: ->
62 | if $scope.loading then return
63 | $scope.scrollDelegate.scrollTop(true)
64 | $scope.error = null
65 | $scope.nLatest = config.LATEST_REPLIES_DEFAULT_NUM
66 | loadReplies(refresh = true)
67 |
68 | displayMore: ->
69 | $scope.nLatest = $scope.latestReplies.length
70 |
71 | toggleLike: (reply) ->
72 | authService.withAuthUser (authUser) ->
73 | topicService.toggleLikeReply(reply, authUser)
74 | .then (action) ->
75 | toast '已赞' if action is 'up'
76 | .catch (error) ->
77 | toast error.error_msg
78 |
79 |
80 | replyAuthor: (reply) ->
81 | authService.withAuthUser (authUser) ->
82 | $scope.newReply.content = "@#{reply.author.loginname} "
83 | $scope.newReply.reply_id = reply.id
84 | focus('focus.newReplyInput')
85 |
86 | clearNewReply: ->
87 | $scope.newReply.content = ''
88 | $scope.newReply.reply_id = null
89 |
90 | showReplyAction: (reply) ->
91 | $ionicActionSheet.show
92 | buttons: [
93 | text: '复制'
94 | ,
95 | text: '引用'
96 | ,
97 | text: '@Ta'
98 | ,
99 | text: '作者'
100 | ]
101 | buttonClicked: (index) ->
102 | switch index
103 | # copy content
104 | when 0
105 | text = $filter('toMarkdown')(reply.content)
106 | if $window.cordova
107 | $cordovaClipboard
108 | .copy text
109 | .then ->
110 | toast '已拷贝到粘贴板'
111 | else
112 | console.log 'copy...' + text
113 | # quote content
114 | when 1
115 | quote = $filter('toMarkdown')(reply.content)
116 | quote = '\n' + quote.trim().replace(/([^\n]+)\n*/g, '>$1\n>\n')
117 | content = $scope.newReply.content + "#{quote}"
118 | $scope.newReply.content = content.trim() + '\n\n'
119 | focus('focus.newReplyInput')
120 | # @ someone
121 | when 2
122 | content = $scope.newReply.content
123 | content += " @#{reply.author.loginname}"
124 | $scope.newReply.content = content.trim() + ' '
125 | focus('focus.newReplyInput')
126 | # about author
127 | else
128 | $state.go('app.user', loginname: reply.author.loginname)
129 | return true
130 |
131 | sendReply: ->
132 | authService.withAuthUser (authUser) ->
133 | $ionicLoading.show()
134 | topicService.sendReply($stateParams.topicId, $scope.newReply, authUser)
135 | .then ->
136 | $scope.clearNewReply()
137 | $scope.doRefresh()
138 | .finally ->
139 | $ionicLoading.hide()
140 |
141 | showSendAction: ->
142 | $ionicActionSheet.show
143 | buttons: [
144 | text: '发送'
145 | ,
146 | text: '预览'
147 | ]
148 | buttonClicked: (index) ->
149 | switch index
150 | when 0
151 | $scope.sendReply()
152 | else
153 | if $scope.replyModal?
154 | $scope.replyModal.show()
155 | else
156 | $ionicLoading.show()
157 | $ionicModal
158 | .fromTemplateUrl('app/replies/reply-preview-modal.html', scope: $scope)
159 | .then (modal) ->
160 | $scope.replyModal = modal
161 | $scope.replyModal.show()
162 | .finally ->
163 | $ionicLoading.hide()
164 | return true
165 |
166 | loadReplies(refresh = true)
167 |
168 | $scope.$on '$destroy', ->
169 | $scope.replyModal?.remove()
170 |
171 |
--------------------------------------------------------------------------------
/www/app/replies/replies.html:
--------------------------------------------------------------------------------
1 |
2 | 回复
3 |
4 |
10 |
11 |
13 |
14 |
15 |
16 |
17 |
22 |
24 | 囧,出错啦...
25 |
26 |
27 |
28 |
29 |
30 |
34 |
36 | 暂无回复
37 |
38 |
39 |
40 |
42 | 最赞回复
43 |
45 | top {{::config.POP_REPLIES_LIMIT}}
46 |
47 |
48 |
49 |
50 |
51 |
52 |
最新回复
54 |
55 |
56 |
57 |
58 |
60 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
79 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/www/app/replies/reply-preview-modal.html:
--------------------------------------------------------------------------------
1 |
2 |
4 | 预览 - markdown
5 |
6 |
8 |
9 |
10 |
11 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/www/app/replies/replyTile.directive.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .directive 'replyTile', ->
4 | restrict: 'E'
5 | templateUrl: 'app/replies/replyTile.html'
6 |
--------------------------------------------------------------------------------
/www/app/replies/replyTile.html:
--------------------------------------------------------------------------------
1 |
3 |
5 |
8 | {{::reply.author.loginname}}
9 |
10 |
11 | {{reply.ups.length}} 赞
12 |
13 |
14 |
15 |
17 |
18 |
19 |
20 |
23 |
24 | 回复
25 |
26 |
27 |
28 |
31 |
32 | 回复
33 |
34 |
35 |
36 |
37 |
40 |
41 | 已赞
42 |
43 |
44 |
45 |
48 |
49 | 赞
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/www/app/scss/_action-sheet.scss:
--------------------------------------------------------------------------------
1 | .platform-android {
2 | .action-sheet-group {
3 | .button {
4 | padding: 0 10px;
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/www/app/scss/_button.scss:
--------------------------------------------------------------------------------
1 | .header-replies-button {
2 | min-height: 25px !important;
3 | line-height: 24px !important;
4 | height: 25px !important;
5 | background-color: $button-positive-border !important;
6 | margin: 4px;
7 | position: relative;
8 | &:before {
9 | content: '';
10 | position: absolute;
11 | border-top: 5px solid $button-positive-border;
12 | border-left: 0px solid transparent;
13 | border-right: 10px solid transparent;
14 | left: -1px;
15 | bottom: -5px;
16 | }
17 | span {
18 | margin: 0 !important;
19 | }
20 | }
21 | .header-avatar-button {
22 | line-height: 34px;
23 | height: 34px;
24 | .avatar {
25 | height: 25px;
26 | width: 25px;
27 | margin-right: 5px;
28 | border-radius: 50%;
29 | vertical-align:middle;
30 | box-shadow: 0px 0px 1px 1px rgba(0,0,0,0.15);
31 | }
32 | }
33 | .button-link {
34 | @include transition(opacity .1s);
35 | cursor: pointer;
36 | text-decoration: none;
37 | &:hover {
38 | color: $button-positive-bg !important;
39 | }
40 | &.active,
41 | &.activated {
42 | color: $button-positive-active-bg !important;
43 | }
44 | }
45 | .button-float {
46 | position: fixed;
47 | bottom: 16px;
48 | right: 16px;
49 | border: none;
50 | border-radius: 50%;
51 | min-width: 56px;
52 | min-height: 56px;
53 | box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26);
54 | z-index: 20;
55 | @include transition(0.2s linear);
56 | @include transition-property('background-color, box-shadow');
57 | &.active,
58 | &.activated {
59 | box-shadow: 0 3px 7px 0 rgba(0, 0, 0, 0.36) !important;
60 | }
61 | }
62 | .reply-button {
63 | margin-left: 0 !important;
64 | font-size: 16px !important;
65 | border: none;
66 | background-color: #EEE;
67 | }
68 | .new-reply {
69 | background: none;
70 |
71 | textarea {
72 | background: none;
73 | max-height: 130px;
74 | box-sizing: border-box;
75 | min-height: 34px;
76 | height: 34px;
77 | padding: 5px;
78 | border-bottom: 1px solid $item-stable-border;
79 | &:focus {
80 | border-bottom: 1px solid $item-positive-border;
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/www/app/scss/_footer.scss:
--------------------------------------------------------------------------------
1 | .elastic-footer {
2 | height: auto;
3 | background-image: none;
4 | box-shadow: 0 0 3px 1px rgba(0, 0, 0, .15);
5 | }
6 |
--------------------------------------------------------------------------------
/www/app/scss/_icon.scss:
--------------------------------------------------------------------------------
1 | .icon-person {
2 | @extend .ion-ios-person-outline;
3 | }
4 | .icon-new-topic {
5 | @extend .ion-android-add;
6 | }
7 | .icon-more-action {
8 | @extend .ion-android-more-vertical;
9 | }
10 | .icon-close {
11 | @extend .ion-ios-close-empty;
12 | }
13 | .icon-done-all {
14 | @extend .ion-android-done-all;
15 | }
16 | .icon-more {
17 | @extend .ion-ios-more;
18 | }
19 |
20 | // android overwrite
21 | .platform-android {
22 | .icon-person {
23 | @extend .ion-android-person;
24 | }
25 | .icon-more {
26 | @extend .ion-android-more-vertical;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/www/app/scss/_main.scss:
--------------------------------------------------------------------------------
1 | hr {
2 | border-top: none;
3 | border-left: none;
4 | border-right: none;
5 | }
6 |
7 | img {
8 | max-width: 100%;
9 | white-space: pre-wrap;
10 | }
11 |
12 | .item-author {
13 | line-height: 20px;
14 | text-decoration: none;
15 | .avatar {
16 | border-radius: 4px;
17 | height: 16px;
18 | width: 16px;
19 | float: left;
20 | margin-right: 5px;
21 | margin-top: 2px;
22 | }
23 | }
24 | .item-topic {
25 | .item-content {
26 | img {
27 | border-radius: 4px !important;
28 | }
29 | padding-right: 16px !important;
30 | }
31 | }
32 | .label {
33 | border-radius: 3px;
34 | padding: 1px 3px;
35 | font-size: 12px;
36 | color: white;
37 | margin-right: 2px;
38 | &.label-positive {
39 | background-color: $button-positive-active-bg;
40 | color: white;
41 | }
42 | &.label-default {
43 | background: #e5e5e5;
44 | color: #999;
45 | }
46 | }
47 | .text-note {
48 | color: #aaa;
49 | }
50 | textarea {
51 | resize: none !important;
52 | width: 100%;
53 | font-size: 16px !important;
54 | line-height: 1.4;
55 | min-height: 50px;
56 | }
57 | .markdown-text {
58 | margin: 8px 0;
59 | color: #444;
60 | font-size: 15px;
61 | line-height: 1.6em;
62 | ol, ul {
63 | list-style-type: decimal;
64 | padding-left: 20px;
65 | color: #444;
66 | }
67 | p {
68 | color: #444;
69 | white-space: pre-wrap;
70 | }
71 | }
72 | .reply-item {
73 | .avatar {
74 | height: 22px;
75 | width: 22px;
76 | }
77 | .reply-content {
78 | padding-left: 28px;
79 | }
80 | }
81 | .message-item {
82 | .avatar {
83 | height: 22px;
84 | width: 22px;
85 | }
86 | p {
87 | padding-left: 28px;
88 | margin-top: 5px;
89 | }
90 | }
91 | .user-item-body {
92 | p {
93 | margin-top: 0;
94 | }
95 | .button-icon {
96 | min-height: 0;
97 | height: 35px;
98 | line-height: 1;
99 | padding: 0;
100 | &:before {
101 | font-size: 21px!important;
102 | line-height: 13px;
103 | }
104 | }
105 | }
106 | .unread-message-dot {
107 | &:before {
108 | content: ' ';
109 | position: absolute;
110 | width: 8px;
111 | height: 8px;
112 | background-color: #FD4638;
113 | border-radius: 50%;
114 | top: 3px;
115 | right: 10px;
116 | box-shadow: 1px 1px 1px gray;
117 | }
118 | }
119 | .menu-dark {
120 | background: #444;
121 | .item {
122 | color: #f8f8f8;
123 | border: 1px solid #3a3a3a;
124 | background: #444;
125 | &.active {
126 | background: #555;
127 | }
128 | }
129 | .item-divider {
130 | background: #444;
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/www/app/scss/_popover.scss:
--------------------------------------------------------------------------------
1 | .popover {
2 | @extend .popover;
3 | height: 275px;
4 | }
5 |
--------------------------------------------------------------------------------
/www/app/scss/app.bundle.scss:
--------------------------------------------------------------------------------
1 | /*
2 | To customize the look and feel of Ionic, you can override the variables
3 | in ionic's _variables.scss file.
4 |
5 | For example, you might change some of the default colors:
6 |
7 | $light: #fff !default;
8 | $stable: #f8f8f8 !default;
9 | $positive: #387ef5 !default;
10 | $calm: #11c1f3 !default;
11 | $balanced: #33cd5f !default;
12 | $energized: #ffc900 !default;
13 | $assertive: #ef473a !default;
14 | $royal: #886aea !default;
15 | $dark: #444 !default;
16 | */
17 |
18 | $button-light-border: #e5e5e5 !default;
19 |
20 | // The path for our ionicons font files, relative to the built CSS in www/css
21 | $ionicons-font-path: "../lib/ionic/fonts" !default;
22 |
23 | // Include all of Ionic + App
24 | @import
25 | "../../lib/ionic/scss/ionic",
26 | "main",
27 | "icon",
28 | "button",
29 | "footer",
30 | "popover",
31 | "action-sheet";
32 |
33 |
--------------------------------------------------------------------------------
/www/app/settings/settings.controller.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .controller 'SettingsCtrl', (
4 | $scope
5 | settings
6 | Restangular
7 | $cordovaAppVersion
8 | ) ->
9 |
10 | versionCompare = (v1, v2) ->
11 | return 0 if !v1 or !v2
12 | v1parts = v1.split('.').map(Number)
13 | v2parts = v2.split('.').map(Number)
14 | if v1parts.length > v2parts.length
15 | return 1
16 | if v1parts.length < v2parts.length
17 | return -1
18 | for i in [0...v1parts.length]
19 | if v1parts[i] is v2parts[i]
20 | continue
21 | else if v1parts[i] > v2parts[i]
22 | return 1
23 | else
24 | return -1
25 | return 0
26 |
27 | angular.extend $scope,
28 | settings: settings.get()
29 | appVersion: null
30 | newVersionData: null
31 | updateSettings: ->
32 | settings.set($scope.settings)
33 |
34 | if window.cordova
35 | $cordovaAppVersion.getAppVersion()
36 | .then (version) ->
37 | $scope.appVersion = version
38 | Restangular
39 | .oneUrl('getNewVersion', 'http://fir.im/api/v2/app/version/5540b7aefe9978f3100002f4')
40 | .get()
41 | .then (newVersionData) ->
42 | $scope.newVersionData = newVersionData
43 | newVersion = $scope.newVersionData.versionShort
44 | curVersion = $scope.appVersion
45 | $scope.hasNewVersion = versionCompare(newVersion, curVersion) > 0
46 |
47 |
--------------------------------------------------------------------------------
/www/app/settings/settings.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
10 | 是否显示最赞回复
11 |
12 |
13 |
16 | 倒序显示最新回复
17 |
18 |
19 |
21 | 当前版本{{::appVersion}}
22 |
23 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/www/app/topic/topic.controller.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .controller 'TopicCtrl', (
4 | API
5 | toast
6 | $scope
7 | $state
8 | $timeout
9 | $ionicModal
10 | authService
11 | userService
12 | topicService
13 | $stateParams
14 | $ionicPopover
15 | messageService
16 | $ionicActionSheet
17 | ) ->
18 |
19 | collectTopic = ->
20 | authService.withAuthUser (authUser) ->
21 | if $scope.isCollected
22 | userService.deCollectTopic($scope.topic, authUser)
23 | .then ->
24 | $scope.isCollected = false
25 | toast '已取消收藏'
26 | else
27 | userService.collectTopic($scope.topic, authUser)
28 | .then ->
29 | $scope.isCollected = true
30 | toast '收藏成功'
31 |
32 | loadTopic = (refresh) ->
33 | $scope.loading = true
34 | topicService.getDetail $stateParams.topicId, refresh
35 | .then (topic) ->
36 | $scope.topic = topic
37 | .catch (error) ->
38 | $scope.error = error
39 | .finally ->
40 | $scope.loading = false
41 | $scope.$broadcast('scroll.refreshComplete')
42 |
43 | angular.extend $scope,
44 | loading: false
45 | isCollected: false
46 | error: null
47 | topic: null
48 | msg: messageService
49 |
50 | doRefresh: ->
51 | loadTopic(refresh = true)
52 |
53 | showTopicAction: ->
54 | $ionicActionSheet.show
55 | buttons: [
56 | text: '在浏览器中打开'
57 | ,
58 | text: '重新加载'
59 | ,
60 | text: '关于作者'
61 | ,
62 | text: if !$scope.isCollected then '收藏话题' else '取消收藏'
63 | ,
64 | text: '回复话题'
65 | ]
66 | buttonClicked: (index) ->
67 | switch index
68 | when 0
69 | window.open "#{API.server}/topic/#{$stateParams.topicId}", '_system'
70 | when 1
71 | loadTopic(refresh = true)
72 | when 2
73 | $state.go 'app.user', loginname: $scope.topic.author.loginname
74 | when 3
75 | collectTopic()
76 | else
77 | $state.go 'app.replies', topicId:$stateParams.topicId
78 | return true
79 |
80 | # 我在获取这个主题的内容
81 | # 这里不强制刷新,允许从缓存里面拿
82 | loadTopic(refresh = false)
83 |
84 | # 我在检查用户是否收藏了这个主题
85 | userService.getDetail($scope.me?.loginname)
86 | .then (dbUser) ->
87 | isCollected = _.find(dbUser.collect_topics, id:$stateParams.topicId)?
88 | $scope.isCollected = isCollected
89 |
90 |
--------------------------------------------------------------------------------
/www/app/topic/topic.html:
--------------------------------------------------------------------------------
1 |
2 | 正文
3 |
4 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
21 |
22 |
24 | 囧,出错啦...
25 |
26 |
27 |
28 |
31 | {{::topic.title}}
32 |
33 |
34 |
36 |
39 | {{::topic.author.loginname}}
40 |
42 |
43 |
44 |
45 |
47 |
48 |
49 |
50 |
53 |
54 | {{::topic.reply_count}} 回复
55 | -
56 |
57 |
58 |
59 |
60 | 暂无回复
61 |
62 |
63 |
64 |
65 |
66 |
67 |
70 |
71 |
--------------------------------------------------------------------------------
/www/app/topics/more-popover.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/www/app/topics/new-topic-modal.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | 发布话题
4 |
5 |
7 |
8 |
9 |
10 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/www/app/topics/topics.controller.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .controller 'TopicsCtrl', (
4 | API
5 | tabs
6 | toast
7 | $scope
8 | $state
9 | $window
10 | $timeout
11 | $ionicModal
12 | authService
13 | $stateParams
14 | topicService
15 | $ionicPopover
16 | messageService
17 | $ionicActionSheet
18 | $ionicScrollDelegate
19 | ) ->
20 |
21 | $ionicModal
22 | .fromTemplateUrl 'app/topics/new-topic-modal.html',
23 | scope: $scope
24 | .then (modal) ->
25 | $scope.newTopicModal = modal
26 |
27 | $ionicPopover
28 | .fromTemplateUrl 'app/topics/more-popover.html',
29 | scope: $scope
30 | .then (popover) ->
31 | $scope.morePopover = popover
32 |
33 | selectedTab = $stateParams.tab ? tabs[0].value
34 |
35 | loadTopics = (refresh) ->
36 | $scope.loading = true
37 | from = if refresh then 0 else $scope.topics?.length ? 0
38 | topicService.getTopics selectedTab, from
39 | .then (resp) ->
40 | if refresh or !$scope.topics
41 | $scope.topics = []
42 | $scope.topics = $scope.topics.concat(resp.topics)
43 | $scope.hasMore = resp.hasMore
44 | .catch (error) ->
45 | $scope.error = error
46 | .finally ->
47 | $scope.loading = false
48 | $scope.$broadcast('scroll.refreshComplete')
49 | $scope.$broadcast('scroll.infiniteScrollComplete')
50 |
51 | mkNewTopic = ->
52 | tab: selectedTab
53 | content: ''
54 | title: ''
55 |
56 | # Export Properties
57 | angular.extend $scope,
58 | hasMore: true
59 | loading: false
60 | error: null
61 | topics: null
62 | auth: authService
63 | msg: messageService
64 | selectedTab: selectedTab
65 | tabs: _.filter(tabs, (t) -> t.value isnt 'all')
66 | newTopic: mkNewTopic()
67 | newTopicModal: null
68 | morePopover: null
69 | scrollDelegate: $ionicScrollDelegate.$getByHandle('topics-handle')
70 |
71 | createNewTopic: ->
72 | authService.withAuthUser (user) ->
73 | $scope.newTopicModal.show()
74 |
75 | doPostTopic: ->
76 | return toast('发布失败:请先选择一个板块。') if _.isEmpty($scope.newTopic.tab)
77 | return toast('发布失败:请先输入标题。' ) if _.isEmpty($scope.newTopic.title)
78 | return toast('发布失败:话题内容不能为空。') if _.isEmpty($scope.newTopic.content)
79 |
80 | authService.withAuthUser (user) ->
81 | topicService.postNew $scope.newTopic, user
82 | .then ->
83 | $scope.scrollDelegate.scrollTop(false)
84 | $scope.newTopic = mkNewTopic()
85 | $scope.newTopicModal.hide()
86 | $timeout $scope.doRefresh
87 | .catch (error) ->
88 | toast('发布失败: ' + error?.data?.error_msg, 'long')
89 |
90 | switchNodeclub: ->
91 | $ionicActionSheet.show
92 | buttons: _.map API.allServers, (s) ->
93 | if s is API.server
94 | text: "#{s} (当前)"
95 | else
96 | text: s
97 | buttonClicked: (index) ->
98 | localStorage.server = API.allServers[index]
99 | $state.go('app.topics', tab:'all')
100 | $window.location.reload(false)
101 | return true
102 |
103 | doRefresh: ->
104 | if $scope.loading then return
105 | $scope.error = null
106 | $scope.hasMore = true
107 | loadTopics(refresh = true)
108 |
109 | loadMore: ->
110 | if $scope.loading or $scope.error then return
111 | loadTopics(refresh = false)
112 |
113 | $scope.$on '$destroy', ->
114 | $scope.newTopicModal?.remove()
115 | $scope.morePopover?.remove()
116 |
117 |
--------------------------------------------------------------------------------
/www/app/topics/topics.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
7 |
8 |
9 |
10 |
17 |
18 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
34 |
35 |
36 |
37 |
39 | 没有了...
40 |
41 |
43 | 囧,出错啦...
44 |
45 |
46 |
47 |
48 |
50 |
51 |
--------------------------------------------------------------------------------
/www/app/topics/topicsList.directive.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .directive 'topicsList', ->
4 | restrict: 'E'
5 | templateUrl: 'app/topics/topicsList.html'
6 | scope:
7 | topics: '='
8 | selectedTab: '='
9 |
--------------------------------------------------------------------------------
/www/app/topics/topicsList.html:
--------------------------------------------------------------------------------
1 |
2 |
8 |
![]()
9 |
{{topic.title}}
10 |
11 | 置顶
13 | 精华
15 | {{topic.tab | tabLabel}}
17 | {{topic.author.loginname}}
18 |
20 |
21 |
22 |
23 |
无话题
25 |
26 |
--------------------------------------------------------------------------------
/www/app/user/user.controller.coffee:
--------------------------------------------------------------------------------
1 | angular.module('ionic-nodeclub')
2 |
3 | .controller 'UserCtrl', (
4 | $scope
5 | userService
6 | authService
7 | $stateParams
8 | messageService
9 | ) ->
10 |
11 | userService.getDetail($stateParams.loginname, true)
12 | .then (user) ->
13 | $scope.user = user
14 | $scope.displayTopics =
15 | if $scope.isCollectVisible()
16 | 'collect_topics'
17 | else
18 | 'recent_topics'
19 |
20 | angular.extend $scope,
21 | user: null
22 | displayTopics: null
23 | auth: authService
24 | msg: messageService
25 | $stateParams: $stateParams
26 |
27 | isCollectVisible: ->
28 | isMyDetail = $scope.user and $scope.me?.loginname is $scope.user?.loginname
29 | hasCollectedTopics = $scope.user?.collect_topics.length
30 | isMyDetail or hasCollectedTopics
31 |
32 | changeType: (type) ->
33 | $scope.displayTopics = type
34 |
35 |
--------------------------------------------------------------------------------
/www/app/user/user.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
10 |
11 |
12 |
17 |
18 |
19 |
21 |

24 |
{{::user.loginname}}
25 |
{{::user.score}}积分
26 |
27 |
28 |
40 |
43 | @{{::user.githubUsername}}
44 |
45 |
46 | 注册时间:
47 |
48 |
49 |
50 |
51 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
--------------------------------------------------------------------------------
/www/img/ionic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ouzhenkun/ionic-nodeclub/f3c83de22ddef22d8a4b45232dd2dffd93c623a5/www/img/ionic.png
--------------------------------------------------------------------------------
/www/index.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 |
--------------------------------------------------------------------------------