├── .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 |
11 |
12 | 请登录网页版 13 | 15 | {{::API.server}} 16 | 17 | 并拷贝设置页中的'Access Token'字符串或者扫描其二维码完成登录 18 |
19 |
20 | 26 | 30 |
31 |
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 | 17 | 18 |
19 |
板块列表
20 |
26 | {{::tab.label}} 27 |
28 |
29 |
30 |
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 |
2 | 4 | {{::message.author.loginname}} 7 | {{::message.author.loginname}} 8 | 10 | 11 | 12 | 13 | 回复了你的话题 14 |

15 | 18 | {{::message.topic.title}} 19 | 20 |

21 |
22 | 23 | 在回复中@了你 24 |

25 | 28 | {{::message.topic.title}} 29 | 30 |

31 |
32 |
33 |
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 |
15 | 16 |

加载中...

17 |
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 |
19 | 20 |

加载中...

21 |
22 |
24 | 囧,出错啦... 25 |
26 | 27 | 28 |
29 | 30 |
31 | {{::topic.title}} 33 |
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 |
12 |
13 | 16 | 20 |
21 |
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 | {{::reply.author.loginname}} 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 |
18 | 19 |

加载中...

20 |
21 | 22 |
24 | 囧,出错啦... 25 |
26 | 27 | 28 | 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 |
11 |
12 | 21 | 26 | 32 | 36 |
37 |
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 | 13 | {{::me.loginname}} 16 | 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 |
14 | 15 |

加载中...

16 |
17 | 18 | 19 |
21 | {{::user.loginname}} 24 |

{{::user.loginname}}

25 |

{{::user.score}}积分

26 |
27 | 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 | --------------------------------------------------------------------------------