diff --git a/.codeclimate.yml b/.codeclimate.yml
new file mode 100644
index 000000000..e8e59deb5
--- /dev/null
+++ b/.codeclimate.yml
@@ -0,0 +1,4 @@
+exclude_paths:
+- "lib/assets/**/*"
+- "app/controllers/admin/**/*"
+- "public/api-doc/**/*"
diff --git a/.gitignore b/.gitignore
index 33f1e4e44..53a5ab0db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,42 @@
.bundle
+tmp/
+coverage/
db/*.sqlite3
+tmp/
log/*.log
+log/*.log*
tmp/**/*
+.sass-cache/
+.yardoc/
+public/assets/
config/database.yml
+config/mongoid.yml
config/config.yml
+config/thin.yml
+config/redis.yml
+config/mailer_daemon.yml
+config/secrets.yml
+public/photo/**/*
+public/user/**/*
public/uploads/**/*
-public/javascripts/cached_*
-public/stylesheets/cached_*
+public/system/**/*
public/topics
public/topics/**/*
+public/doc
+.redcar
+.DS_Store
+*.swp
+*.swo
+*.sublime-workspace
+.rvmrc
+vendor/ruby
+doc/wiki_repo
+solr/data/
+solr/pids/
+tags
+chromedriver.log
+.idea/
+spec/examples.txt
+.vagrant
+.byebug_history
+.ruby-version
diff --git a/.rspec b/.rspec
new file mode 100644
index 000000000..8a154df78
--- /dev/null
+++ b/.rspec
@@ -0,0 +1,3 @@
+--color
+-f progress
+--require spec_helper
diff --git a/.rubocop.yml b/.rubocop.yml
new file mode 100644
index 000000000..bb566ad37
--- /dev/null
+++ b/.rubocop.yml
@@ -0,0 +1,93 @@
+AllCops:
+ Exclude:
+ - 'vendor/**/*'
+ - 'lib/templates/**/*'
+ - 'lib/tasks/release.rake'
+ - 'spec/**/*'
+ - 'app/models/cache_version.rb'
+ - 'Gemfile'
+ - 'db/**/*'
+ - 'config/**/*'
+ TargetRubyVersion: 2.4
+
+Style/Documentation:
+ Enabled: false
+
+Style/FrozenStringLiteralComment:
+ Enabled: false
+
+Style/StringLiterals:
+ Enabled: true
+ EnforcedStyle: double_quotes
+
+Style/UnneededPercentQ:
+ Enabled: true
+
+Style/AsciiComments:
+ Enabled: false
+
+Style/IfUnlessModifier:
+ Enabled: false
+
+Style/NumericPredicate:
+ EnforcedStyle: comparison
+
+Style/PredicateName:
+ Enabled: false
+
+Style/GuardClause:
+ Enabled: false
+
+Style/RedundantSelf:
+ Enabled: false
+
+Style/RaiseArgs:
+ EnforcedStyle: compact
+
+Style/MutableConstant:
+ Enabled: false
+
+Metrics/LineLength:
+ Max: 1200
+
+Metrics/ClassLength:
+ Max: 1200
+
+Metrics/MethodLength:
+ Max: 1200
+
+Metrics/ModuleLength:
+ Max: 1200
+
+Metrics/BlockLength:
+ Max: 1200
+
+Metrics/CyclomaticComplexity:
+ Enabled: false
+
+Metrics/AbcSize:
+ Enabled: false
+
+Metrics/PerceivedComplexity:
+ Enabled: false
+
+Rails/TimeZone:
+ Enabled: false
+
+Style/GlobalVars:
+ Enabled: false
+
+Style/GuardClause:
+ Enabled: false
+
+Rails/FindBy:
+ Enabled: false
+
+Rails/HasAndBelongsToMany:
+ Enabled: false
+
+Style/EmptyMethod:
+ EnforcedStyle: expanded
+
+Style/LambdaCall:
+ Enabled: false
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 000000000..7482fcad4
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,37 @@
+language: ruby
+
+addons:
+ postgresql: "9.4"
+
+services:
+ - postgresql
+ - memcached
+ - redis-server
+ - elasticsearch
+
+rvm:
+ - 2.4.0
+
+matrix:
+ fast_finish: true
+ allow_failures:
+ - rvm: ruby-head
+
+before_install:
+ - sed -i "s/gems.ruby-china.org/rubygems.org/g" Gemfile.lock
+ - psql -c "CREATE USER \"admin\" WITH CREATEDB PASSWORD 'admin';" -U postgres
+ - psql -c "create database \"homeland-test\" WITH OWNER=\"admin\";" -U postgres
+ - cp config/config.yml.default config/config.yml
+ - cp config/database.yml.default config/database.yml
+ - cp config/redis.yml.default config/redis.yml
+ - cp config/elasticsearch.yml.default config/elasticsearch.yml
+ - cp config/secrets.yml.default config/secrets.yml
+ - sed -i "s/SETUP_REDIS_HOST/127.0.0.1/g" config/redis.yml
+ - sed -i "s/SETUP_REDIS_PORT/6379/g" config/redis.yml
+
+before_script:
+ - RAILS_ENV=test bundle exec rake db:migrate
+
+script:
+ - bundle exec rubocop .
+ - RAILS_ENV=test bundle exec rake
diff --git a/API.md b/API.md
new file mode 100644
index 000000000..83112eed2
--- /dev/null
+++ b/API.md
@@ -0,0 +1,72 @@
+# API 文档
+
+## OAuth 2 / API 认证
+
+在使用 API 之前,你需要 [注册应用](/oauth/applications/new) 并获得可以 **OAuth App** 信息。并使用标准的 OAuth 2 实现登录,获得 `access_token` 信息。
+
+### OAuth 路径
+
+- /oauth/authorize
+- /oauth/token
+- /oauth/revoke
+
+### Response 说明
+
+所有 Response 采用 JSON 格式返回,请求状态通过 HTTP Status 返回。
+
+### HTTP Status
+
+错误的情况 Response Body 一定会是这样的格式: `{ "error" : "Error message" }`
+
+- 200, 201 - 请求成功,或执行成功。
+- 400 - 参数不符合 API 的要求、或者数据格式验证没有通过,请配合 Response Body 里面的 error 信息确定问题。
+- 401 - 用户认证失败,或缺少认证信息,比如 access_token 过期,或没传,可以尝试用 refresh_token 方式获得新的 access_token。
+- 403 - 当前用户对资源没有操作权限。
+- 404 - 资源不存在。
+- 500 - 服务器异常。
+
+#### 资源权限描述
+
+在部分 API 的 response 内容里面你会看到 `abilities` 节点,这是特别标识当前 `access_token` 对应的用户对此资源的权限。
+
+请参考源代码,确定那些路径是需要用户认证的,需要用户认证的路径,你需要带上 `access_token=?` 参数。
+
+**例如**
+
+```json
+{
+ "topic": {
+ "id": 256170,
+ ....,
+ "abilities": { "update": true, "destroy": true }
+ }
+}
+```
+
+- update 是否有权限修改
+- destroy 是否有权限删除
+
+## API 路由
+
+API 的详细文档,请访问 [Api::V3](/api-doc/Api/V3.html) 阅读。
+
+## 演示
+
+我们用 Ruby 演示一下访问 [/api/v3/hello.json](/api-doc/Api/V3/RootController.html#hello-instance_method) 这个路径,其中包含 OAuth 2 的流程。
+
+_这里用到 RubyGem [oauth2](https://github.com/intridea/oauth2)_
+
+```rb
+require "oauth2"
+client = OAuth2::Client.new('client id', 'secret', site: 'https://ruby-china.org')
+access_token = client.password.get_token('username', 'password')
+res = Faraday.get("https://ruby-china.org/api/v3/hello.json?access_token=#{access_token.token}")
+puts res.status
+puts res.body
+```
+
+最后输出
+
+```rb
+{ 'current_user' : 'username' }
+```
\ No newline at end of file
diff --git a/Benchmarks.md b/Benchmarks.md
new file mode 100644
index 000000000..e21606d60
--- /dev/null
+++ b/Benchmarks.md
@@ -0,0 +1,20 @@
+# 页面响应时间记录
+
+要点,所有统计都得基于有 cache 的情况,生产环境。
+
+## 2015-5-28
+
+- / = 36ms
+- /topics = 45ms
+- /topics/19436 = 67ms
+- /wiki = 26ms
+- /notifications = 39ms
+- /huacnlee = 100ms
+- /huacnlee/topics = 95ms
+- /huacnlee/favorites = 50ms
+- /huacnlee/followers = 85ms
+- /wiki/about = 37ms
+- /jobs = 68ms
+- /topics/new = 84ms
+- /sites = 25ms
+- /account/edit = 73ms
\ No newline at end of file
diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md
new file mode 100644
index 000000000..b5ffaef3d
--- /dev/null
+++ b/CONTRIBUTE.md
@@ -0,0 +1,75 @@
+Contribute Guide
+----------------
+
+## Requirements
+
+* Ruby 2.4.0 +
+* PostgreSQL 9.4 +
+* Redis 2.8 +
+* Memcached 1.4 +
+* Elasticsearch 2.0 +
+
+## Install in development
+
+### Mac OS X, use Homebrew
+
+```bash
+$ brew install memcached redis postgresql imagemagick gs elasticsearch
+```
+
+### Ubuntu
+
+```bash
+$ sudo apt-get install memcached postgresql postgresql-contrib redis-server imagemagick ghostscript
+```
+
+Install Elasticsearch
+
+```bash
+curl -sSL https://git.io/vVHhm | bash
+```
+
+```bash
+$ git clone https://github.com/ruby-china/homeland.git
+$ cd homeland
+$ ./bin/setup
+Checking Package Dependencies...
+--------------------------------------------------------------------------------
+Redis 2.0+ [Yes]
+Memcached 1.4+ [Yes]
+ImageMagick 6.5+ [Yes]
+--------------------------------------------------------------------------------
+
+Installing dependencies
+--------------------------------------------------------------------------------
+The Gemfile's dependencies are satisfied
+--------------------------------------------------------------------------------
+
+Configure
+--------------------------------------------------------------------------------
+Your Redis host (default: 127.0.0.1:6379):
+Your Elasticsearch host (default: 127.0.0.1:9200):
+--------------------------------------------------------------------------------
+
+Seed default data... [Done]
+
+== Removing old logs and tempfiles ==
+
+Homeland Successfully Installed.
+
+$ rails s
+```
+
+## Testing
+
+```bash
+bundle exec rake
+```
+
+## Reindex ElasticSearch
+
+```bash
+rails environment elasticsearch:import:model CLASS=Page FORCE=y
+rails environment elasticsearch:import:model CLASS=Topic FORCE=y
+rails environment elasticsearch:import:model CLASS=User FORCE=y
+```
diff --git a/Gemfile b/Gemfile
index cee7fb0d4..85fde8084 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,19 +1,148 @@
-source 'http://rubygems.org'
-
-gem 'rails', '3.0.0'
-gem 'mysql2'
-gem 'paperclip', :git => 'http://github.com/thoughtbot/paperclip.git'
-gem 'authlogic', :git => 'http://github.com/binarylogic/authlogic.git'
-gem 'will_paginate', '3.0.pre'
-gem 'memcache-client'
-gem 'omniauth', '0.2.0.beta3'
-gem "oa-openid", '0.2.0.beta3'
-# gem 'aws-s3', :require => 'aws/s3'
-gem 'sqlite3'
-
-# Bundle gems for the local environment. Make sure to
-# put test-only gems in this group so their generators
-# and rake tasks are available in development mode:
-# group :development, :test do
-# gem 'webrat'
-# end
+if ENV['TRAVIS']
+ source 'https://rubygems.org'
+else
+ source 'https://gems.ruby-china.org'
+end
+
+gem 'rails', '~> 5.1.0'
+gem 'sprockets'
+gem 'sass-rails'
+gem 'coffee-rails'
+gem 'uglifier'
+gem 'jquery-rails'
+gem 'jbuilder'
+gem 'turbolinks', '~> 5.0.0'
+gem 'dropzonejs-rails'
+gem 'rails_autolink'
+
+gem 'sanitize'
+
+gem 'pg'
+gem 'pghero'
+
+gem 'rack-attack'
+
+gem 'rails-i18n'
+gem 'http_accept_language'
+gem 'twemoji'
+gem 'jquery-atwho-rails'
+gem 'font-awesome-rails'
+
+# OAuth Provider
+gem 'doorkeeper'
+gem 'doorkeeper-i18n'
+
+gem 'bulk_insert'
+
+# 上传组件
+gem 'carrierwave'
+# Aliyun / Upyun 可选项
+gem 'carrierwave-upyun'
+gem 'carrierwave-aliyun'
+# Lazy load
+gem 'mini_magick', require: false
+
+# 验证码,头像
+gem 'rucaptcha'
+gem 'letter_avatar'
+
+# 用户系统
+gem 'devise'
+gem 'devise-encryptable'
+
+# 通知系统
+gem 'notifications'
+gem 'ruby-push-notifications'
+
+# 赞、关注、收藏、屏蔽等功能的数据结构
+gem 'action-store'
+
+# 分页
+gem 'kaminari'
+
+# 搜索
+gem 'elasticsearch-model'
+gem 'elasticsearch-rails'
+
+# 三方平台 OAuth 验证登陆
+gem 'omniauth'
+gem 'omniauth-github'
+
+# Permission
+gem 'cancancan'
+
+# Redis
+gem 'redis'
+gem 'hiredis'
+gem 'redis-namespace'
+gem 'redis-objects'
+
+# Cache
+gem 'second_level_cache'
+
+# Setting
+gem 'rails-settings-cached'
+
+# HTML Pipeline
+gem 'html-pipeline'
+gem 'html-pipeline-rouge_filter'
+gem 'redcarpet'
+gem 'auto-space'
+
+# 队列
+gem 'sidekiq'
+
+# 分享功能
+gem 'social-share-button'
+
+# 表单
+gem 'simple_form'
+
+# Mailer Service
+gem 'postmark'
+gem 'postmark-rails'
+
+# Dalli, kgio is for Dalli
+gem 'kgio'
+gem 'dalli'
+
+gem 'puma'
+
+# API cors
+gem 'rack-cors', require: 'rack/cors'
+gem 'rack-utf8_sanitizer'
+
+gem 'exception-track'
+gem 'status-page'
+
+gem 'bundler-audit', require: false
+
+# Homeland Plugins
+gem 'homeland-press'
+gem 'homeland-jobs'
+gem 'homeland-wiki'
+gem 'homeland-note'
+gem 'homeland-site'
+
+gem 'sdoc', '1.0.0.rc1'
+
+group :development do
+ gem 'derailed'
+ # Better Errors
+ gem 'better_errors'
+ gem 'spring'
+ gem 'spring-commands-rspec'
+end
+
+group :development, :test do
+ gem 'listen'
+ gem 'rubocop', '0.47.1', require: false
+ gem 'rspec-rails'
+ gem 'factory_girl_rails'
+ gem 'database_cleaner'
+ gem 'capybara'
+ gem 'letter_opener'
+ gem 'yard'
+
+ gem 'codecov', require: false
+end
diff --git a/Gemfile.lock b/Gemfile.lock
index f8d1a3714..3450d78ea 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,155 +1,516 @@
-GIT
- remote: http://github.com/binarylogic/authlogic.git
- revision: 6e2745b
- specs:
- authlogic (2.1.6)
- activesupport
-
-GIT
- remote: http://github.com/thoughtbot/paperclip.git
- revision: 9e6afe4
- specs:
- paperclip (2.3.3)
- activerecord
- activesupport
-
GEM
- remote: http://rubygems.org/
+ remote: https://gems.ruby-china.org/
specs:
- abstract (1.0.0)
- actionmailer (3.0.0)
- actionpack (= 3.0.0)
- mail (~> 2.2.5)
- actionpack (3.0.0)
- activemodel (= 3.0.0)
- activesupport (= 3.0.0)
- builder (~> 2.1.2)
- erubis (~> 2.6.6)
- i18n (~> 0.4.1)
- rack (~> 1.2.1)
- rack-mount (~> 0.6.12)
- rack-test (~> 0.5.4)
- tzinfo (~> 0.3.23)
- activemodel (3.0.0)
- activesupport (= 3.0.0)
- builder (~> 2.1.2)
- i18n (~> 0.4.1)
- activerecord (3.0.0)
- activemodel (= 3.0.0)
- activesupport (= 3.0.0)
- arel (~> 1.0.0)
- tzinfo (~> 0.3.23)
- activeresource (3.0.0)
- activemodel (= 3.0.0)
- activesupport (= 3.0.0)
- activesupport (3.0.0)
- addressable (2.2.4)
- arel (1.0.1)
- activesupport (~> 3.0.0)
- builder (2.1.2)
- erubis (2.6.6)
- abstract (>= 1.0.0)
- faraday (0.5.5)
- addressable (~> 2.2.4)
- multipart-post (~> 1.1.0)
- rack (>= 1.1.0, < 2)
- i18n (0.4.1)
- mail (2.2.5)
- activesupport (>= 2.3.6)
- mime-types
- treetop (>= 1.4.5)
- memcache-client (1.8.5)
- mime-types (1.16)
- multi_json (0.0.5)
- multipart-post (1.1.0)
- mysql2 (0.2.3)
- net-ldap (0.1.1)
- nokogiri (1.4.4)
- oa-basic (0.2.0.beta3)
- multi_json (~> 0.0.2)
- nokogiri (~> 1.4.2)
- oa-core (= 0.2.0.beta3)
- rest-client (~> 1.6.0)
- oa-core (0.2.0.beta3)
- rack (~> 1.1)
- oa-enterprise (0.2.0.beta3)
- net-ldap (~> 0.1.1)
- nokogiri (~> 1.4.2)
- oa-core (= 0.2.0.beta3)
- pyu-ruby-sasl (~> 0.0.3.1)
- rubyntlm (~> 0.1.1)
- oa-more (0.2.0.beta3)
- multi_json (~> 0.0.2)
- oa-core (= 0.2.0.beta3)
- rest-client (~> 1.6.0)
- oa-oauth (0.2.0.beta3)
- multi_json (~> 0.0.2)
- nokogiri (~> 1.4.2)
- oa-core (= 0.2.0.beta3)
- oauth (~> 0.4.0)
- oauth2 (~> 0.1.1)
- oa-openid (0.2.0.beta3)
- oa-core (= 0.2.0.beta3)
- rack-openid (~> 1.2.0)
- ruby-openid-apps-discovery
- oauth (0.4.4)
- oauth2 (0.1.1)
- faraday (~> 0.5.0)
- multi_json (~> 0.0.4)
- omniauth (0.2.0.beta3)
- oa-basic (= 0.2.0.beta3)
- oa-core (= 0.2.0.beta3)
- oa-enterprise (= 0.2.0.beta3)
- oa-more (= 0.2.0.beta3)
- oa-oauth (= 0.2.0.beta3)
- oa-openid (= 0.2.0.beta3)
- polyglot (0.3.1)
- pyu-ruby-sasl (0.0.3.2)
- rack (1.2.1)
- rack-mount (0.6.12)
+ action-store (0.3.2)
+ rails (~> 5, >= 4.2.0)
+ actioncable (5.1.3)
+ actionpack (= 5.1.3)
+ nio4r (~> 2.0)
+ websocket-driver (~> 0.6.1)
+ actionmailer (5.1.3)
+ actionpack (= 5.1.3)
+ actionview (= 5.1.3)
+ activejob (= 5.1.3)
+ mail (~> 2.5, >= 2.5.4)
+ rails-dom-testing (~> 2.0)
+ actionpack (5.1.3)
+ actionview (= 5.1.3)
+ activesupport (= 5.1.3)
+ rack (~> 2.0)
+ rack-test (~> 0.6.3)
+ rails-dom-testing (~> 2.0)
+ rails-html-sanitizer (~> 1.0, >= 1.0.2)
+ actionview (5.1.3)
+ activesupport (= 5.1.3)
+ builder (~> 3.1)
+ erubi (~> 1.4)
+ rails-dom-testing (~> 2.0)
+ rails-html-sanitizer (~> 1.0, >= 1.0.3)
+ activejob (5.1.3)
+ activesupport (= 5.1.3)
+ globalid (>= 0.3.6)
+ activemodel (5.1.3)
+ activesupport (= 5.1.3)
+ activerecord (5.1.3)
+ activemodel (= 5.1.3)
+ activesupport (= 5.1.3)
+ arel (~> 8.0)
+ activesupport (5.1.3)
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ i18n (~> 0.7)
+ minitest (~> 5.1)
+ tzinfo (~> 1.1)
+ addressable (2.5.2)
+ public_suffix (>= 2.0.2, < 4.0)
+ aliyun-oss-sdk (0.1.8)
+ addressable
+ gyoku
+ httparty
+ arel (8.0.0)
+ ast (2.3.0)
+ auto-space (0.0.4)
+ activesupport (> 3.0.0)
+ bcrypt (3.1.11)
+ benchmark-ips (2.7.2)
+ better_errors (2.3.0)
+ coderay (>= 1.0.0)
+ erubi (>= 1.0.0)
+ rack (>= 0.9.0)
+ builder (3.2.3)
+ bulk_insert (1.5.0)
+ activerecord (>= 4.1.0)
+ bundler-audit (0.6.0)
+ bundler (~> 1.2)
+ thor (~> 0.18)
+ cancancan (2.0.0)
+ capybara (2.15.1)
+ addressable
+ mini_mime (>= 0.1.3)
+ nokogiri (>= 1.3.3)
rack (>= 1.0.0)
- rack-openid (1.2.0)
- rack (>= 1.1.0)
- ruby-openid (>= 2.1.8)
- rack-test (0.5.4)
- rack (>= 1.0)
- rails (3.0.0)
- actionmailer (= 3.0.0)
- actionpack (= 3.0.0)
- activerecord (= 3.0.0)
- activeresource (= 3.0.0)
- activesupport (= 3.0.0)
- bundler (~> 1.0.0)
- railties (= 3.0.0)
- railties (3.0.0)
- actionpack (= 3.0.0)
- activesupport (= 3.0.0)
- rake (>= 0.8.4)
- thor (~> 0.14.0)
- rake (0.8.7)
- rest-client (1.6.1)
+ rack-test (>= 0.5.4)
+ xpath (~> 2.0)
+ carrierwave (1.1.0)
+ activemodel (>= 4.0.0)
+ activesupport (>= 4.0.0)
mime-types (>= 1.16)
- ruby-openid (2.1.8)
- ruby-openid-apps-discovery (1.2.0)
- ruby-openid (>= 2.1.7)
- rubyntlm (0.1.1)
- sqlite3 (1.3.3)
- thor (0.14.0)
- treetop (1.4.8)
- polyglot (>= 0.3.1)
- tzinfo (0.3.23)
- will_paginate (3.0.pre)
+ carrierwave-aliyun (0.8.1)
+ aliyun-oss-sdk (>= 0.1.6)
+ carrierwave (>= 0.5.7)
+ carrierwave-upyun (0.2.2)
+ carrierwave (>= 0.5.7)
+ faraday (>= 0.8.0)
+ codecov (0.1.10)
+ json
+ simplecov
+ url
+ coderay (1.1.1)
+ coffee-rails (4.2.2)
+ coffee-script (>= 2.2.0)
+ railties (>= 4.0.0)
+ coffee-script (2.4.1)
+ coffee-script-source
+ execjs
+ coffee-script-source (1.12.2)
+ concurrent-ruby (1.0.5)
+ connection_pool (2.2.1)
+ crass (1.0.2)
+ dalli (2.7.6)
+ database_cleaner (1.6.1)
+ derailed (0.1.0)
+ derailed_benchmarks
+ derailed_benchmarks (1.3.2)
+ benchmark-ips (~> 2)
+ get_process_mem (~> 0)
+ heapy (~> 0)
+ memory_profiler (~> 0)
+ rack (>= 1)
+ rake (> 10, < 13)
+ thor (~> 0.19)
+ devise (4.3.0)
+ bcrypt (~> 3.0)
+ orm_adapter (~> 0.1)
+ railties (>= 4.1.0, < 5.2)
+ responders
+ warden (~> 1.2.3)
+ devise-encryptable (0.2.0)
+ devise (>= 2.1.0)
+ diff-lcs (1.3)
+ docile (1.1.5)
+ doorkeeper (4.2.6)
+ railties (>= 4.2)
+ doorkeeper-i18n (4.0.0)
+ dropzonejs-rails (0.8.1)
+ rails (> 3.1)
+ elasticsearch (5.0.4)
+ elasticsearch-api (= 5.0.4)
+ elasticsearch-transport (= 5.0.4)
+ elasticsearch-api (5.0.4)
+ multi_json
+ elasticsearch-model (5.0.1)
+ activesupport (> 3)
+ elasticsearch (~> 5)
+ hashie
+ elasticsearch-rails (5.0.1)
+ elasticsearch-transport (5.0.4)
+ faraday
+ multi_json
+ erubi (1.6.1)
+ exception-track (0.2.0)
+ exception_notification (~> 4)
+ kaminari (>= 0.15)
+ rails (>= 4.0, < 5.2)
+ exception_notification (4.2.2)
+ actionmailer (>= 4.0, < 6)
+ activesupport (>= 4.0, < 6)
+ execjs (2.7.0)
+ factory_girl (4.8.0)
+ activesupport (>= 3.0.0)
+ factory_girl_rails (4.8.0)
+ factory_girl (~> 4.8.0)
+ railties (>= 3.0.0)
+ faraday (0.12.2)
+ multipart-post (>= 1.2, < 3)
+ ffi (1.9.18)
+ font-awesome-rails (4.7.0.2)
+ railties (>= 3.2, < 5.2)
+ get_process_mem (0.2.1)
+ globalid (0.4.0)
+ activesupport (>= 4.2.0)
+ gyoku (1.3.1)
+ builder (>= 2.1.2)
+ hashie (3.5.6)
+ heapy (0.1.2)
+ hiredis (0.6.1)
+ homeland-jobs (0.1.1)
+ rails (~> 5)
+ homeland-note (0.1.3)
+ rails (~> 5)
+ homeland-press (0.3.4)
+ rails (~> 5)
+ homeland-site (0.1.2)
+ rails (~> 5)
+ homeland-wiki (0.3.0)
+ rails (~> 5)
+ html-pipeline (2.7.0)
+ activesupport (>= 2)
+ nokogiri (>= 1.4)
+ html-pipeline-rouge_filter (1.0.5)
+ activesupport
+ html-pipeline (>= 1.11)
+ rouge (~> 2.0.7)
+ http_accept_language (2.1.1)
+ httparty (0.15.6)
+ multi_xml (>= 0.5.2)
+ i18n (0.8.6)
+ jbuilder (2.7.0)
+ activesupport (>= 4.2.0)
+ multi_json (>= 1.2)
+ jquery-atwho-rails (1.3.2)
+ jquery-rails (4.3.1)
+ rails-dom-testing (>= 1, < 3)
+ railties (>= 4.2.0)
+ thor (>= 0.14, < 2.0)
+ json (2.1.0)
+ jwt (1.5.6)
+ kaminari (1.0.1)
+ activesupport (>= 4.1.0)
+ kaminari-actionview (= 1.0.1)
+ kaminari-activerecord (= 1.0.1)
+ kaminari-core (= 1.0.1)
+ kaminari-actionview (1.0.1)
+ actionview
+ kaminari-core (= 1.0.1)
+ kaminari-activerecord (1.0.1)
+ activerecord
+ kaminari-core (= 1.0.1)
+ kaminari-core (1.0.1)
+ kgio (2.11.0)
+ launchy (2.4.3)
+ addressable (~> 2.3)
+ letter_avatar (0.3.6)
+ letter_opener (1.4.1)
+ launchy (~> 2.2)
+ listen (3.1.5)
+ rb-fsevent (~> 0.9, >= 0.9.4)
+ rb-inotify (~> 0.9, >= 0.9.7)
+ ruby_dep (~> 1.2)
+ loofah (2.0.3)
+ nokogiri (>= 1.5.9)
+ mail (2.6.6)
+ mime-types (>= 1.16, < 4)
+ memory_profiler (0.9.8)
+ method_source (0.8.2)
+ mime-types (3.1)
+ mime-types-data (~> 3.2015)
+ mime-types-data (3.2016.0521)
+ mini_magick (4.8.0)
+ mini_mime (0.1.4)
+ mini_portile2 (2.2.0)
+ minitest (5.10.3)
+ multi_json (1.12.1)
+ multi_xml (0.6.0)
+ multipart-post (2.0.0)
+ nio4r (2.1.0)
+ nokogiri (1.8.0)
+ mini_portile2 (~> 2.2.0)
+ nokogumbo (1.4.13)
+ nokogiri
+ notifications (0.4.3)
+ kaminari (>= 0.15)
+ rails (>= 4.2.0)
+ oauth2 (1.4.0)
+ faraday (>= 0.8, < 0.13)
+ jwt (~> 1.0)
+ multi_json (~> 1.3)
+ multi_xml (~> 0.5)
+ rack (>= 1.2, < 3)
+ omniauth (1.6.1)
+ hashie (>= 3.4.6, < 3.6.0)
+ rack (>= 1.6.2, < 3)
+ omniauth-github (1.3.0)
+ omniauth (~> 1.5)
+ omniauth-oauth2 (>= 1.4.0, < 2.0)
+ omniauth-oauth2 (1.4.0)
+ oauth2 (~> 1.0)
+ omniauth (~> 1.2)
+ orm_adapter (0.5.0)
+ parser (2.4.0.0)
+ ast (~> 2.2)
+ pg (0.21.0)
+ pghero (2.0.3)
+ activerecord
+ postmark (1.10.0)
+ json
+ rake
+ postmark-rails (0.15.0)
+ actionmailer (>= 3.0.0)
+ postmark (~> 1.10.0)
+ powerpack (0.1.1)
+ public_suffix (3.0.0)
+ puma (3.10.0)
+ rack (2.0.3)
+ rack-attack (5.0.1)
+ rack
+ rack-cors (1.0.1)
+ rack-protection (2.0.0)
+ rack
+ rack-test (0.6.3)
+ rack (>= 1.0)
+ rack-utf8_sanitizer (1.3.2)
+ rack (>= 1.0, < 3.0)
+ rails (5.1.3)
+ actioncable (= 5.1.3)
+ actionmailer (= 5.1.3)
+ actionpack (= 5.1.3)
+ actionview (= 5.1.3)
+ activejob (= 5.1.3)
+ activemodel (= 5.1.3)
+ activerecord (= 5.1.3)
+ activesupport (= 5.1.3)
+ bundler (>= 1.3.0)
+ railties (= 5.1.3)
+ sprockets-rails (>= 2.0.0)
+ rails-dom-testing (2.0.3)
+ activesupport (>= 4.2.0)
+ nokogiri (>= 1.6)
+ rails-html-sanitizer (1.0.3)
+ loofah (~> 2.0)
+ rails-i18n (5.0.4)
+ i18n (~> 0.7)
+ railties (~> 5.0)
+ rails-settings-cached (0.6.6)
+ rails (>= 4.2.0)
+ rails_autolink (1.1.6)
+ rails (> 3.1)
+ railties (5.1.3)
+ actionpack (= 5.1.3)
+ activesupport (= 5.1.3)
+ method_source
+ rake (>= 0.8.7)
+ thor (>= 0.18.1, < 2.0)
+ rainbow (2.2.2)
+ rake
+ rake (12.0.0)
+ rb-fsevent (0.10.2)
+ rb-inotify (0.9.10)
+ ffi (>= 0.5.0, < 2)
+ rdoc (5.0.0)
+ redcarpet (3.4.0)
+ redis (3.3.3)
+ redis-namespace (1.5.3)
+ redis (~> 3.0, >= 3.0.4)
+ redis-objects (1.3.0)
+ redis (~> 3.3)
+ responders (2.4.0)
+ actionpack (>= 4.2.0, < 5.3)
+ railties (>= 4.2.0, < 5.3)
+ rouge (2.0.7)
+ rspec-core (3.6.0)
+ rspec-support (~> 3.6.0)
+ rspec-expectations (3.6.0)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.6.0)
+ rspec-mocks (3.6.0)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.6.0)
+ rspec-rails (3.6.1)
+ actionpack (>= 3.0)
+ activesupport (>= 3.0)
+ railties (>= 3.0)
+ rspec-core (~> 3.6.0)
+ rspec-expectations (~> 3.6.0)
+ rspec-mocks (~> 3.6.0)
+ rspec-support (~> 3.6.0)
+ rspec-support (3.6.0)
+ rubocop (0.47.1)
+ parser (>= 2.3.3.1, < 3.0)
+ powerpack (~> 0.1)
+ rainbow (>= 1.99.1, < 3.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (~> 1.0, >= 1.0.1)
+ ruby-progressbar (1.8.1)
+ ruby-push-notifications (1.1.0)
+ builder (~> 3.0)
+ ruby_dep (1.5.0)
+ rucaptcha (2.1.3)
+ railties (>= 3.2)
+ sanitize (4.5.0)
+ crass (~> 1.0.2)
+ nokogiri (>= 1.4.4)
+ nokogumbo (~> 1.4.1)
+ sass (3.5.1)
+ sass-listen (~> 4.0.0)
+ sass-listen (4.0.0)
+ rb-fsevent (~> 0.9, >= 0.9.4)
+ rb-inotify (~> 0.9, >= 0.9.7)
+ sass-rails (5.0.6)
+ railties (>= 4.0.0, < 6)
+ sass (~> 3.1)
+ sprockets (>= 2.8, < 4.0)
+ sprockets-rails (>= 2.0, < 4.0)
+ tilt (>= 1.1, < 3)
+ sdoc (1.0.0.rc1)
+ rdoc (= 5.0.0)
+ second_level_cache (2.3.3)
+ activerecord (>= 5.0.0, < 5.2)
+ activesupport (>= 5.0.0, < 5.2)
+ sidekiq (5.0.4)
+ concurrent-ruby (~> 1.0)
+ connection_pool (~> 2.2, >= 2.2.0)
+ rack-protection (>= 1.5.0)
+ redis (~> 3.3, >= 3.3.3)
+ simple_form (3.5.0)
+ actionpack (> 4, < 5.2)
+ activemodel (> 4, < 5.2)
+ simplecov (0.15.0)
+ docile (~> 1.1.0)
+ json (>= 1.8, < 3)
+ simplecov-html (~> 0.10.0)
+ simplecov-html (0.10.2)
+ social-share-button (0.10.0)
+ coffee-rails
+ spring (2.0.2)
+ activesupport (>= 4.2)
+ spring-commands-rspec (1.0.4)
+ spring (>= 0.9.1)
+ sprockets (3.7.1)
+ concurrent-ruby (~> 1.0)
+ rack (> 1, < 3)
+ sprockets-rails (3.2.0)
+ actionpack (>= 4.0)
+ activesupport (>= 4.0)
+ sprockets (>= 3.0.0)
+ status-page (0.1.4)
+ rails (>= 4.2)
+ thor (0.20.0)
+ thread_safe (0.3.6)
+ tilt (2.0.8)
+ turbolinks (5.0.1)
+ turbolinks-source (~> 5)
+ turbolinks-source (5.0.3)
+ twemoji (3.1.4)
+ nokogiri (~> 1.6)
+ tzinfo (1.2.3)
+ thread_safe (~> 0.1)
+ uglifier (3.2.0)
+ execjs (>= 0.3.0, < 3)
+ unicode-display_width (1.3.0)
+ url (0.3.2)
+ warden (1.2.7)
+ rack (>= 1.0)
+ websocket-driver (0.6.5)
+ websocket-extensions (>= 0.1.0)
+ websocket-extensions (0.1.2)
+ xpath (2.1.0)
+ nokogiri (~> 1.3)
+ yard (0.9.9)
PLATFORMS
ruby
DEPENDENCIES
- authlogic!
- memcache-client
- mysql2
- oa-openid (= 0.2.0.beta3)
- omniauth (= 0.2.0.beta3)
- paperclip!
- rails (= 3.0.0)
- sqlite3
- will_paginate (= 3.0.pre)
+ action-store
+ auto-space
+ better_errors
+ bulk_insert
+ bundler-audit
+ cancancan
+ capybara
+ carrierwave
+ carrierwave-aliyun
+ carrierwave-upyun
+ codecov
+ coffee-rails
+ dalli
+ database_cleaner
+ derailed
+ devise
+ devise-encryptable
+ doorkeeper
+ doorkeeper-i18n
+ dropzonejs-rails
+ elasticsearch-model
+ elasticsearch-rails
+ exception-track
+ factory_girl_rails
+ font-awesome-rails
+ hiredis
+ homeland-jobs
+ homeland-note
+ homeland-press
+ homeland-site
+ homeland-wiki
+ html-pipeline
+ html-pipeline-rouge_filter
+ http_accept_language
+ jbuilder
+ jquery-atwho-rails
+ jquery-rails
+ kaminari
+ kgio
+ letter_avatar
+ letter_opener
+ listen
+ mini_magick
+ notifications
+ omniauth
+ omniauth-github
+ pg
+ pghero
+ postmark
+ postmark-rails
+ puma
+ rack-attack
+ rack-cors
+ rack-utf8_sanitizer
+ rails (~> 5.1.0)
+ rails-i18n
+ rails-settings-cached
+ rails_autolink
+ redcarpet
+ redis
+ redis-namespace
+ redis-objects
+ rspec-rails
+ rubocop (= 0.47.1)
+ ruby-push-notifications
+ rucaptcha
+ sanitize
+ sass-rails
+ sdoc (= 1.0.0.rc1)
+ second_level_cache
+ sidekiq
+ simple_form
+ social-share-button
+ spring
+ spring-commands-rspec
+ sprockets
+ status-page
+ turbolinks (~> 5.0.0)
+ twemoji
+ uglifier
+ yard
+
+BUNDLED WITH
+ 1.14.6
diff --git a/PLUGIN_DEV.md b/PLUGIN_DEV.md
new file mode 100644
index 000000000..f2da8dcb0
--- /dev/null
+++ b/PLUGIN_DEV.md
@@ -0,0 +1,92 @@
+如何编写 Homeland 的插件
+----------------------
+
+Homeland 的插件,基于 [Rails Engine](http://guides.rubyonrails.org/engines.html)。
+
+## Get started
+
+新建一个 Rails Engine:
+
+你可以使用 `rails plugin new homeland- --mountable` 的方式来创建一个插件。
+
+```bash
+rails plugin new homeland-foo --mountable
+```
+
+将会生成 Plugin 的项目目录。
+
+## 注册 Homeland Plugin
+
+打开 lib/homeland/foo/engine.rb
+
+```rb
+module Homeland
+ module Foo
+ # 关于 Engine 的细节,可以阅读 Rails Guides - Engines 部分的内容
+ # http://guides.rubyonrails.org/engines.html
+ class Engine < ::Rails::Engine
+ isolate_namespace Homeland::Foo
+
+ initializer 'homeland.foo.init' do |app|
+ # 确定应用 config.modules 启用了 foo,才开启
+ next unless Setting.has_module?(:foo)
+ # 注册 Homeland Plugin
+ Homeland.register_plugin do |plugin|
+ # 插件名称,应用 Ruby 的变量命名风格,例如 foo_bar
+ plugin.name = 'foo'
+ # 插件的名称用于显示
+ plugin.display_name = '测试插件'
+ # 版本号
+ plugin.version = Homeland::Foo::VERSION
+ plugin.description = '..'
+ # 是否在主导航栏显示链接
+ plugin.navbar_link = true
+ # 是否在用户菜单显示链接
+ plugin.user_menu_link = true
+ # 是否在管理界面的导航显示链接,需要额外配置 plugin.admin_path
+ plugin.admin_navbar_link = true
+ # 应用的根路径,用于生成链接
+ plugin.root_path = "/foos"
+ # 应用的管理后台路径
+ plugin.admin_path = "/admin/foos"
+ end
+
+ app.routes.prepend do
+ mount Homeland::Foo::Engine => '/'
+ end
+
+ # 让 Homeland Migration 的时候,包含插件的 Migration
+ app.config.paths["db/migrate"].concat(config.paths["db/migrate"].expanded)
+ end
+ end
+ end
+end
+```
+
+## 如何测试 Plugin
+
+你需要准备好 Homeland 源代码的开发环境,并将自己的插件加入到 Homeland 项目的 Gemfile 里面:
+
+例如
+
+```
+# Homeland 主项目源代码
+~/work/homeland
+# 插件源代码
+~/work/homeland-foo
+```
+
+修改 homeland/Gemfile
+
+```rb
+# 引用上层路径的 Plugin 目录
+gem 'homeland-foo', path: '../homeland-foo'
+```
+
+然后启动 Homeland,打开相应的插件目录验证。
+
+暂时没有自动化测试的方案。
+
+## 参考实现
+
+https://github.com/ruby-china/homeland-press
diff --git a/README.md b/README.md
new file mode 100644
index 000000000..ac25ed4ab
--- /dev/null
+++ b/README.md
@@ -0,0 +1,48 @@
+Homeland
+--------
+
+![](https://gethomeland.com/images/text-logo.svg)
+
+Open source discussion website.
+
+开源的论坛/社区网站系统,基于 [Ruby China](https://ruby-china.org) 发展而来。
+
+[![Build Status](https://travis-ci.org/ruby-china/homeland.svg?branch=master)](https://travis-ci.org/ruby-china/homeland) [![codecov.io](https://codecov.io/github/ruby-china/homeland/coverage.svg?branch=master)](https://codecov.io/github/ruby-china/homeland?branch=master)
+
+## Deployment
+
+Please visit https://gethomeland.com get more documents.
+
+## Release Notes
+
+Please visit [Releases](https://github.com/ruby-china/homeland/releases) page.
+
+## Contribute Guide
+
+Please read this document: [CONTRIBUTE GUIDE](https://github.com/ruby-china/homeland/blob/master/CONTRIBUTE.md)
+
+## Thanks
+
+* [Contributors](https://github.com/ruby-china/homeland/contributors)
+* [Twitter Bootstrap](https://twitter.github.com/bootstrap)
+* [Font Awesome](http://fortawesome.github.io/Font-Awesome/icons/)
+* Forked from [Homeland Project](https://github.com/huacnlee/homeland)
+* Theme from [Mediom](https://github.com/huacnlee/mediom)
+
+## Sites used Homeland
+
+https://gethomeland.com/expo
+
+## License
+
+Copyright (c) 2011-2017 Ruby China
+
+Released under the MIT license:
+
+* [www.opensource.org/licenses/MIT](http://www.opensource.org/licenses/MIT)
+
+Emojis under the CC-BY 4.0 license from [Twitter/Twemoji][twemoji]:
+
+* https://github.com/twitter/twemoji#license
+
+[twemoji]: https://github.com/twitter/twemoji
diff --git a/Rakefile b/Rakefile
old mode 100644
new mode 100755
index 4a761d727..02afa2458
--- a/Rakefile
+++ b/Rakefile
@@ -1,7 +1,34 @@
+#!/usr/bin/env rake
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
-require File.expand_path('../config/application', __FILE__)
-require 'rake'
+require File.expand_path("../config/application", __FILE__)
+require "elasticsearch/rails/tasks/import"
-Homeland::Application.load_tasks
+Rails.application.load_tasks
+
+task default: "bundle:audit"
+
+require "sdoc"
+require "rdoc/task"
+
+rdoc_files = %w(
+ README.md
+ PLUGIN_DEV.md
+ CONTRIBUTE.md
+ API.md
+ app/models
+ app/controllers
+ app/helpers
+ lib/homeland.rb
+ lib/single_sign_on.rb
+ lib/homeland
+)
+
+RDoc::Task.new do |rdoc|
+ rdoc.rdoc_dir = "public/doc"
+ rdoc.generator = "sdoc"
+ rdoc.template = "rails"
+ rdoc.main = "README.md"
+ rdoc.rdoc_files.include(*rdoc_files)
+end
diff --git a/Vagrantfile b/Vagrantfile
new file mode 100644
index 000000000..8f5b2022e
--- /dev/null
+++ b/Vagrantfile
@@ -0,0 +1,15 @@
+# -*- mode: ruby -*-
+# vi: set ft=ruby :
+
+Vagrant.configure(2) do |config|
+ config.vm.box = "ubuntu/trusty64"
+ config.vm.hostname = "homeland-dev"
+ # For Android/iOS app dev
+ config.vm.network "public_network"
+ config.vm.provision "shell", path: "bin/provision.sh", privileged: false
+
+ config.vm.provider "virtualbox" do |vb|
+ vb.name = "homeland-dev"
+ vb.memory = "2048"
+ end
+end
diff --git a/app/assets/images/favicon.png b/app/assets/images/favicon.png
new file mode 100644
index 000000000..a269934c4
Binary files /dev/null and b/app/assets/images/favicon.png differ
diff --git a/app/assets/javascripts/app.coffee b/app/assets/javascripts/app.coffee
new file mode 100644
index 000000000..0960c28c5
--- /dev/null
+++ b/app/assets/javascripts/app.coffee
@@ -0,0 +1,419 @@
+#= require jquery2
+#= require jquery_ujs
+#= require bootstrap.min
+#= require jquery.mobile-events
+#= require underscore
+#= require backbone
+#= require pagination
+#= require jquery.timeago
+#= require jquery.timeago.settings
+#= require jquery.hotkeys
+#= require jquery.autogrow-textarea
+#= require tooltipster.bundle.min
+#= require dropzone
+#= require jquery.fluidbox.min
+#= require social-share-button
+#= require social-share-button/wechat
+#= require jquery.atwho
+#= require emoji-data
+#= require emoji-modal
+#= require notifier
+#= require action_cable
+#= require form_storage
+#= require topics
+#= require editor
+#= require toc
+#= require turbolinks
+#= require google_analytics
+#= require jquery.infinitescroll.min
+#= require d3.min
+#= require cal-heatmap.min
+#= require_self
+
+AppView = Backbone.View.extend
+ el: "body"
+ repliesPerPage: 50
+ windowInActive: true
+
+ events:
+ "click a.likeable": "likeable"
+ "click .header .form-search .btn-search": "openHeaderSearchBox"
+ "click .header .form-search .btn-close": "closeHeaderSearchBox"
+ "click a.button-block-user": "blockUser"
+ "click a.button-follow-user": "followUser"
+ "click a.button-block-node": "blockNode"
+ "click a.rucaptcha-image-box": "reLoadRucaptchaImage"
+
+ initialize: ->
+ FormStorage.restore()
+ @initForDesktopView()
+ @initComponents()
+ @initScrollEvent()
+ @initInfiniteScroll()
+ @initCable()
+ @restoreHeaderSearchBox()
+
+ if $('body').data('controller-name') in ['topics', 'replies']
+ window._topicView = new TopicView({parentView: @})
+
+ window._tocView = new TOCView({parentView: @})
+
+ initComponents: () ->
+ $("abbr.timeago").timeago()
+ $(".alert").alert()
+ $('.dropdown-toggle').dropdown()
+ $('[data-toggle="tooltip"]').tooltip()
+
+ # 绑定评论框 Ctrl+Enter 提交事件
+ $(".cell_comments_new textarea").unbind "keydown"
+ $(".cell_comments_new textarea").bind "keydown", "ctrl+return", (el) ->
+ if $(el.target).val().trim().length > 0
+ $(el.target).parent().parent().submit()
+ return false
+
+ $(window).off "blur.inactive focus.inactive"
+ $(window).on "blur.inactive focus.inactive", @updateWindowActiveState
+
+ # Likeable Popover
+ $('a.likeable[data-count!=0]').tooltipster
+ content: "Loading..."
+ theme: 'tooltipster-shadow'
+ side: 'bottom'
+ maxWidth: 230
+ interactive: true
+ contentAsHTML: true
+ triggerClose:
+ mouseleave: true
+ functionBefore: (instance, helper) ->
+ $target = $(helper.origin)
+ if $target.data('remote-loaded') is 1
+ return
+
+ likeable_type = $target.data("type")
+ likeable_id = $target.data("id")
+ data =
+ type: likeable_type
+ id: likeable_id
+ $.ajax
+ url: '/likes'
+ data: data
+ success: (html) ->
+ if html.length is 0
+ $target.data('remote-loaded', 1)
+ instance.hide()
+ instance.destroy()
+ else
+ instance.content(html)
+ $target.data('remote-loaded', 1)
+
+ initForDesktopView : () ->
+ return if App.mobile != false
+ $("a[rel=twipsy]").tooltip()
+
+ # CommentAble @ 回复功能
+ App.mentionable(".cell_comments_new textarea")
+
+ likeable : (e) ->
+ if !App.isLogined()
+ location.href = "/account/sign_in"
+ return false
+
+ $target = $(e.currentTarget)
+ likeable_type = $target.data("type")
+ likeable_id = $target.data("id")
+ likes_count = parseInt($target.data("count"))
+
+ $el = $(".likeable[data-type='#{likeable_type}'][data-id='#{likeable_id}']")
+
+ if $el.data("state") != "active"
+ $.ajax
+ url : "/likes"
+ type : "POST"
+ data :
+ type : likeable_type
+ id : likeable_id
+
+ likes_count += 1
+ $el.data('count', likes_count)
+ @likeableAsLiked($el)
+ else
+ $.ajax
+ url : "/likes/#{likeable_id}"
+ type : "DELETE"
+ data :
+ type : likeable_type
+ if likes_count > 0
+ likes_count -= 1
+ $el.data("state","").data('count', likes_count).attr("title", "").removeClass("active")
+ if likes_count == 0
+ $('span', $el).text("")
+ else
+ $('span', $el).text("#{likes_count} 个赞")
+ $el.data("remote-loaded", 0)
+ false
+
+ likeableAsLiked : (el) ->
+ likes_count = el.data("count")
+ el.data("state","active").attr("title", "取消赞").addClass("active")
+ $('span',el).text("#{likes_count} 个赞")
+
+ initCable: () ->
+ if !window.notificationChannel && App.isLogined()
+ window.notificationChannel = App.cable.subscriptions.create "NotificationsChannel",
+ connected: ->
+ @subscribe()
+
+ received: (data) =>
+ @receivedNotificationCount(data)
+
+ subscribe: ->
+ @perform 'subscribed'
+
+ receivedNotificationCount : (json) ->
+ # console.log 'receivedNotificationCount', json
+ span = $(".notification-count span")
+ link = $(".notification-count a")
+ new_title = document.title.replace(/^\(\d+\) /,'')
+ if json.count > 0
+ span.show()
+ new_title = "(#{json.count}) #{new_title}"
+ url = App.fixUrlDash("#{App.root_url}#{json.content_path}")
+ $.notifier.notify("",json.title,json.content,url)
+ link.addClass("new")
+ else
+ span.hide()
+ link.removeClass("new")
+ span.text(json.count)
+ document.title = new_title
+
+ restoreHeaderSearchBox: ->
+ $searchInput = $(".header .form-search input")
+
+ if location.pathname != "/search"
+ $searchInput.val("")
+ else
+ results = new RegExp('[\?&]q=([^]*)').exec(window.location.href)
+ q = results && decodeURIComponent(results[1])
+ $searchInput.val(q)
+
+ openHeaderSearchBox: (e) ->
+ $(".header .form-search").addClass("active")
+ $(".header .form-search input").focus()
+ return false
+
+ closeHeaderSearchBox: (e) ->
+ $(".header .form-search input").val("")
+ $(".header .form-search").removeClass("active")
+ return false
+
+ followUser: (e) ->
+ btn = $(e.currentTarget)
+ userId = btn.data("id")
+ span = btn.find("span")
+ followerCounter = $(".follow-info .followers[data-login=#{userId}] .counter")
+ if btn.hasClass("active")
+ $.ajax
+ url: "/#{userId}/unfollow"
+ type: "POST"
+ success: (res) ->
+ if res.code == 0
+ btn.removeClass('active')
+ span.text("关注")
+ followerCounter.text(res.data.followers_count)
+ else
+ $.ajax
+ url: "/#{userId}/follow"
+ type: 'POST'
+ success: (res) ->
+ if res.code == 0
+ btn.addClass('active').attr("title", "")
+ span.text("取消关注")
+ followerCounter.text(res.data.followers_count)
+ return false
+
+ blockUser: (e) ->
+ btn = $(e.currentTarget)
+ userId = btn.data("id")
+ span = btn.find("span")
+ if btn.hasClass("active")
+ $.post("/#{userId}/unblock")
+ btn.removeClass('active').attr("title", "忽略后,社区首页列表将不会显示此用户发布的内容。")
+ span.text("屏蔽")
+ else
+ $.post("/#{userId}/block")
+ btn.addClass('active').attr("title", "")
+ span.text("取消屏蔽")
+ return false
+
+ blockNode: (e) ->
+ btn = $(e.currentTarget)
+ nodeId = btn.data("id")
+ span = btn.find("span")
+ if btn.hasClass("active")
+ $.post("/nodes/#{nodeId}/unblock")
+ btn.removeClass('active').attr("title", "忽略后,社区首页列表将不会显示这里的内容。")
+ span.text("忽略节点")
+ else
+ $.post("/nodes/#{nodeId}/block")
+ btn.addClass('active').attr("title", "")
+ span.text("取消屏蔽")
+ return false
+
+ reLoadRucaptchaImage: (e) ->
+ btn = $(e.currentTarget)
+ img = btn.find('img:first')
+ currentSrc = img.attr('src')
+ img.attr('src', currentSrc.split('?')[0] + '?' + (new Date()).getTime())
+ return false
+
+ updateWindowActiveState: (e) ->
+ prevType = $(this).data("prevType")
+
+ if prevType != e.type
+ switch (e.type)
+ when "blur"
+ @windowInActive = false
+ when "focus"
+ @windowInActive = true
+
+ $(this).data("prevType", e.type)
+
+ initInfiniteScroll: ->
+ $('.infinite-scroll .item-list').infinitescroll
+ nextSelector: '.pagination .next a'
+ navSelector: '.pagination'
+ itemSelector: '.topic, .notification-group'
+ extraScrollPx: 200
+ bufferPx: 50
+ localMode: true
+ loading:
+ finishedMsg: '已到末尾
'
+ msgText: '载入中...
'
+ img: 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='
+
+ initScrollEvent: ->
+ $(window).off('scroll.navbar-fixed')
+ $(window).on('scroll.navbar-fixed', @toggleNavbarFixed)
+ @toggleNavbarFixed()
+
+ toggleNavbarFixed: (e) ->
+ top = $(window).scrollTop()
+ if top >= 50
+ $(".header .navbar").addClass('navbar-fixed-active')
+ else
+ $(".header .navbar").removeClass('navbar-fixed-active')
+
+ return if $(".navbar-topic-title").size() == 0
+ if top >= 50
+ $(".header .navbar").addClass('fixed-title')
+ else
+ $(".header .navbar").removeClass('fixed-title')
+
+
+window.App =
+ turbolinks: false
+ mobile: false
+ locale: 'zh-CN'
+ notifier : null
+ current_user_id: null
+ access_token : ''
+ asset_url : ''
+ twemoji_url: 'https://twemoji.maxcdn.com/'
+ root_url : ''
+ cable: ActionCable.createConsumer()
+
+ isLogined : ->
+ document.getElementsByName('current-user').length > 0
+
+ loading : () ->
+ console.log "loading..."
+
+ fixUrlDash : (url) ->
+ url.replace(/\/\//g,"/").replace(/:\//,"://")
+
+ # 警告信息显示, to 显示在那个dom前(可以用 css selector)
+ alert : (msg,to) ->
+ $(".alert").remove()
+ $(to).before("")
+
+ # 成功信息显示, to 显示在那个dom前(可以用 css selector)
+ notice : (msg,to) ->
+ $(".alert").remove()
+ $(to).before("")
+
+ openUrl : (url) ->
+ window.open(url)
+
+ # Use this method to redirect so that it can be stubbed in test
+ gotoUrl: (url) ->
+ Turbolinks.visit(url)
+
+ # scan logins in jQuery collection and returns as a object,
+ # which key is login, and value is the name.
+ scanMentionableLogins: (query) ->
+ result = []
+ logins = []
+ for e in query
+ $e = $(e)
+ item =
+ login: $e.find(".user-name").first().text()
+ name: $e.find(".user-name").first().attr('data-name')
+ avatar_url: $e.find(".avatar img").first().attr("src")
+
+ continue if not item.login
+ continue if not item.name
+ continue if logins.indexOf(item.login) != -1
+
+ logins.push(item.login)
+ result.push(item)
+
+ console.log result
+ _.uniq(result)
+
+ mentionable : (el, logins) ->
+ logins = [] if !logins
+ $(el).atwho
+ at : "@"
+ limit: 8
+ searchKey: 'login'
+ callbacks:
+ filter: (query, data, searchKey) ->
+ return data
+ sorter: (query, items, searchKey) ->
+ return items
+ remoteFilter: (query, callback) ->
+ r = new RegExp("^#{query}")
+ # 过滤出本地匹配的数据
+ localMatches = _.filter logins, (u) ->
+ return r.test(u.login) || r.test(u.name)
+ # Remote 匹配
+ $.getJSON '/search/users.json', { q: query }, (data) ->
+ # 本地的排前面
+ for u in localMatches
+ data.unshift(u)
+ # 去重复
+ data = _.uniq data, false, (item) ->
+ return item.login;
+ # 限制数量
+ data = _.first(data, 8)
+ callback(data)
+ displayTpl : " ${login} ${name} "
+ insertTpl : "@${login}"
+ .atwho
+ at : ":"
+ limit: 8
+ searchKey: 'code'
+ data : window.EMOJI_LIST
+ displayTpl : " ${code} "
+ insertTpl: "${code}"
+ true
+
+
+document.addEventListener 'turbolinks:load', ->
+ window._appView = new AppView()
+
+document.addEventListener 'turbolinks:click', (event) ->
+ if event.target.getAttribute('href').charAt(0) is '#'
+ event.preventDefault()
+
+FormStorage.init()
diff --git a/app/assets/javascripts/editor.coffee b/app/assets/javascripts/editor.coffee
new file mode 100644
index 000000000..722e0009b
--- /dev/null
+++ b/app/assets/javascripts/editor.coffee
@@ -0,0 +1,166 @@
+window.Editor = Backbone.View.extend
+ el: '.editor-toolbar'
+
+ events:
+ "click #editor-upload-image": "browseUpload"
+ "click .insert-codes a": "appendCodesFromHint"
+ "click .pickup-emoji": "pickupEmoji"
+
+ initialize: (opts) ->
+ @initComponents()
+ @initDropzone()
+
+ initDropzone: ->
+ self = @
+ editor = $("textarea.topic-editor")
+ editor.wrap "
"
+
+ editor_dropzone = $('.topic-editor-dropzone')
+ editor_dropzone.on 'paste', (event) =>
+ self.handlePaste(event)
+
+ dropzone = editor_dropzone.dropzone(
+ url: "/photos"
+ dictDefaultMessage: ""
+ clickable: true
+ paramName: "file"
+ maxFilesize: 20
+ uploadMultiple: false
+ headers:
+ "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content")
+ previewContainer: false
+ processing: ->
+ $(".div-dropzone-alert").alert "close"
+ self.showUploading()
+ dragover: ->
+ editor.addClass "div-dropzone-focus"
+ return
+ dragleave: ->
+ editor.removeClass "div-dropzone-focus"
+ return
+ drop: ->
+ editor.removeClass "div-dropzone-focus"
+ editor.focus()
+ return
+ success: (header, res) ->
+ self.appendImageFromUpload([res.url])
+ return
+ error: (temp, msg) ->
+ App.alert(msg)
+ return
+ totaluploadprogress: (num) ->
+ return
+ sending: ->
+ return
+ queuecomplete: ->
+ self.restoreUploaderStatus()
+ return
+ )
+
+ uploadFile: (item, filename) ->
+ self = @
+ formData = new FormData()
+ formData.append "file", item, filename
+ $.ajax
+ url: '/photos'
+ type: "POST"
+ data: formData
+ dataType: "JSON"
+ processData: false
+ contentType: false
+ beforeSend: ->
+ self.showUploading()
+ success: (e, status, res) ->
+ self.appendImageFromUpload([res.responseJSON.url])
+ self.restoreUploaderStatus()
+ error: (res) ->
+ App.alert("上传失败")
+ self.restoreUploaderStatus()
+ complete: ->
+ self.restoreUploaderStatus()
+
+ handlePaste: (e) ->
+ self = @
+ pasteEvent = e.originalEvent
+ if pasteEvent.clipboardData and pasteEvent.clipboardData.items
+ image = self.isImage(pasteEvent)
+ if image
+ e.preventDefault()
+ self.uploadFile image.getAsFile(), "image.png"
+
+ isImage: (data) ->
+ i = 0
+ while i < data.clipboardData.items.length
+ item = data.clipboardData.items[i]
+ if item.type.indexOf("image") isnt -1
+ return item
+ i++
+ return false
+
+ browseUpload: (e) ->
+ $(".topic-editor").focus()
+ $('.topic-editor-dropzone').click()
+ return false
+
+ showUploading: () ->
+ $("#editor-upload-image").hide()
+ if $("#editor-upload-image").parent().find("span.loading").length == 0
+ $("#editor-upload-image").before(" ")
+
+ restoreUploaderStatus: ->
+ $("#editor-upload-image").parent().find("span.loading").remove()
+ $("#editor-upload-image").show()
+
+ appendImageFromUpload : (srcs) ->
+ src_merged = ""
+ for src in srcs
+ src_merged = "![](#{src})\n"
+ @insertString(src_merged)
+ return false
+
+ # 往编辑器里面的光标前插入两个空白字符
+ insertSpaces : (e) ->
+ @insertString(' ')
+ return false
+
+ # 往编辑器里面插入代码模版
+ appendCodesFromHint : (e) ->
+ link = $(e.currentTarget)
+ language = link.data("lang")
+ txtBox = $(".topic-editor")
+ caret_pos = txtBox.caret('pos')
+ prefix_break = ""
+ if txtBox.val().length > 0
+ prefix_break = "\n"
+ src_merged = "#{prefix_break }```#{language}\n\n```\n"
+ source = txtBox.val()
+ before_text = source.slice(0, caret_pos)
+ txtBox.val(before_text + src_merged + source.slice(caret_pos+1, source.count))
+ txtBox.caret('pos',caret_pos + src_merged.length - 5)
+ txtBox.focus()
+ txtBox.trigger('click')
+ return false
+
+ insertString: (str) ->
+ $target = $(".topic-editor")
+ start = $target[0].selectionStart
+ end = $target[0].selectionEnd
+ $target.val($target.val().substring(0, start) + str + $target.val().substring(end));
+ $target[0].selectionStart = $target[0].selectionEnd = start + str.length
+ $target.focus()
+
+ initComponents : ->
+ # 绑定文本框 tab 按键事件
+ $("textarea.topic-editor").unbind "keydown.tab"
+ $("textarea.topic-editor").bind "keydown.tab", "tab", (el) =>
+ return @insertSpaces(el)
+
+ $("textarea.topic-editor").autogrow()
+
+
+ pickupEmoji: () ->
+ if !window._emojiModal
+ window._emojiModal = new EmojiModalView()
+ window._emojiModal.show()
+ false
+
diff --git a/app/assets/javascripts/emoji-data.js.erb b/app/assets/javascripts/emoji-data.js.erb
new file mode 100644
index 000000000..8c3089103
--- /dev/null
+++ b/app/assets/javascripts/emoji-data.js.erb
@@ -0,0 +1,1110 @@
+var default_emojis = [":+1:", ":-1:", ":clap:", ":smile:", ":sob:", ":heart_eyes:", ":sweat_smile:", ":tada:", ":ok_hand:"];
+window.EMOJI_LIST = _.sortBy(<%= Twemoji.codes.collect { |k,v| { code: k, url: v } }.to_json %>, function(emoji) {
+ return 0 - default_emojis.indexOf(emoji.code);
+});
+
+
+window.EMOJI_GROUPS = [
+ {
+ name: 'favorites',
+ fullname: "Favorites",
+ tabicon: 'clock9',
+ icons: []
+ },
+ {
+ name:"people",
+ fullname:"People",
+ tabicon:"grinning",
+ icons:[
+ "grinning",
+ "grin",
+ "joy",
+ "smiley",
+ "smile",
+ "sweat_smile",
+ "laughing",
+ "innocent",
+ "smiling_imp",
+ "imp",
+ "wink",
+ "blush",
+ "relaxed",
+ "yum",
+ "relieved",
+ "heart_eyes",
+ "sunglasses",
+ "smirk",
+ "neutral_face",
+ "expressionless",
+ "unamused",
+ "sweat",
+ "pensive",
+ "confused",
+ "confounded",
+ "kissing",
+ "kissing_heart",
+ "kissing_smiling_eyes",
+ "kissing_closed_eyes",
+ "stuck_out_tongue",
+ "stuck_out_tongue_winking_eye",
+ "stuck_out_tongue_closed_eyes",
+ "disappointed",
+ "worried",
+ "angry",
+ "rage",
+ "cry",
+ "persevere",
+ "triumph",
+ "disappointed_relieved",
+ "frowning",
+ "anguished",
+ "fearful",
+ "weary",
+ "sleepy",
+ "tired_face",
+ "grimacing",
+ "sob",
+ "open_mouth",
+ "hushed",
+ "cold_sweat",
+ "scream",
+ "astonished",
+ "flushed",
+ "sleeping",
+ "dizzy_face",
+ "no_mouth",
+ "mask",
+ "smile_cat",
+ "joy_cat",
+ "smiley_cat",
+ "heart_eyes_cat",
+ "smirk_cat",
+ "kissing_cat",
+ "pouting_cat",
+ "crying_cat_face",
+ "scream_cat",
+ "footprints",
+ "bust_in_silhouette",
+ "busts_in_silhouette",
+ "baby",
+ "boy",
+ "girl",
+ "man",
+ "woman",
+ "family",
+ "couple",
+ "two_men_holding_hands",
+ "two_women_holding_hands",
+ "dancers",
+ "bride_with_veil",
+ "person_with_blond_hair",
+ "man_with_gua_pi_mao",
+ "man_with_turban",
+ "older_man",
+ "older_woman",
+ "cop",
+ "construction_worker",
+ "princess",
+ "guardsman",
+ "angel",
+ "santa",
+ "ghost",
+ "japanese_ogre",
+ "japanese_goblin",
+ "hankey",
+ "skull",
+ "alien",
+ "space_invader",
+ "bow",
+ "information_desk_person",
+ "no_good",
+ "ok_woman",
+ "raising_hand",
+ "person_with_pouting_face",
+ "person_frowning",
+ "massage",
+ "haircut",
+ "couple_with_heart",
+ "couplekiss",
+ "raised_hands",
+ "clap",
+ "hand",
+ "ear",
+ "eyes",
+ "nose",
+ "lips",
+ "kiss",
+ "tongue",
+ "nail_care",
+ "wave",
+ "+1",
+ "-1",
+ "point_up",
+ "point_up_2",
+ "point_down",
+ "point_left",
+ "point_right",
+ "ok_hand",
+ "v",
+ "facepunch",
+ "fist",
+ "raising_hand",
+ "muscle",
+ "open_hands",
+ "pray",
+ "eye",
+ "hugging_face",
+ "middle_finger",
+ "money_mouth_face",
+ "nerd_face",
+ "facepunch",
+ "robot_face",
+ "face_with_rolling_eyes",
+ "skull_and_crossbones",
+ "slightly_frowning_face",
+ "speaking_head_in_silhouette",
+ "sleuth_or_spy",
+ "thinking_face",
+ "upside_down_face",
+ "funeral_urn",
+ "wind_blowing_face",
+ "writing_hand",
+ "zipper_mouth_face",
+ "question",
+ "anger",
+ "100"
+ ]
+ },
+ {
+ name:"nature",
+ fullname:"Nature",
+ tabicon:"mouse",
+ icons:[
+ "seedling",
+ "evergreen_tree",
+ "deciduous_tree",
+ "palm_tree",
+ "cactus",
+ "tulip",
+ "cherry_blossom",
+ "rose",
+ "hibiscus",
+ "sunflower",
+ "blossom",
+ "bouquet",
+ "ear_of_rice",
+ "herb",
+ "four_leaf_clover",
+ "maple_leaf",
+ "fallen_leaf",
+ "leaves",
+ "mushroom",
+ "chestnut",
+ "rat",
+ "mouse2",
+ "mouse",
+ "hamster",
+ "ox",
+ "water_buffalo",
+ "cow2",
+ "cow",
+ "tiger2",
+ "leopard",
+ "tiger",
+ "rabbit2",
+ "rabbit",
+ "cat2",
+ "cat",
+ "racehorse",
+ "horse",
+ "ram",
+ "sheep",
+ "goat",
+ "rooster",
+ "chicken",
+ "baby_chick",
+ "hatching_chick",
+ "hatched_chick",
+ "bird",
+ "penguin",
+ "elephant",
+ "dromedary_camel",
+ "camel",
+ "boar",
+ "pig2",
+ "pig",
+ "pig_nose",
+ "dog2",
+ "poodle",
+ "dog",
+ "wolf",
+ "bear",
+ "koala",
+ "panda_face",
+ "monkey_face",
+ "see_no_evil",
+ "hear_no_evil",
+ "speak_no_evil",
+ "monkey",
+ "dragon",
+ "dragon_face",
+ "crocodile",
+ "snake",
+ "turtle",
+ "frog",
+ "whale2",
+ "whale",
+ "dolphin",
+ "octopus",
+ "fish",
+ "tropical_fish",
+ "blowfish",
+ "shell",
+ "snail",
+ "bug",
+ "ant",
+ "bee",
+ "beetle",
+ "feet",
+ "crab",
+ "dove_of_peace",
+ "lion_face",
+ "scorpion",
+ "spider",
+ "spider_web",
+ "turkey",
+ "unicorn_face"
+ ]
+ },
+ {
+ name: "wather",
+ fullname: "Flags & Wather",
+ tabicon: "partly_sunny",
+ icons: [
+ "zap",
+ "fire",
+ "crescent_moon",
+ "sunny",
+ "partly_sunny",
+ "cloud",
+ "droplet",
+ "sweat_drops",
+ "umbrella",
+ "dash",
+ "snowflake",
+ "star2",
+ "star",
+ "stars",
+ "sunrise_over_mountains",
+ "sunrise",
+ "rainbow",
+ "ocean",
+ "volcano",
+ "milky_way",
+ "mount_fuji",
+ "japan",
+ "globe_with_meridians",
+ "earth_africa",
+ "earth_americas",
+ "earth_asia",
+ "new_moon",
+ "waxing_crescent_moon",
+ "first_quarter_moon",
+ "moon",
+ "full_moon",
+ "waning_gibbous_moon",
+ "last_quarter_moon",
+ "waning_crescent_moon",
+ "new_moon_with_face",
+ "full_moon_with_face",
+ "first_quarter_moon_with_face",
+ "last_quarter_moon_with_face",
+ "sun_with_face",
+ "chipmunk",
+ "lightning",
+ "rain_cloud",
+ "snow_cloud",
+ "tornado",
+ "fog",
+ "comet",
+ "thunder_cloud_and_rain",
+ "waxing_gibbous_moon",
+ "sunny",
+ "white_sun_rain_cloud",
+ "white_sun_small_cloud",
+ "flag-black",
+ "flag-cn",
+ "flag-de",
+ "flag-es",
+ "flag-fr",
+ "flag-gb",
+ "flag-it",
+ "flag-jp",
+ "flag-kr",
+ "flag-ru",
+ "flag-us",
+ "flag-white"
+ ]
+ },
+ {
+ name:"food",
+ fullname:"Food & Drink",
+ tabicon:"beers",
+ icons:[
+ "tomato",
+ "eggplant",
+ "corn",
+ "sweet_potato",
+ "grapes",
+ "melon",
+ "watermelon",
+ "tangerine",
+ "lemon",
+ "banana",
+ "pineapple",
+ "apple",
+ "green_apple",
+ "pear",
+ "peach",
+ "cherries",
+ "strawberry",
+ "hamburger",
+ "pizza",
+ "meat_on_bone",
+ "poultry_leg",
+ "rice_cracker",
+ "rice_ball",
+ "rice",
+ "curry",
+ "ramen",
+ "spaghetti",
+ "bread",
+ "fries",
+ "dango",
+ "oden",
+ "sushi",
+ "fried_shrimp",
+ "fish_cake",
+ "icecream",
+ "shaved_ice",
+ "ice_cream",
+ "doughnut",
+ "cookie",
+ "chocolate_bar",
+ "candy",
+ "lollipop",
+ "custard",
+ "honey_pot",
+ "cake",
+ "bento",
+ "stew",
+ "egg",
+ "fork_and_knife",
+ "tea",
+ "coffee",
+ "sake",
+ "wine_glass",
+ "cocktail",
+ "tropical_drink",
+ "beer",
+ "beers",
+ "baby_bottle",
+ "burrito",
+ "champagne",
+ "cheese",
+ "hot_pepper",
+ "hotdog",
+ "taco"
+ ]
+ },
+ {
+ name:"celebration",
+ fullname:"Celebration",
+ tabicon:"gift",
+ icons:[
+ "ribbon",
+ "gift",
+ "birthday",
+ "jack_o_lantern",
+ "christmas_tree",
+ "tanabata_tree",
+ "bamboo",
+ "rice_scene",
+ "fireworks",
+ "sparkler",
+ "tada",
+ "confetti_ball",
+ "balloon",
+ "dizzy",
+ "sparkles",
+ "boom",
+ "mortar_board",
+ "crown",
+ "dolls",
+ "flags",
+ "wind_chime",
+ "crossed_flags",
+ "izakaya_lantern",
+ "ring",
+ "heart",
+ "broken_heart",
+ "love_letter",
+ "two_hearts",
+ "revolving_hearts",
+ "heartbeat",
+ "heartpulse",
+ "sparkling_heart",
+ "cupid",
+ "gift_heart",
+ "heart_decoration",
+ "purple_heart",
+ "yellow_heart",
+ "green_heart",
+ "blue_heart",
+ "heart_exclamation"
+ ]
+ },
+ {
+ name:"activity",
+ fullname:"Activities",
+ tabicon:"soccer",
+ icons:[
+ "runner",
+ "walking",
+ "dancer",
+ "rowboat",
+ "swimmer",
+ "surfer",
+ "bath",
+ "snowboarder",
+ "ski",
+ "snowman",
+ "bicyclist",
+ "mountain_bicyclist",
+ "horse_racing",
+ "tent",
+ "fishing_pole_and_fish",
+ "soccer",
+ "basketball",
+ "football",
+ "baseball",
+ "tennis",
+ "rugby_football",
+ "golf",
+ "trophy",
+ "running_shirt_with_sash",
+ "checkered_flag",
+ "musical_keyboard",
+ "guitar",
+ "violin",
+ "saxophone",
+ "trumpet",
+ "musical_note",
+ "notes",
+ "musical_score",
+ "headphones",
+ "microphone",
+ "performing_arts",
+ "ticket",
+ "tophat",
+ "circus_tent",
+ "clapper",
+ "art",
+ "dart",
+ "8ball",
+ "bowling",
+ "slot_machine",
+ "game_die",
+ "video_game",
+ "flower_playing_cards",
+ "black_joker",
+ "mahjong",
+ "carousel_horse",
+ "ferris_wheel",
+ "roller_coaster",
+ "badminton",
+ "ballot_box",
+ "basketball_player",
+ "bow_and_arrow",
+ "cricket",
+ "crossed_swords",
+ "field_hockey",
+ "golfer",
+ "hockey",
+ "ice_skate",
+ "paintbrush",
+ "skier",
+ "snowman2",
+ "stadium",
+ "volleyball"
+ ]
+ },
+ {
+ name:"travel",
+ fullname:"Travel & Places",
+ tabicon:"oncoming_bus",
+ icons:[
+ "train",
+ "mountain_railway",
+ "railway_car",
+ "steam_locomotive",
+ "monorail",
+ "bullettrain_side",
+ "bullettrain_front",
+ "train2",
+ "metro",
+ "light_rail",
+ "station",
+ "tram",
+ "bus",
+ "oncoming_bus",
+ "trolleybus",
+ "minibus",
+ "ambulance",
+ "fire_engine",
+ "police_car",
+ "oncoming_police_car",
+ "rotating_light",
+ "taxi",
+ "oncoming_taxi",
+ "car",
+ "oncoming_automobile",
+ "blue_car",
+ "truck",
+ "articulated_lorry",
+ "tractor",
+ "bike",
+ "busstop",
+ "fuelpump",
+ "construction",
+ "vertical_traffic_light",
+ "traffic_light",
+ "rocket",
+ "helicopter",
+ "airplane",
+ "seat",
+ "anchor",
+ "ship",
+ "speedboat",
+ "boat",
+ "aerial_tramway",
+ "mountain_cableway",
+ "suspension_railway",
+ "passport_control",
+ "customs",
+ "baggage_claim",
+ "left_luggage",
+ "yen",
+ "euro",
+ "pound",
+ "dollar",
+ "statue_of_liberty",
+ "moyai",
+ "foggy",
+ "tokyo_tower",
+ "fountain",
+ "european_castle",
+ "japanese_castle",
+ "city_sunrise",
+ "city_sunset",
+ "night_with_stars",
+ "bridge_at_night",
+ "house",
+ "house_with_garden",
+ "office",
+ "department_store",
+ "factory",
+ "post_office",
+ "european_post_office",
+ "hospital",
+ "bank",
+ "hotel",
+ "love_hotel",
+ "wedding",
+ "church",
+ "convenience_store",
+ "school",
+ "airplane_arriving",
+ "airplane_departure",
+ "airplane_small",
+ "beach",
+ "beach_umbrella",
+ "camping",
+ "city_dusk",
+ "cityscape",
+ "classical_building",
+ "construction_site",
+ "cruise_ship",
+ "desert",
+ "ferry",
+ "hole",
+ "homes",
+ "house_abandoned",
+ "island",
+ "kaaba",
+ "map",
+ "mosque",
+ "motorboat",
+ "motorcycle",
+ "motorway",
+ "mountain",
+ "mountain_snow",
+ "park",
+ "place_of_worship",
+ "race_car",
+ "railway_track",
+ "red_car",
+ "sailboat",
+ "shinto_shrine",
+ "sleeping_accommodation",
+ "synagogue"
+ ]
+ },
+ {
+ name:"objects",
+ fullname:"Objects",
+ tabicon:"bulb",
+ icons:[
+ "watch",
+ "iphone",
+ "calling",
+ "computer",
+ "alarm_clock",
+ "hourglass_flowing_sand",
+ "hourglass",
+ "camera",
+ "video_camera",
+ "movie_camera",
+ "tv",
+ "radio",
+ "pager",
+ "telephone_receiver",
+ "phone",
+ "fax",
+ "minidisc",
+ "floppy_disk",
+ "cd",
+ "dvd",
+ "vhs",
+ "battery",
+ "electric_plug",
+ "bulb",
+ "flashlight",
+ "satellite",
+ "credit_card",
+ "money_with_wings",
+ "moneybag",
+ "gem",
+ "closed_umbrella",
+ "pouch",
+ "purse",
+ "handbag",
+ "briefcase",
+ "school_satchel",
+ "lipstick",
+ "eyeglasses",
+ "womans_hat",
+ "sandal",
+ "high_heel",
+ "boot",
+ "mans_shoe",
+ "athletic_shoe",
+ "bikini",
+ "dress",
+ "kimono",
+ "womans_clothes",
+ "shirt",
+ "necktie",
+ "jeans",
+ "door",
+ "shower",
+ "bathtub",
+ "toilet",
+ "barber",
+ "syringe",
+ "pill",
+ "microscope",
+ "telescope",
+ "crystal_ball",
+ "wrench",
+ "hocho",
+ "nut_and_bolt",
+ "hammer",
+ "bomb",
+ "smoking",
+ "gun",
+ "bookmark",
+ "newspaper",
+ "key",
+ "email",
+ "envelope_with_arrow",
+ "incoming_envelope",
+ "e-mail",
+ "inbox_tray",
+ "outbox_tray",
+ "package",
+ "postal_horn",
+ "postbox",
+ "mailbox_closed",
+ "mailbox",
+ "mailbox_with_mail",
+ "mailbox_with_no_mail",
+ "page_facing_up",
+ "page_with_curl",
+ "bookmark_tabs",
+ "chart_with_upwards_trend",
+ "chart_with_downwards_trend",
+ "bar_chart",
+ "date",
+ "calendar",
+ "low_brightness",
+ "high_brightness",
+ "scroll",
+ "clipboard",
+ "book",
+ "notebook",
+ "notebook_with_decorative_cover",
+ "ledger",
+ "closed_book",
+ "green_book",
+ "blue_book",
+ "orange_book",
+ "books",
+ "card_index",
+ "link",
+ "paperclip",
+ "pushpin",
+ "scissors",
+ "triangular_ruler",
+ "round_pushpin",
+ "straight_ruler",
+ "triangular_flag_on_post",
+ "file_folder",
+ "open_file_folder",
+ "black_nib",
+ "pencil2",
+ "memo",
+ "lock_with_ink_pen",
+ "closed_lock_with_key",
+ "lock",
+ "unlock",
+ "mega",
+ "loudspeaker",
+ "sound",
+ "loud_sound",
+ "speaker",
+ "mute",
+ "zzz",
+ "bell",
+ "no_bell",
+ "thought_balloon",
+ "speech_balloon",
+ "children_crossing",
+ "mag",
+ "mag_right",
+ "no_entry_sign",
+ "no_entry"
+ ]
+ },
+
+ {
+ name: "symbols",
+ fullname: "Symbols",
+ tabicon: "symbols",
+ icons: [
+ "name_badge",
+ "no_pedestrians",
+ "do_not_litter",
+ "no_bicycles",
+ "non-potable_water",
+ "no_mobile_phones",
+ "underage",
+ "accept",
+ "ideograph_advantage",
+ "white_flower",
+ "secret",
+ "congratulations",
+ "u5408",
+ "u6e80",
+ "u7981",
+ "u6709",
+ "u7121",
+ "u7533",
+ "u55b6",
+ "u6708",
+ "u5272",
+ "u7a7a",
+ "sa",
+ "koko",
+ "u6307",
+ "chart",
+ "sparkle",
+ "eight_spoked_asterisk",
+ "negative_squared_cross_mark",
+ "white_check_mark",
+ "eight_pointed_black_star",
+ "vibration_mode",
+ "mobile_phone_off",
+ "vs",
+ "a",
+ "b",
+ "ab",
+ "cl",
+ "o2",
+ "sos",
+ "id",
+ "parking",
+ "wc",
+ "cool",
+ "free",
+ "new",
+ "ng",
+ "ok",
+ "up",
+ "atm",
+ "aries",
+ "taurus",
+ "gemini",
+ "cancer",
+ "leo",
+ "virgo",
+ "libra",
+ "scorpius",
+ "sagittarius",
+ "capricorn",
+ "aquarius",
+ "pisces",
+ "restroom",
+ "mens",
+ "womens",
+ "baby_symbol",
+ "wheelchair",
+ "potable_water",
+ "no_smoking",
+ "put_litter_in_its_place",
+ "arrow_forward",
+ "arrow_backward",
+ "arrow_up_small",
+ "arrow_down_small",
+ "fast_forward",
+ "rewind",
+ "arrow_double_up",
+ "arrow_double_down",
+ "arrow_right",
+ "arrow_left",
+ "arrow_up",
+ "arrow_down",
+ "arrow_upper_right",
+ "arrow_lower_right",
+ "arrow_lower_left",
+ "arrow_upper_left",
+ "arrow_up_down",
+ "left_right_arrow",
+ "arrows_counterclockwise",
+ "arrow_right_hook",
+ "leftwards_arrow_with_hook",
+ "arrow_heading_up",
+ "arrow_heading_down",
+ "twisted_rightwards_arrows",
+ "repeat",
+ "repeat_one",
+ "zero",
+ "one",
+ "two",
+ "three",
+ "four",
+ "five",
+ "six",
+ "seven",
+ "eight",
+ "nine",
+ "keycap_ten",
+ "1234",
+ "hash",
+ "abc",
+ "abcd",
+ "capital_abcd",
+ "information_source",
+ "signal_strength",
+ "cinema",
+ "symbols",
+ "heavy_plus_sign",
+ "heavy_minus_sign",
+ "wavy_dash",
+ "heavy_division_sign",
+ "heavy_multiplication_x",
+ "heavy_check_mark",
+ "arrows_clockwise",
+ "tm",
+ "copyright",
+ "registered",
+ "currency_exchange",
+ "heavy_dollar_sign",
+ "curly_loop",
+ "loop",
+ "part_alternation_mark",
+ "exclamation",
+ "bangbang",
+ "grey_exclamation",
+ "grey_question",
+ "interrobang",
+ "x",
+ "o",
+ "end",
+ "back",
+ "on",
+ "top",
+ "soon",
+ "cyclone",
+ "m",
+ "ophiuchus",
+ "six_pointed_star",
+ "beginner",
+ "trident",
+ "warning",
+ "hotsprings",
+ "recycle",
+ "diamond_shape_with_a_dot_inside",
+ "spades",
+ "clubs",
+ "hearts",
+ "diamonds",
+ "ballot_box_with_check",
+ "white_circle",
+ "black_circle",
+ "radio_button",
+ "red_circle",
+ "large_blue_circle",
+ "small_red_triangle",
+ "small_red_triangle_down",
+ "small_orange_diamond",
+ "small_blue_diamond",
+ "large_orange_diamond",
+ "large_blue_diamond",
+ "black_small_square",
+ "white_small_square",
+ "black_large_square",
+ "white_large_square",
+ "black_medium_square",
+ "white_medium_square",
+ "black_medium_small_square",
+ "white_medium_small_square",
+ "black_square_button",
+ "white_square_button",
+ "clock1",
+ "clock2",
+ "clock3",
+ "clock4",
+ "clock5",
+ "clock6",
+ "clock7",
+ "clock8",
+ "clock9",
+ "clock10",
+ "clock11",
+ "clock12",
+ "clock130",
+ "clock230",
+ "clock330",
+ "clock430",
+ "clock530",
+ "clock630",
+ "clock730",
+ "clock830",
+ "clock930",
+ "clock1030",
+ "clock1130",
+ "clock1230",
+ "alembic",
+ "amphora",
+ "atom",
+ "biohazard",
+ "bed",
+ "bellhop",
+ "calendar_spiral",
+ "camera_with_flash",
+ "candle",
+ "card_box",
+ "chains",
+ "clock",
+ "coffin",
+ "compression",
+ "control_knobs",
+ "couch",
+ "crayon",
+ "cross",
+ "dagger",
+ "dark_sunglasses",
+ "desktop",
+ "dividers",
+ "envelope",
+ "file_cabinet",
+ "film_frames",
+ "fleur-de-lis",
+ "fork_knife_plate",
+ "frame_photo",
+ "gear",
+ "hammer_pick",
+ "helmet_with_cross",
+ "joystick",
+ "key2",
+ "keyboard",
+ "knife",
+ "label",
+ "level_slider",
+ "levitate",
+ "lifter",
+ "medal",
+ "menorah",
+ "metal",
+ "microphone2",
+ "military_medal",
+ "mouse_three_button",
+ "newspaper2",
+ "notepad_spiral",
+ "oil",
+ "om_symbol",
+ "orthodox_cross",
+ "paperclips",
+ "pause_button",
+ "peace",
+ "pen_ballpoint",
+ "pen_fountain",
+ "pencil",
+ "pick",
+ "ping_pong",
+ "play_pause",
+ "popcorn",
+ "prayer_beads",
+ "printer",
+ "projector",
+ "radioactive",
+ "record_button",
+ "reminder_ribbon",
+ "rosette",
+ "satellite_orbital",
+ "scales",
+ "shamrock",
+ "shield",
+ "shopping_bags",
+ "star_and_crescent",
+ "star_of_david",
+ "stop_button",
+ "stopwatch",
+ "telephone",
+ "ten",
+ "thermometer",
+ "thermometer_face",
+ "tickets",
+ "timer",
+ "tools",
+ "track_next",
+ "track_previous",
+ "trackball",
+ "umbrella2",
+ "wastebasket",
+ "wheel_of_dharma",
+ "yin_yang"
+ ]
+ }
+];
diff --git a/app/assets/javascripts/emoji-modal.coffee b/app/assets/javascripts/emoji-modal.coffee
new file mode 100644
index 000000000..3269e4009
--- /dev/null
+++ b/app/assets/javascripts/emoji-modal.coffee
@@ -0,0 +1,123 @@
+window.EmojiModalView = Backbone.View.extend
+ className: 'emoji-modal modal'
+
+ panels: {}
+
+ events:
+ "click .tab-pane a.emoji": "insertCode"
+ "mouseover .tab-pane a.emoji": "preview"
+ "click .nav-tabs li a": "changePanel"
+
+ initialize: ->
+ @.$el.html("""
+
+ """)
+ for group in EMOJI_GROUPS
+ @addGroup(group)
+
+ @activeFirstPanel()
+
+ activeFirstPanel: ->
+ @.$el.find('.nav-tabs li').first().addClass('active')
+ firstGroupName = @.$el.find('.nav-tabs li a').first().data("group")
+ tabPane = @.$el.find("#emoji-group-#{firstGroupName}")
+ tabPane.html(@panels[firstGroupName])
+ tabPane.addClass("active")
+
+ findEmojiUrlByName: (name) ->
+ emoji = _.find EMOJI_LIST, (emoji) ->
+ return emoji.code == ":#{name}:"
+ if !emoji
+ return ""
+ return "#{App.twemoji_url}/svg/#{emoji.url}.svg"
+
+ addGroup: (group) ->
+ @renderGroupHTML(group)
+ if group.name == 'favorites'
+ return false if group.icons.length == 0
+ navTab = """
+
+
+
+ """
+ navPanel = """
+
+
+ """
+
+ @.$el.find('.nav-tabs').append(navTab)
+ @.$el.find('.tab-content').append(navPanel)
+
+ renderGroupHTML: (group) ->
+ emojis = []
+ if group.name == 'favorites'
+ group.icons = _.pluck(@favoriteEmojis(), 'code')
+ for emojiName in group.icons
+ url = @findEmojiUrlByName(emojiName)
+ if !url
+ continue
+ emojis.push " "
+ @panels[group.name] = emojis.join('')
+
+ changePanel: (e) ->
+ groupName = $(e.currentTarget).data('group')
+ $("#emoji-group-#{groupName}").html(@panels[groupName])
+
+ insertCode: (e) ->
+ target = $(e.currentTarget)
+ code = target.data('code')
+ @saveFavoritEmoji(code)
+ window._editor.insertString(":#{code}: ")
+ return false
+
+ preview: (e) ->
+ target = $(e.currentTarget)
+ emojiName = target.data('code')
+ code = ":#{target.data('code')}: "
+ html = " #{code}"
+ @.$el.find('.modal-footer').html(html)
+
+ show: ->
+ if $('.emoji-modal').size() == 0
+ $('body').append(@.$el)
+ @.$el.modal('show')
+
+ hide: ->
+ @.$el.modal('hide')
+
+ saveFavoritEmoji: (code) ->
+ emojis = @favoriteEmojis()
+ emoji = _.find emojis, (item) ->
+ return item.code == code
+ if !emoji
+ emoji = { code: code, hits: 0 }
+ emojis.push(emoji)
+ emoji.hits += 1
+ emojis = _.sortBy emojis, (item) ->
+ return 0 - item.hits
+ emojis = _.first(emojis, 100)
+ localStorage.setItem('favorite-emojis', JSON.stringify(emojis))
+ @renderGroupHTML(EMOJI_GROUPS[0])
+
+ favoriteEmojis: ->
+ return [] if !window.localStorage
+ try
+ JSON.parse(localStorage.getItem('favorite-emojis') || '[]')
+ catch
+ []
diff --git a/app/assets/javascripts/form_storage.coffee b/app/assets/javascripts/form_storage.coffee
new file mode 100644
index 000000000..688975e7c
--- /dev/null
+++ b/app/assets/javascripts/form_storage.coffee
@@ -0,0 +1,26 @@
+@FormStorage =
+ key: (element) ->
+ "#{location.pathname} #{$(element).prop('id')}"
+
+ init: ->
+ if window.localStorage
+ $(document).on 'input', 'textarea[name*=body]', ->
+ textarea = $(this)
+ localStorage.setItem(FormStorage.key(textarea), textarea.val())
+
+ $(document).on 'submit', 'form', ->
+ form = $(this)
+ form.find('textarea[name*=body]').each ->
+ localStorage.removeItem(FormStorage.key(this))
+
+ $(document).on 'click', 'form a.reset', ->
+ form = $(this).closest('form')
+ form.find('textarea[name*=body]').each ->
+ localStorage.removeItem(FormStorage.key(this))
+
+ restore: ->
+ if window.localStorage
+ $('textarea[name*=body]').each ->
+ textarea = $(this)
+ if value = localStorage.getItem(FormStorage.key(textarea))
+ textarea.val(value)
diff --git a/app/assets/javascripts/notifier.coffee b/app/assets/javascripts/notifier.coffee
new file mode 100644
index 000000000..ec9fa641d
--- /dev/null
+++ b/app/assets/javascripts/notifier.coffee
@@ -0,0 +1,64 @@
+class Notifier
+ constructor: ->
+ @enableNotification = false
+ @checkOrRequirePermission()
+
+ hasSupport: ->
+ window.webkitNotifications?
+
+ requestPermission: (cb) ->
+ window.webkitNotifications.requestPermission (cb)
+
+ setPermission: =>
+ if @hasPermission()
+ $('#notification-alert a.close').click()
+ @enableNotification = true
+ else if window.webkitNotifications.checkPermission() is 2
+ $('#notification-alert a.close').click()
+
+ hasPermission: ->
+ if window.webkitNotifications.checkPermission() is 0
+ return true
+ else
+ return false
+
+ checkOrRequirePermission: =>
+ if @hasSupport()
+ if @hasPermission()
+ @enableNotification = true
+ else
+ if window.webkitNotifications.checkPermission() isnt 2
+ @showTooltip()
+ else
+ console.log("Desktop notifications are not supported for this Browser/OS version yet.")
+
+ showTooltip: ->
+ $('.breadcrumb').before("")
+ $("#notification-alert").alert()
+ $('#notification-alert').on 'click', 'a#link_enable_notifications', (e) =>
+ e.preventDefault()
+ @requestPermission(@setPermission)
+
+ visitUrl: (url) ->
+ window.location.href = url
+
+ notify: (avatar, title, content, url = null) ->
+ if @enableNotification
+ if not window.Notification
+ popup = window.webkitNotifications.createNotification(avatar, title, content)
+ if url
+ popup.onclick = ->
+ window.parent.focus()
+ $.notifier.visitUrl(url)
+ else
+ opts =
+ body : content
+ onclick : ->
+ window.parent.focus()
+ $.notifier.visitUrl(url)
+ popup = new window.Notification(title,opts)
+ popup.show()
+
+ # setTimeout ( => popup.cancel() ), 12000
+
+jQuery.notifier = new Notifier
diff --git a/app/assets/javascripts/toc.coffee b/app/assets/javascripts/toc.coffee
new file mode 100644
index 000000000..bf2d79519
--- /dev/null
+++ b/app/assets/javascripts/toc.coffee
@@ -0,0 +1,37 @@
+# Table of Contents for Markdown body
+window.TOCView = Backbone.View.extend
+ el: "body"
+
+ initialize: (opts) ->
+ @parentView = opts.parentView
+ haveAnyHeaders = @initHeadersInTopic()
+ $(".toc-container").show() if haveAnyHeaders
+
+ initHeadersInTopic: ->
+ if $(".markdown-toc .toc-container").size() > 0
+ return false
+ markdownEl = $(".markdown-toc")
+ markdownEl.prepend """
+
+
+ 目录
+
+
+
+ """
+
+ items = $.map markdownEl.find("h1,h2,h3,h4,h5,h6"), (el, _) ->
+ level = el.tagName.replace("H", "")
+ anchor = el.id
+ "
+ #{el.textContent}
+ "
+ if items.length
+ $(".toc-container .list").html(items.join(""))
+ true
+ else
+ false
diff --git a/app/assets/javascripts/topics.coffee b/app/assets/javascripts/topics.coffee
new file mode 100644
index 000000000..410f6daa2
--- /dev/null
+++ b/app/assets/javascripts/topics.coffee
@@ -0,0 +1,330 @@
+# TopicsController 下所有页面的 JS 功能
+window.Topics =
+ topic_id: null
+ user_liked_reply_ids: []
+
+window.TopicView = Backbone.View.extend
+ el: "body"
+ currentPageImageURLs : []
+ clearHightTimer: null
+
+ events:
+ "click .navbar .topic-title": "scrollPage"
+ "click #replies .reply .btn-reply": "reply"
+ "click a.at_floor": "clickAtFloor"
+ "click a.follow": "follow"
+ "click a.bookmark": "bookmark"
+ "click .btn-move-page": "scrollPage"
+ "click .notify-updated .update": "updateReplies"
+ "click #node-selector .nodes .name a": "nodeSelectorNodeSelected"
+ "click .editor-toolbar .reply-to a.close": "unsetReplyTo"
+ "tap .topics .topic": "topicRowClick"
+
+ initialize: (opts) ->
+ @parentView = opts.parentView
+
+ @initComponents()
+ @initCableUpdate()
+ @initContentImageZoom()
+ @initCloseWarning()
+ @checkRepliesLikeStatus()
+ @itemsUpdated()
+
+ # called by new Reply insterted.
+ itemsUpdated: ->
+ @resetClearReplyHightTimer()
+ @loadReplyToFloor()
+
+ resetClearReplyHightTimer: ->
+ clearTimeout(@clearHightTimer)
+ @clearHightTimer = setTimeout ->
+ $(".reply").removeClass("light")
+ , 10000
+
+ # 回复
+ reply: (e) ->
+ _el = $(e.target)
+ reply_to_id = _el.data('id')
+ @setReplyTo(reply_to_id)
+ reply_body = $("#new_reply textarea")
+ reply_body.focus()
+ return false
+
+ setReplyTo: (id) ->
+ $('input[name="reply[reply_to_id]"]').val(id)
+ replyEl = $(".reply[data-id=#{id}]")
+ targetAnchor = replyEl.attr('id')
+ replyToPanel = $(".editor-toolbar .reply-to")
+ userNameEl = replyEl.find("a.user-name:first-child")
+ replyToLink = replyToPanel.find(".user")
+ replyToLink.attr("href", "##{targetAnchor}")
+ replyToLink.text(userNameEl.text())
+ replyToPanel.show()
+
+ unsetReplyTo: ->
+ $('input[name="reply[reply_to_id]"]').val('')
+ replyToPanel = $(".editor-toolbar .reply-to")
+ replyToPanel.hide()
+
+ return false
+
+ clickAtFloor: (e) ->
+ floor = $(e.target).data('floor')
+ @gotoFloor(floor)
+
+ # 跳到指定楼。如果楼层在当前页,高亮该层,否则跳转到楼层所在页面并添
+ # 加楼层的 anchor。返回楼层 DOM Element 的 jQuery 对象
+ #
+ # - floor: 回复的楼层数,从1开始
+ gotoFloor: (floor) ->
+ replyEl = $("#reply#{floor}")
+
+ @highlightReply(replyEl)
+
+ replyEl
+
+ # 高亮指定楼。取消其它楼的高亮
+ #
+ # - replyEl: 需要高亮的 DOM Element,须要 jQuery 对象
+ highlightReply: (replyEl) ->
+ $("#replies .reply").removeClass("light")
+ replyEl.addClass("light")
+
+ # 异步更改用户 like 过的回复的 like 按钮的状态
+ checkRepliesLikeStatus : () ->
+ for id in Topics.user_liked_reply_ids
+ el = $("#replies a.likeable[data-id=#{id}]")
+ @parentView.likeableAsLiked(el)
+
+ # Ajax 回复后的事件
+ replyCallback : (success, msg) ->
+ return if msg == ''
+ $("#main .alert-message").remove()
+ if success
+ $("abbr.timeago",$("#replies .reply").last()).timeago()
+ $("abbr.timeago",$("#replies .total")).timeago()
+ $("#new_reply textarea").val('')
+ $("#preview").text('')
+ App.notice(msg,'#reply')
+ else
+ App.alert(msg,'#reply')
+ $("#new_reply textarea").focus()
+ $('#reply-button').button('reset')
+ @resetClearReplyHightTimer()
+ @unsetReplyTo()
+
+ # 图片点击增加全屏预览功能
+ initContentImageZoom : () ->
+ exceptClasses = ["emoji", "twemoji", "media-object avatar-16"]
+ imgEls = $(".markdown img")
+ for el in imgEls
+ if exceptClasses.indexOf($(el).attr("class")) == -1
+ $(el).wrap(" ")
+
+ # Bind click event
+ if App.turbolinks || App.mobile
+ $('a.zoom-image').attr("target","_blank")
+ else
+ $('a.zoom-image').fluidbox
+ overlayColor: "#FFF"
+ closeTrigger: [ {
+ selector: 'window'
+ event: 'scroll'
+ } ]
+ true
+
+ preview: (body) ->
+ $("#preview").text "Loading..."
+
+ $.post "/topics/preview",
+ "body": body,
+ (data) ->
+ $("#preview").html data.body
+ "json"
+
+ hookPreview: (switcher, textarea) ->
+ # put div#preview after textarea
+ self = @
+ preview_box = $(document.createElement("div")).attr "id", "preview"
+ preview_box.addClass("markdown form-control")
+ $(textarea).after preview_box
+ preview_box.hide()
+
+ $(".edit a",switcher).click ->
+ $(".preview",switcher).removeClass("active")
+ $(this).parent().addClass("active")
+ $(preview_box).hide()
+ $(textarea).show()
+ return false
+
+ $(".preview a",switcher).click ->
+ $(".edit",switcher).removeClass("active")
+ $(this).parent().addClass("active")
+ $(preview_box).show()
+ $(textarea).hide()
+ self.preview($(textarea).val())
+ return false
+
+ initCloseWarning: () ->
+ text = $("textarea.closewarning")
+ return false if text.length == 0
+ msg = "离开本页面将丢失未保存页面!" if !msg
+ $("input[type=submit]").click ->
+ $(window).unbind("beforeunload")
+ text.change ->
+ if text.val().length > 0
+ $(window).bind "beforeunload", (e) ->
+ if $.browser.msie
+ e.returnValue = msg
+ else
+ return msg
+ else
+ $(window).unbind("beforeunload")
+
+ bookmark : (e) ->
+ target = $(e.currentTarget)
+ topic_id = target.data("id")
+ link = $(".bookmark[data-id='#{topic_id}']")
+
+ if link.hasClass("active")
+ $.ajax
+ url : "/topics/#{topic_id}/unfavorite"
+ type : "DELETE"
+ link.attr("title","收藏").removeClass("active")
+ else
+ $.post "/topics/#{topic_id}/favorite"
+ link.attr("title","取消收藏").addClass("active")
+ false
+
+ follow : (e) ->
+ target = $(e.currentTarget)
+ topic_id = target.data("id")
+ link = $(".follow[data-id='#{topic_id}']")
+
+ if link.hasClass("active")
+ $.ajax
+ url : "/topics/#{topic_id}/unfollow"
+ type : "DELETE"
+ link.removeClass("active")
+ else
+ $.ajax
+ url : "/topics/#{topic_id}/follow"
+ type : "POST"
+ link.addClass("active")
+ false
+
+ submitTextArea : (e) ->
+ if $(e.target).val().trim().length > 0
+ $("form#new_reply").submit()
+ return false
+
+ scrollPage: (e) ->
+ target = $(e.currentTarget)
+ moveType = target.data('type')
+ opts =
+ scrollTop: 0
+ if moveType == 'bottom'
+ opts.scrollTop = $('body').height()
+ $("body, html").animate(opts, 300)
+ return false
+
+ initComponents : ->
+ $("textarea.topic-editor").unbind "keydown.cr"
+ $("textarea.topic-editor").bind "keydown.cr", "ctrl+return", (el) =>
+ return @submitTextArea(el)
+
+ $("textarea.topic-editor").unbind "keydown.mr"
+ $("textarea.topic-editor").bind "keydown.mr", "Meta+return", (el) =>
+ return @submitTextArea(el)
+
+ # also highlight if hash is reply#
+ matchResult = window.location.hash.match(/^#reply\-(\d+)$/)
+ if matchResult?
+ @highlightReply($("#reply-#{matchResult[1]}").parent())
+
+ @hookPreview($(".editor-toolbar"), $(".topic-editor"))
+
+ $("body").bind "keydown", "m", (el) ->
+ $('#markdown_help_tip_modal').modal
+ keyboard : true
+ backdrop : true
+ show : true
+
+ # @ Mention complete
+ App.mentionable("textarea", App.scanMentionableLogins($(".reply")))
+
+ # Focus title field in new-topic page
+ $("body[data-controller-name='topics'] #topic_title").focus()
+
+ # init editor toolbar
+ window._editor = new Editor()
+
+ initCableUpdate: () ->
+ self = @
+
+ if not Topics.topic_id
+ return
+
+ if !window.repliesChannel
+ window.repliesChannel = App.cable.subscriptions.create 'RepliesChannel',
+ topicId: null
+
+ connected: ->
+ @subscribe()
+
+ received: (json) =>
+ return false if json.user_id == App.current_user_id
+ return false if json.action != 'create'
+ if App.windowInActive
+ @updateReplies()
+ else
+ $(".notify-updated").show()
+
+ subscribe: ->
+ @topicId = Topics.topic_id
+ @perform 'follow', topic_id: Topics.topic_id
+ else if window.repliesChannel.topicId != Topics.topic_id
+ window.repliesChannel.subscribe()
+
+ updateReplies: () ->
+ lastId = $("#replies .reply:last").data('id')
+ if(!lastId)
+ Turbolinks.visit(location.href)
+ return false
+ $.get "/topics/#{Topics.topic_id}/replies.js?last_id=#{lastId}", =>
+ $(".notify-updated").hide()
+ $("#new_reply textarea").focus()
+ false
+
+ nodeSelectorNodeSelected: (e) ->
+ el = $(e.currentTarget)
+ $("#node-selector").modal('hide')
+ if $('.form input[name="topic[node_id]"]').length > 0
+ e.preventDefault()
+ nodeId = el.data('id')
+ $('.form input[name="topic[node_id]"]').val(nodeId)
+ $('#node-selector-button').html(el.text())
+ return false
+ else
+ return true
+
+ topicRowClick: (e) ->
+ if !App.turbolinks
+ return
+ target = $(e.currentTarget).find(".title a")
+ if e.target.tagName == "A"
+ return true
+ if $(e.target)[0] == target[0]
+ return true
+
+ e.preventDefault()
+
+ $(e.currentTarget).addClass('topic-visited')
+ Turbolinks.visit(target.attr('href'))
+ return false
+
+ loadReplyToFloor: ->
+ _.each $(".reply-to-block"), (el) =>
+ replyToId = $(el).data('reply-to-id')
+ floor = $("#reply-#{replyToId}").data('floor');
+ $(el).find('.reply-floor').text("\##{floor}")
diff --git a/lib/tasks/.gitkeep b/app/assets/stylesheets/.gitkeep
similarity index 100%
rename from lib/tasks/.gitkeep
rename to app/assets/stylesheets/.gitkeep
diff --git a/app/assets/stylesheets/admin.scss b/app/assets/stylesheets/admin.scss
new file mode 100644
index 000000000..c5513192d
--- /dev/null
+++ b/app/assets/stylesheets/admin.scss
@@ -0,0 +1,57 @@
+/*
+ *= require application
+ *= require_self
+ */
+
+@import "vars";
+
+body { color: #333; }
+
+body, p, ol, ul, td {
+ font-family: verdana, arial, helvetica, sans-serif;
+ font-size: 13px;
+ line-height: 18px;
+}
+
+.nav-search { display: none !important; }
+
+.panel-body h1,
+fieldset legend { border:0px; font-size: 18px; margin-top:0px; margin-bottom: 20px; padding: 0;line-height: 100%; }
+
+pre {
+ background-color: #eee;
+ padding: 10px;
+ font-size: 11px;
+}
+.group { margin-bottom:20px; }
+.group h2 { font-size:12px; margin-bottom:8px; }
+.group ul { margin:0 20px;}
+.pagination { clear:both; margin-top:10px; text-align: left;}
+
+.toolbar {
+ margin-bottom:15px;
+}
+
+table tr.deleted td { text-decoration: line-through; color:#999}
+table tr td a.fa {
+ text-decoration: normal;
+ color: #666;
+ margin-right: 4px;
+ &:hover { color: #333; text-decoration: none; }
+}
+
+.nav > li > a {
+ padding: 14px 6px;
+}
+
+.stat {
+
+ margin: 15px;
+ padding-bottom: 15px;
+ border-bottom: 1px dashed #eee;
+
+ .total { font-size: 18px; color: #555; margin-bottom: 10px; margin-top: 8px; }
+ .name { font-size: 14px; color: #999; }
+ .total-week { color: $blue; font-size: 12px; margin: 0 10px; }
+ .total-month { color: $green; font-size: 12px; }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
new file mode 100644
index 000000000..4a9ee8832
--- /dev/null
+++ b/app/assets/stylesheets/application.scss
@@ -0,0 +1,692 @@
+/*
+ *= require font-awesome
+ *= require highlight
+ *= require social-share-button
+ *= require jquery.atwho
+ *= require jquery.fluidbox
+ *= require_self
+ */
+
+@import "bootstrap.min";
+@import "vars";
+
+@mixin clearfix() {
+ &:before,
+ &:after {
+ content: " ";
+ display: table;
+ }
+ &:after {
+ clear: both;
+ }
+ & {
+ zoom: 1;
+ }
+}
+
+textarea,
+#preview {
+ &::-webkit-scrollbar {
+ width: 12px;
+ }
+
+ &::-webkit-scrollbar-track {
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: #f0f0f0;
+ border: 3px solid #fff;
+ border-radius: 9px;
+ }
+}
+
+.rucaptcha-image { width: 120px; }
+
+/* Bootstrap Theme */
+body {
+ background: #e5e5e5;
+ color: $black;
+ font-family: Helvetica, Arial, "PingFang SC", "Noto Sans", Roboto, "Microsoft Yahei", sans-serif;
+ letter-spacing: .03em;
+ padding-top: 65px;
+}
+
+.header {
+ .navbar {
+ background-color: #FFF;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
+ &.navbar-fixed-active {
+ box-shadow: 0 1px 1px rgba(0, 0, 0,.21);
+ .navbar-collapse.in {
+ border-bottom-color: #FFF;
+ box-shadow: 0 1px 1px rgba(0, 0, 0,.21);
+ }
+ }
+ margin-bottom: 15px;
+ border: 0px;
+ color: $blueText;
+ z-index: 1030;
+ }
+
+ .navbar-brand {
+ line-height: 100%;
+ color: #666 !important;
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-weight: bold;
+ margin-left: 0 !important;
+ border-bottom: 0px;
+ b { color: $blue; }
+ }
+ .navbar-default .navbar-brand,
+ .navbar-default .navbar-nav>li>a { color: #333; border-bottom: 3px solid #FFF; padding-bottom: 12px; }
+ .navbar-default .navbar-nav>li>a:active { background-color: $grayDark; border-bottom-color: #999; }
+ .navbar-inverse .navbar-nav .open .dropdown-menu .divider { color: #999 !important; }
+ .navbar-inverse .navbar-nav .open .dropdown-menu li.active a { border-bottom: 0px; color: $blue; }
+ .navbar-inverse .navbar-toggle { color: #333; padding: 3px 9px; font-size: 18px; border:1px solid rgba(255,255,255,0.1); background-color: rgba(255,255,255,0.05); }
+ .navbar-inverse .navbar-toggle:focus { background-color: rgba(255,255,255,0.05);}
+ .navbar-inverse .navbar-toggle:hover { background-color: rgba(255,255,255,0.1); }
+ .navbar-inverse .navbar-collapse,
+ .navbar-inverse .navbar-form { border-color: $blueText; }
+ .navbar-inverse .navbar-nav>.open>a,
+ .navbar-inverse .navbar-nav>.open>a:focus,
+ .navbar-inverse .navbar-nav>.open>a:hover { background: $grayDark; color: #333; }
+ .navbar-inverse .navbar-nav .open .dropdown-menu { position: absolute; left: auto; right: 0; background: #FFF; }
+ .navbar-inverse .navbar-nav .open .dropdown-menu>li>a { color: #333; }
+ .navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover { background-color: rgba(255,255,255,0.05); }
+ .navbar-inverse .navbar-nav .open .dropdown-menu .divider { background-color: #F0F0F0; }
+ .navbar-nav {
+ li.active a,
+ li.active a:focus,
+ li.active a:hover { color: $blue; background: transparent; }
+ }
+
+ #main-nav-menu {
+ .navbar-nav {
+ margin: 0px;
+ a { color: #333; transition: all .2s ease-in-out; }
+ li a:hover,
+ li.active a,
+ li.active a:focus,
+ li.active a:hover { border-bottom:3px solid $blue; color: $blue; background: transparent; padding-bottom: 12px;}
+ }
+ }
+
+ .user-bar {
+ li > a { padding-left: 11px !important; padding-right: 11px !important; }
+ .dropdown-avatar {
+ margin-right: 6px;
+ .dropdown-toggle { padding-top: 14px !important; padding-bottom: 9px !important;}
+ }
+ .navbar-toggle { display: none; margin: 0; padding: 6px 9px; }
+
+ .avatar-32 { display: inline-block; width: 24px; height: 24px; }
+ }
+
+ /* navbar-collapse */
+ .navbar-header {
+ .navbar-toggle {
+ position: absolute;
+ float: none;
+ left: 5px;
+ top: 5px;
+ padding: 6px 9px;
+ margin: 0;
+ }
+ }
+
+ .navbar-collapse {
+ float: left;
+ }
+
+ @media (max-width: 767px) {
+ .navbar-header {
+ text-align: center;
+ padding: 0;
+ margin-bottom: -41px;
+ .navbar-brand {
+ padding: 0px;
+ font-size: 16px; float: none; margin: 0 auto;
+ line-height: 45px;
+ }
+ }
+
+ .navbar-collapse {
+ float: none;
+ position: absolute;
+ width: 100%;
+ left: 0;
+ border-color: $gray !important;
+ box-shadow: 0 1px 1px rgba(0, 0, 0,.11);
+ margin: 0;
+ background: #FFF;
+ z-index: 10;
+ top: 50px;
+ }
+
+ .navbar-nav { float: left; margin: 0px; }
+ .navbar-nav>li { float: left; }
+ .navbar-nav.navbar-right { float:right; }
+ .user-bar {
+ .dropdown-toggle { display: none; }
+ .navbar-toggle { display: block; border-radius: 0px; }
+ .dropdown.open .navbar-toggle { background-color: $grayDark; }
+ }
+ }
+
+ .form-search {
+ font-size: 14px;
+ position: relative;
+ margin-top: 13px;
+ margin-right: 10px;
+
+ .fa {
+ color: #333;
+ &:hover { color: #666; }
+ }
+
+ .fa-search{
+ cursor: pointer;
+ position: absolute;
+ top: 6px;
+ right: 0;
+ transition: all .3s;
+ }
+ .btn-close {
+ position: absolute;
+ top: 6px;
+ right: 0px;
+ /*display: none;*/
+ cursor: pointer;
+ -webkit-transform: scale(0,0);
+ -moz-transform: scale(0,0);
+ transform: scale(0,0);
+ -webkit-transition: all .3s;
+ -moz-transition: all .3s;
+ transition: all .3s;
+ }
+ .form-control {
+ font-size: 12px;
+ border:none;
+ width: 0px;
+ height: 100%;
+ padding:6px 1px 4px 1px;
+ margin-left: 4px;
+ background: transparent;
+ transition: all .3s;
+ box-sizing: border-box;
+ color: #333;
+ &::-webkit-input-placeholder {
+ color: #ddd;
+ }
+ }
+ &.active {
+ .form-control {
+ width: 150px !important;
+ cursor: text;
+ }
+ .fa-search { left: 0; right: auto; }
+ .btn-close {
+ -webkit-transform: scale(1,1);
+ -moz-transform: scale(1,1);
+ transform: scale(1,1);
+ }
+ }
+
+ }
+}
+
+.sub-navbar {
+ padding: 8px 0;
+ background: #f9f9f9;
+ box-shadow: 0 1px 0px rgba(0, 0, 0, 0.02);
+ margin-top: -14px;
+ margin-bottom: 15px;
+
+ @media (max-width: 480px) {
+ margin-top: 0;
+ padding: 5px 0;
+
+ .container {
+ padding: 0 5px;
+ }
+ }
+}
+
+.sidebar.col-md-3 { padding-left: 0; }
+
+.dropdown-menu { border-color: #FFF; border-radius: 2px; border-top-left-radius: 0;
+ border-top-right-radius: 0; box-shadow: 0px 1px 2px rgba(0,0,0,0.15); }
+
+a { color: #333; }
+a:hover { color: #303030; }
+
+.btn {
+ border-radius: 3px;
+ border: 1px solid #ccc;
+ padding: 5px 12px;
+ outline: 0 !important;
+}
+
+
+.btn-default,
+.btn-default:visited { background: #FFF; border-color: #dadada; }
+.btn-default:hover { background: #f9f9f9; border-color: #ddd; }
+.btn-default.active,
+.btn-default.focus,
+.btn-default:active,
+.btn-default:focus,
+.btn-default:hover,
+.open > .dropdown-toggle.btn-default { background: #f0f0f0; border-color: #d0d0d0;}
+.btn-primary,
+.btn-primary:visited { background: $blue; border-color: $blueDark; color: #FFF; }
+.btn-primary:hover { background: $blueLight; border-color: $blue; }
+.btn-primary.active { background: $blueDark; border-color: $blueDark; }
+.btn-danger,
+.btn-danger:visited { background: $red; border-color: $redDark; }
+.btn-danger:hover { background: $redLight; border-color: $red;}
+.btn-danger.active { background: $redDark; border-color: $redDark; }
+.btn-warning,
+.btn-warning:visited { background:$yellow; border-color: $yellow; }
+.btn-warning:hover { background:$yellowLight; border-color: $yellowLight; }
+.btn-warning.active { background:$yellowDark; border-color: $yellowDark; }
+.btn-success,
+.btn-success:visited { background:$green; border-color: $greenDark; }
+.btn-success:hover { background:$greenLight; border-color: $green; }
+.btn-success.active { background:$greenDark; border-color: $greenDark; }
+.open>.dropdown-toggle.btn-primary { background: #0059C7; }
+
+.navbar-btn { background: rgba(255,255,255, 0.1); color: $blueText; border:0px;}
+.navbar-btn:hover { background: rgba(255,255,255, 0.15); color: $blueText;}
+
+.label { font-weight: normal; border-radius: 2px; padding: 2px 4px;}
+.label-default { background: $grayLabel; color: $grayLabelText;}
+.label-primary { background: $blue; }
+.label-warning { background: $yellowLabel; color: $yellowLabelText; }
+.label-info { background: $blueLabel; color: $blueLabelText; }
+.label-danger { background: $redLabel; color: $redLabelText; }
+.label-success { background: $greenLabel; color: $greenLabelText; }
+
+.alert {
+ padding: 8px 15px;
+ margin-bottom: 15px;
+ color: #333 !important;
+ border:0 ;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
+
+ .close {
+ font-size: 13px;
+ margin-top: 4px;
+ margin-right: 2px;
+ font-weight: normal;
+ }
+}
+.alert-info { background-color: #b8ccee; }
+.alert-success { background-color: #d1ffc5; }
+.alert-warning {
+ background-color: #fffbce;
+ .markdown {
+ hr { border-color: #c1b5b4;}
+ }
+}
+.alert-danger { background-color: #ffe6df; }
+
+.container > .alert {
+ margin-right: -15px;
+}
+
+.text-success { color: $green; }
+.text-primary { color: $blue; }
+.text-warning { color: $yellow; }
+.text-danger { color: $red; }
+
+.control-label { color: #333; font-weight: normal; }
+.control-label.checkbox { text-align: left; }
+.form-control {
+ border-radius: 3px;
+ border-color: #ddd;
+ box-shadow: inset 0 0 0px rgba(0,0,0, 0) !important;
+ transition: none;
+ -webkit-appearance: none;
+ &:focus { box-shadow: inset 0 0px 0px #fff; border-color: $blueLight; }
+ &[disabled], &[readonly] {
+ background: #f0f0f0;
+ }
+}
+textarea.form-control,
+#preview.form-control { }
+#preview.form-control { border-radius: 0px 3px 3px 3px; }
+textarea.form-control,
+#preview.form-control,
+textarea.form-control:focus { padding: 6px; border-bottom-width: 1px; }
+#preview.form-control { display:none; }
+.editor-toolbar {
+ .nav-pills { }
+ .nav-pills>li>a {
+ border-radius: 3px 3px 0 0;
+ padding: 3px 23px;
+ margin-right: 5px;
+ background-color: #f0f0f0;
+ color: #999;
+ }
+ .nav-pills>li.active>a:link,
+ .nav-pills>li.active>a:visited,
+ .nav-pills>li.active>a:hover { background-color: #d0d0d0; color: #666; }
+
+ .reply-to {
+ padding-top: 3px;
+ padding-left: 8px;
+ .close { font-size: 14px; margin-left: 5px; margin-top: 3px; }
+ }
+}
+textarea.topic-editor {
+ border-radius: 0px 3px 3px 3px;
+ &:focus {
+ border-color: #ddd;
+ }
+}
+form {
+ #preview { min-height: 500px; overflow-y: scroll; }
+ .help-block { font-size: 12px; color: #aaa; margin-bottom: 0px; }
+ .form-actions { }
+}
+
+.table-bordered>thead>tr>td, .table-bordered>thead>tr>th, .table>tr>th {
+ border-bottom-width: 1px;
+ background: #F0F0F0;
+ color: #888;
+ font-weight: normal;
+ font-size: 14px;
+}
+.table-striped>tr:nth-of-type(odd) {
+ background: #FFF;
+}
+.table-striped>tr:nth-of-type(even) {
+ background: #f9f9f9;
+}
+
+.input-group-addon {
+ background: #f0f0f0;
+ border-color: #ddd;
+ color:#999;
+ padding: 0 15px;
+}
+
+.input-group-btn > .btn {
+ padding: 6px 12px;
+ &:focus {
+ background: #FFF;
+ border-color: #ccc;
+ }
+}
+.input-group-btn:first-child > .btn {
+ border-right: 0px;
+}
+
+.input-group-captcha {
+ background: #FFF;
+ border-radius: 3px 0 0 3px;
+ img {
+ border-radius: 3px 0 0 3px;
+ }
+}
+
+.popover {
+ border-radius: 3px; border-color: #FFF; box-shadow: 0 1px 3px rgba(0, 0, 0,.18);
+}
+.popover>.arrow { border-bottom-color: #f0f0f0 !important; }
+
+.popover-content {
+ padding: 15px;
+}
+
+
+.nav-tabs {
+ li:first-child { margin-left: 15px; }
+}
+
+.pagination {
+ margin: 0;
+ &>li:first-child>a,
+ &>li:first-child>span { border-top-left-radius: 3px; border-bottom-left-radius: 3px; }
+
+ &>li:last-child>a,
+ &>li:last-child>span { border-top-right-radius: 3px; border-bottom-right-radius: 3px; }
+ li>a { color: #777; }
+
+ li>a,
+ .disabled>a,
+ li>span { border-color: #E0E0E0 !important; }
+
+ li>a:hover { color: #555; background: $gray; }
+
+ li.active>a,
+ li.active>a:hover { background-color: #CFDFFC; border-color: #BDCFEF !important; color: $blue; }
+}
+.pager {
+ margin: 0px;
+ .info {
+ line-height: 32px;
+ color: #ccc;
+ samp { color: #999; }
+ }
+ li>a,
+ li>span {
+ color: #666;
+ border-radius: 3px;
+ border: 0px;
+ background: transparent;
+ &:hover { background: #fff; }
+ }
+ li.disabled>a,
+ li.disabled>span {
+ color: #ddd;
+ background: transparent;
+ &:hover { color: #ddd; background: transparent; }
+ }
+}
+abbr { text-decoration: none; border-bottom: 0px; cursor: pointer; }
+
+kbd {
+ background-color: #f5f5f5; color: #999; border-radius: 2px; border-color: #fafafa;
+ box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.05);
+}
+
+.table>tbody>tr>td,
+.table>tbody>tr>th,
+.table>tfoot>tr>td,
+.table>tfoot>tr>th,
+.table>thead>tr>td,
+.table>thead>tr>th {
+ padding: 4px 5px;
+}
+
+
+.panel {
+ border: 0px;
+ border-radius: 3px;
+ border: 1px;
+ border-color: #e5e6e9 #dfe0e4 #d0d1d5;
+ margin-bottom: 15px;
+/* box-shadow: 0 0px 3px 2px rgba(0, 0, 0, 0.03); */
+
+ .panel-heading {
+ background: #fafafa;
+ padding: 6px 15px;
+ border-bottom-color: #eee;
+ color: #777;
+ }
+ .panel-footer {
+ padding: 6px 15px;
+ border-top-color: #eee;
+ background: #fafafa;
+ }
+}
+
+.nav-stacked { margin-bottom: 15px; }
+.nav-stacked.nav-pills>li {
+ &:first-child {
+ a { border-radius: 3px 3px 0 0 };
+ }
+ &:last-child {
+ a { border-radius: 0 0 3px 3px };
+ }
+}
+.nav-stacked.nav-pills>li>a {
+ text-align: left;
+ font-size: 14px;
+ border-radius: 0px;
+ box-shadow: 0 1px 0px rgba(0, 0, 0, 0.09);
+ padding: 8px 20px;
+ margin-bottom: 0px;
+ margin: 0;
+ background: #fff;
+ color: #666;
+
+ i.fa {
+ width: 20px;
+ }
+}
+.nav-stacked.nav-pills>li>a:hover { color: #555; background-color: #f9f9f9; }
+.nav-stacked.nav-pills>li.active>a,
+.nav-stacked.nav-pills>li.active>a:focus,
+.nav-stacked.nav-pills>li.active>a:hover {
+ color: $blue;
+ background-color: #fff;
+}
+
+/* Modal */
+.modal {
+ .modal-content {
+ h4.title { margin-bottom: 20px; font-size: 15px; }
+ border: 0px;
+ box-shadow: 0px 0px 15px rgba(0, 0, 0, 0.15), 0px 0px 1px 1px rgba(0, 0, 0, 0.05);
+
+ p { margin-bottom: 5px; }
+ }
+ .modal-header {
+ border: 0px;
+ padding-bottom: 0px;
+ .close { margin-top: -8px; font-weight: normal; outline: none !important; }
+ .modal-title {
+ font-size: 16px;
+ }
+ }
+ .modal-footer {
+ border-color: #e5e5e5;
+ }
+
+ @media (min-width: 768px) {
+ .modal-dialog {
+ width: 768px;
+ }
+ }
+}
+
+.modal-backdrop.in {
+ opacity: 1;
+ background-color: rgba(0, 0, 0, 0.1);
+}
+
+.list-group {
+ .list-group-item {
+ border-color: #eee;
+ }
+}
+
+.fa-spin {
+ -webkit-animation: fa-spin 0.8s infinite linear;
+ animation: fa-spin 0.8s infinite linear;
+}
+
+/* App Style */
+.opts {
+ color: #666;
+ a { }
+ a:link,
+ a:visited {
+ color: #666;
+ padding: 3px;
+ text-decoration: none;
+ }
+ a:hover { background: #f0f0f0; color: #666; text-decoration: none; }
+ .loading {
+ }
+}
+
+.turbolinks-progress-bar {
+ background-color: $blue !important;
+ height: 2px !important;
+}
+
+.pull-right.opts {
+ a { margin-left: 5px; margin-right: 0px; }
+}
+.avatar {
+ .uface,
+ .media-object { border-radius: 120px; }
+}
+.avatar-16 { width: 16px; height: 16px; border-radius: 120px; }
+.avatar-32 { width: 32px; height: 32px; border-radius: 120px; }
+.avatar-48 { width: 48px; height: 48px; border-radius: 120px; }
+.avatar-96 { width: 96px; height: 96px; border-radius: 120px; }
+@media (max-width: 480px) {
+ .avatar-48 { width: 32px; height: 32px; }
+}
+
+.uname { color:#666; }
+img.emoji { width:20px; height:20px; }
+.node-name {
+ background: #f0f0f0; padding: 1px 3px; color: #777; margin-right: 5px;
+ &:hover { color:#555; text-decoration:none; background:#e0e0e0; }
+}
+.fa.awesome {
+ font-size: 13px;
+ color: $red;
+}
+.notification-count {
+ .count { margin-left: 4px; display: none; font-size: 12px;}
+ .new {
+ color: $redLight !important;
+ .count { display: inline; line-height: 100%; }
+ }
+}
+.deleted { text-decoration: line-through; color: #e0e0e0; }
+.no-result { color: #aaa; padding-bottom: 20px; text-align: center;}
+
+.opts a.active {
+ .fa {
+ @extend .animated;
+ @extend .bounceIn;
+ color: #e76f3c;
+ }
+}
+
+.setting-menu { padding-right: 0;}
+
+@media (max-width: 480px) {
+ body { padding-top: 50px; }
+ .container { padding: 0; }
+ .panel { border-radius: 0; }
+ .row {
+ margin: 0;
+ .col-md-9,
+ .col-md-8,
+ .col-md-6,
+ .col-md-5,
+ .col-md-4,
+ .col-md-3 { padding: 0; }
+ }
+ .hidden-mobile { display: none !important; }
+ .pagination {
+ display: block;
+ li { display: none; }
+ li.prev,
+ li.next {
+ float: left; display: block;
+ a {
+ border-radius: 20px !important;
+ }
+ }
+ li.next { float: right; }
+ }
+}
diff --git a/app/assets/stylesheets/front.scss b/app/assets/stylesheets/front.scss
new file mode 100644
index 000000000..49c0260a7
--- /dev/null
+++ b/app/assets/stylesheets/front.scss
@@ -0,0 +1,852 @@
+/*
+ *= require application
+ *= require cal-heatmap
+ *= require tooltipster.bundle
+ *= require notifications
+ *= require home
+ *= require users
+ *= require teams
+ *= require search
+ *= require toc
+ *= require_self
+ */
+
+@import "vars";
+
+.node-list {
+ .node {
+ margin-bottom: 10px;
+ margin-top: 0px;
+ &:last-child { margin-bottom: 0px; }
+ .media-left { min-width: 130px; }
+ label { font-weight: normal; color: #aaa; text-align: right; }
+ .name {
+ margin-bottom: 10px;
+ width: 100px;
+ display: block;
+ float: left;
+ text-align: left;
+ a:link, a:visited { color: #333; }
+ }
+ }
+}
+
+.navbar {
+ &.fixed-title {
+ .navbar-topic-title { display: none; }
+ }
+ .navbar-topic-title {
+ display: none;
+ }
+}
+
+@media (min-width: 767px) {
+ .navbar {
+ &.fixed-title {
+ .navbar-topic-title { display: block; }
+ #main-nav-menu { display: none; }
+ .nav-search { display: none; }
+ }
+ .navbar-topic-title {
+ display: none;
+ height: 50px;
+ overflow: hidden;
+
+ a.topic-title {
+ display: inline;
+ text-decoration: none;
+ overflow: hidden;
+ line-height: 0;
+ max-width: 400px;
+ color: #000;
+ &:hover,
+ &:active,
+ &:visited { color: #000; }
+
+ i.fa { color: #999; margin-left: 3px; }
+ i.fa-diamond { color: $red; }
+ i.fa-check { color: $green; }
+ }
+ .node { line-height: 50px; margin-left: 18px; color: #777; margin-right: 3px; }
+ h1 {
+ margin: 0; padding: 0; font-size: 18px; line-height: 50px;
+ }
+ }
+ }
+}
+
+@media (min-width: 992px) {
+ .navbar {
+ .navbar-topic-title {
+ a.topic-title {
+ max-width: 780px;
+ }
+ }
+ }
+}
+
+.move-page-buttons {
+ position: fixed;
+ bottom: 10px;
+ right: 10px;
+ width: 45px;
+ .btn { background: #fff; }
+}
+
+.node-header {
+ .container {
+ padding: 0 30px;
+ }
+ @media (max-width: 480px) {
+ .container {
+ padding: 0 5px;
+ }
+
+ .filter {
+ .all-nodes { display: none; }
+ }
+ }
+ .title {
+ font-size: 24px; color: #333;
+ margin-bottom: 8px;
+ .total { color: #999; font-size: 14px; margin-left: 10px; }
+ }
+
+ .summary {
+ p:last-child { margin-bottom: 0; }
+ }
+
+ .filter {
+ &>li {
+ margin-right: 0px;
+ &.active {
+ a:link,
+ a:visited,
+ a:hover {
+ color: #000;
+ border-bottom: 2px dotted #666666;
+ }
+ }
+ &>a {
+ background: transparent !important;
+ border-radius: 0px;
+ line-height: 100%;
+ padding: 8px 8px;
+ margin-right: 5px;
+ font-size: 14px;
+ border-bottom: 2px dotted transparent;
+ display: inline-block;
+ color: #606060;
+
+ &:hover {
+ border-color: #eee;
+ background: transparent;
+ }
+
+ &.all-nodes {
+ border-radius: 3px;
+ outline: 0 !important;
+ margin-right: 15px;
+ background: #f0f0f0;
+ border: 0;
+
+ .caret-right {
+ display: inline-block;
+ width: 0;
+ height: 0;
+ margin-left: 10px;
+ vertical-align: middle;
+ border-left: 4px solid;
+ border-top: 4px solid transparent;
+ border-bottom: 4px solid transparent;
+ }
+
+ &:hover {
+ border: 0;
+ background: #e0e0e0;
+ }
+ }
+
+ }
+ }
+ }
+}
+
+.topics-node {
+ .node { display: none; }
+}
+
+.topics {
+ .panel-body {
+ padding: 0 15px;
+ }
+
+ .no-result {
+ padding-bottom: 0;
+ margin-bottom: 0;
+ padding: 100px 0;
+ }
+
+ .topics-group:first-child {
+ @media (max-width: 991px) {
+ .topic:last-child { border-bottom: 1px solid #F0F0F0; }
+ }
+ }
+ .topic {
+ min-height: 68px;
+ border-bottom: 1px solid #F0F0F0;
+ padding: 10px 15px;
+ margin: 0 -15px;
+ vertical-align: top;
+ &:last-child { border-bottom: 0px; }
+ .avatar { text-align:center; }
+ .title {
+ font-size: 15px;
+ margin-bottom: 0;
+ a:link,
+ a:visited {
+ color: #222; font-weight: 400; line-height: 30px;
+ .node { color: #777; margin-right: 3px; }
+ }
+ a:active,
+ a:hover {
+ color: #555;
+ .node { color: #555; }
+ text-decoration: none;
+ }
+ i.fa { color: #999; margin-left: 3px; }
+ i.fa-diamond { color: $red; }
+ i.fa-check { color: $green; }
+ }
+
+ .info {
+ color: #ABA8A6; font-size: 12px; margin-top: 0;
+ a { color: #ABA8A6; }
+ }
+
+ .count {
+ width: 100px; text-align: right; padding-top: 15px;
+ a:link,
+ a:hover,
+ a:visited {
+ line-height: 11px;
+ color: #fff;
+ min-width: 32px;
+ text-align: center;
+ border-radius: 80px; padding: 3px 8px 3px 8px;
+ display: inline-block;
+ text-decoration: none;
+ }
+
+ a:link { background: #CFD3E6; }
+ a:hover { background: #A9BBDC; }
+ a.state-true,
+ a:visited { background: #f0f0f0; }
+ }
+ }
+}
+
+.topic-detail {
+ margin-bottom: 15px;
+ .panel-heading {
+ padding: 15px;
+ transition: all .3s;
+ h1 {
+ margin-top:0;
+ font-size: 20px; color:#333; text-align: left; line-height:150%; margin-bottom:8px;
+ .node { color: #777; margin-right: 3px; }
+ i.fa-check { color: $green; font-size: 16px; }
+ }
+ .avatar { text-align:right; }
+ }
+
+ .label-awesome {
+ font-size: 13px;
+ background: #FCF8F7;
+ padding: 5px 15px;
+ border-bottom: 1px solid #f3f0f0;
+ color: #eb5424;
+
+ a { color: #aAa5a4; }
+ }
+ .label-nopoint {
+ font-size: 13px;
+ background:#FCF8E3;
+ padding: 3px 15px;
+ border-top: 1px solid #FAEBCC;
+ color: #ae938B;
+
+ a { color: #ae938B; }
+ }
+
+ .info {
+ color:#c0c0c0;
+ font-size:12px;
+ a { color: #444; }
+ .node {
+ color: #999;
+ font-weight: bold;
+ }
+ .user-name { color: $blue; font-size: 13px; }
+ .team-name {
+ font-size: 13px;
+ color: $green;
+ }
+ em { font-style:normal; }
+
+ .opts {
+ a {
+ margin-left: 5px; color: #999;
+ &:hover { color: #333; }
+ }
+ }
+ }
+}
+
+#topic-sidebar {
+ position: fixed;
+ display: none;
+ width: 260px;
+
+ @media (min-width: 960px) {
+ display: block;
+ width: 242px;
+ }
+
+ @media (min-width: 1200px) {
+ display: block;
+ width: 292px;
+ }
+
+ .group {
+ text-align: center; margin-bottom: 20px;
+ }
+
+ .buttons {
+ margin-top: 20px;
+ .likes {
+ a { display: block; width: 90px; margin: 0 auto; border-radius: 5px; padding: 10px 0;}
+ a:link, a:hover, a:visited { text-decoration: none; color: #333; }
+ a:hover { background: rgba(0, 0, 0, 0.03); }
+ i.fa { display: block; font-size: 40px; color: #666; }
+ a.active {
+ i.fa { color: #e76f3c; }
+ }
+ span { display: block; color: #666; }
+ }
+ }
+ .reply-buttons {
+ text-align:center;
+ .total { margin-bottom: 10px; }
+ }
+ a.btn-move-page { color: #666; }
+}
+
+#replies {
+ margin-bottom: 15px;
+ .panel-body {
+ padding-top: 0px;
+ padding-bottom: 0px;
+ }
+ .info {
+ .uname { color: #777; font-weight: bold; }
+ .opts {
+ a {
+ font-size: 13px;
+ margin-left: 5px; color: #999;
+ &:hover { color: #333; }
+ }
+ a.edit { display: none; }
+ }
+ }
+ .reply {
+ margin: 0 -15px;
+ padding: 15px 15px;
+ position: relative;
+ border-bottom: 1px solid #eee;
+ padding-left: 74px;
+ &.reply-system,
+ &.reply-deleted {
+ padding: 8px 15px;
+ font-size: 12px;
+ color: #666;
+ border-bottom: 1px solid #F0F0F0;
+
+ img.media-object {
+ border-radius: 180px;
+ display: inline-block; margin-right: 3px; vertical-align: text-bottom;
+ }
+
+ .time { margin-left: 4px; color: #aaa; }
+ .ban-reason { color: #444; border-bottom: 1px dashed #eee; }
+ }
+ .infos { min-height: 48px; }
+ .avatar { position: absolute; top: 15px; left: 15px; }
+ &:last-child {
+ border-bottom: 0px;
+ }
+ &.none {
+ text-align: center;
+ color: #999;
+ min-height: 32px;
+ }
+ &.light { background:#F7F2FC; }
+ &.popular { background:#fffce9; }
+
+ .info {
+ .name {
+ font-weight: bold;
+ font-size: 13px;
+ a { color: #555; }
+ }
+ color: #999;
+ margin-bottom: 6px;
+ font-size: 12px;
+ .floor { color: #7AA87A; }
+ a.time {
+ color: #999;
+ border-bottom: 1px dashed #ccc;
+ text-decoration: none !important;
+ cursor: pointer;
+ }
+ }
+
+ .opts {
+ a {
+ display: inline-block;
+ vertical-align: baseline;
+ line-height: 22px;
+ padding: 2px 5px;
+ height: 22px;
+ min-width: 22px;
+ text-align: center;
+ }
+ }
+
+ .reply-to-block {
+ padding: 8px 15px;
+ background: #f7f7f7;
+ border-radius: 3px;
+ margin-bottom: 15px;
+ .info {
+ a {
+ color: #666;
+ }
+ .media-object { display: inline-block; margin-right: 5px; vertical-align: top; }
+ margin: 0;
+
+ .user-name { font-weight: bold; }
+ }
+ .markdown {
+ margin-top: 10px;
+ font-size: 14px;
+ p {
+ font-size: 14px;
+ }
+ }
+ }
+
+ .markdown {
+ pre {
+ margin-right: 0px;
+ margin-left: 0px;
+ }
+ }
+
+ @media (min-width: 1026px) {
+ .hideable { display: none; }
+ }
+ &:hover {
+ .hideable { display: inline-block; }
+ }
+ }
+
+ @media (max-width: 480px) {
+ .reply {
+ padding-left: 57px;
+ }
+ }
+}
+
+#node-selector {
+ .panel { box-shadow: 0 0 0; padding: 0; margin: 0; }
+ .panel-heading { display: none; }
+ .panel-body { padding: 0 20px; margin: 0; }
+}
+
+#notifications {
+ .panel-heading {
+ .clean-button { margin-left: 10px; }
+ }
+ .notification {
+ position: relative;
+ margin-bottom: 15px;
+ padding-bottom: 15px;
+ border-bottom: 1px solid $gray;
+ &:last-child { margin-bottom: 0px; border-bottom: 0px; padding-bottom: 0px; }
+ .unread { color: $blueLight; font-size: 10px; position: absolute; right: 5px; top: 20px;}
+ .avatar { text-align:center; }
+ .info { color: #999; margin-bottom: 8px; font-size: 13px; }
+ .date { font-size: 12px; color: #aaa;}
+ }
+}
+
+.sidebar {
+ .panel-body {
+ word-break: break-all;
+ }
+}
+
+
+.api-doc {
+ .route-list {
+ padding: 20px 0;
+ border-right: 2px dashed #ddd;
+ li {
+ line-height: 200%; color:#999;
+ a:link,
+ a:visited { color: #404040 !important; text-decoration: underline !important; }
+ }
+
+ }
+ .route {
+ margin-top: 15px;
+ h5 {
+ color: #333;
+ border-bottom: 1px solid $gray;
+ margin: 0; margin-bottom: 10px;
+ padding: 5px 0 0 0;
+ label {
+ font-size: 12px;
+ font-weight: normal; display: inline-block; width: 50px; color: $blackLight;
+ }
+ }
+ .content { margin: 0 15px; }
+ .desc {
+ h4 { border: 0px; font-size: 13px !important; margin:0; color: #999; }
+ }
+ h6 { color: #999; }
+ table.params {
+ td.field { width: 80px; }
+ td.type { width: 70px; }
+ td.required { width: 50px; }
+ td.values { width: 180px; }
+ td.default { width: 100px; }
+
+ }
+ }
+}
+
+// Fix searchbox style
+.bs-searchbox .form-control {
+ float: none;
+}
+
+@media (min-width: 744px) and (max-width: 1200px) {
+ .sidebar .panel .panel-body .feed-button {
+ float: none !important;
+ margin-top: 15px;
+ }
+}
+/* Social Share Button */
+.social-share-button {
+ height: 16px;
+ a {
+ i.fa { font-size: 24px; margin: 0 4px; }
+ &:link, &:visited { color: #777; }
+ &:hover {
+ color: $blueLight;
+ }
+ }
+}
+.popover-content {
+ .social-share-button { display: block; }
+}
+
+/* Markdown Styles */
+.markdown {
+ position:relative;
+ letter-spacing: .03em;
+ font-size: 15px;
+ text-overflow: ellipsis;
+ word-wrap: break-word;
+ img, iframe { max-width: 100%; border: 0; }
+ p,
+ pre,
+ ul,
+ ol,
+ hr,
+ blockquote { margin-bottom: 20px; }
+ p:last-child,
+ blockquote:last-child,
+ pre:last-child,
+ ul:last-child,
+ ol:last-child,
+ hr:last-child { margin-bottom:0; }
+
+ p { font-size: 15px; line-height: 26px; }
+ hr { border:2px dashed $gray; border-bottom:0px; margin-left: auto; margin-right: auto; width: 50%; }
+ blockquote {
+ margin-left: 0 18px 20px 18px;
+ padding: 0;
+ padding-left: 32px;
+ border: 0px;
+ quotes: "\201C""\201D""\2018""\2019";
+ position: relative;
+ font-size: 14px;
+ line-height: 1.45;
+ p { display:inline; color: #999; }
+ &:before,
+ &:after {
+ display: block;
+ content: "\201C";
+ font-size: 35px;
+ position: absolute;
+ font-family: serif;
+ left: 0px;
+ top: 0px;
+ color: #aaa;
+ }
+ }
+ pre {
+ font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
+ font-size: 12px;
+ background-color: #f9f9f9;
+ border: 0px;
+ border-top: 1px solid #f0f0f0;
+ border-bottom: 1px solid #f0f0f0;
+ margin: 0 -15px 20px -15px;
+ padding: 5px 15px;
+ color: #444;
+ overflow: auto;
+ border-radius: 0px;
+ code {
+ display: block;
+ line-height: 150%;
+ padding: 0 !important;
+ font-size: 12px !important;
+ background-color: #f9f9f9 !important;
+ border: none!important;
+ }
+ }
+ pre::-webkit-scrollbar {
+ height: 8px;
+ width: 8px; }
+
+ pre::-webkit-scrollbar-thumb:horizontal {
+ width: 25px;
+ background-color: #ccc;
+ -webkit-border-radius: 4px; }
+
+ pre::-webkit-scrollbar-track-piece {
+ margin-bottom: 10px;
+ background-color: #e5e5e5;
+ border-bottom-left-radius: 4px 4px;
+ border-bottom-right-radius: 4px 4px;
+ border-top-left-radius: 4px 4px;
+ border-top-right-radius: 4px 4px; }
+
+ pre::-webkit-scrollbar-thumb:vertical {
+ height: 25px;
+ background-color: #ccc;
+ -webkit-border-radius: 4px;
+ -webkit-box-shadow: 0 1px 1px white; }
+
+ code {
+ display: inline-block;
+ font-size: 12px!important;
+ background-color: #f5f5f5 !important;
+ border: 0px;
+ color: #444 !important;
+ padding: 1px 4px !important;
+ margin: 2px;
+ border-radius: 3px;
+ word-break: break-all;
+ line-height: 20px;
+ }
+ a:link,
+ a:visited {
+ color:#0069D6 !important; text-decoration: none !important;
+ }
+ a:hover { text-decoration: underline !important; color:#00438A !important; }
+ a.mention-floor { color:#60b566 !important; margin-right: 3px; }
+ a.mention {
+ color:#777 !important; font-weight: bold;
+ margin-right: 2px;
+ b { color:#777 !important; font-weight: normal; }
+ }
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ font-weight:bold; text-align:left;
+ margin-top: 0px; margin-bottom: 20px;
+ }
+ h1 { font-size: 26px !important; text-align: center; margin-bottom: 30px !important; }
+ h2,
+ h3,
+ h4 {
+ text-align: left;
+ font-weight: bold;
+ font-size: 16px !important;
+ line-height: 100%;
+ margin: 0; color: #555;
+ margin-bottom: 20px;
+ border-bottom: 1px solid #eee;
+ padding-bottom: 10px;
+ }
+ h2 { font-size: 20px !important; color: #111; }
+ h3 { font-size: 18px !important; color: #333; }
+ h5, h6 { font-size: 15px; line-height: 100%; color: #777; }
+ h6 { font-size: 14px; color: #999; }
+
+ strong { color:#000; }
+ ul,
+ ol {
+ list-style-type: square;
+ margin:0;
+ margin-bottom: 20px;
+ padding:0px 20px;
+ p,
+ blockquote,
+ pre { margin-bottom:8px; }
+ li { line-height:1.6em; padding:2px 0; color:#333; }
+ ul { list-style-type: circle; margin-bottom: 0px; }
+ }
+ ol {
+ list-style-type: decimal;
+ ol { list-style-type: lower-alpha; margin-bottom: 0px; }
+ }
+
+ img { vertical-align: top; max-width: 100%; }
+ a.zoom-image { cursor: zoom-in; }
+ a.at_floor { color: #60B566 !important; }
+ a.at_user, a.user-mention { color: #0069D6 !important; }
+ img.twemoji { width: 20px; }
+}
+
+
+footer {
+ margin-top: 10px; margin-bottom: 20px;
+ color: $blackLight;
+ a { color: #666; }
+ .links { color: #ddd; }
+ .socials {
+ a { font-size: 20px; margin-right: 8px; }
+ }
+}
+
+.notify-updated {
+ display: none;
+ padding: 4px 15px;
+ margin-bottom: 20px;
+ text-align: left;
+ background: #FDF8A6; border:1px solid #F5E3A4; color: $redLabelText;
+ a:link,
+ a:visited { color: $yellowText; }
+}
+
+.dz-preview { display: none; }
+textarea.div-dropzone-focus { border-color: #BBE1C9; background: #fafafa; }
+
+.emoji-modal {
+ .modal-dialog {
+ max-width: 496px;
+ .close { margin-top: 0px; }
+ }
+ .modal-header { border: 0px; padding: 8px; }
+ .modal-body { padding: 0 8px 8px 8px; }
+ .twemoji { width: 20px; height: 20px; }
+
+ .nav > li > a {
+ padding: 5px 8px;
+ }
+
+ .nav-tabs li:first-child { margin-left: 8px; }
+
+ .tab-pane {
+ padding: 0px;
+ height: 180px;
+ overflow: scroll;
+
+ &::-webkit-scrollbar {
+ width: 4px;
+ border-radius: 3px;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: #e0e0e0;
+ }
+
+ a {
+ padding: 5px;
+ display: inline-block; width: 30px; height: 30px;
+ &:hover { background: #f0f0f0; }
+ }
+ }
+ .modal-footer {
+ padding: 8px;
+ text-align: left;
+ font-size: 16px;
+ .emoji { width: 48px; height: 48px; margin-right: 10px; }
+ }
+}
+
+
+.popover-liked-users {
+ .avatar-16 {
+ display: inline-block;
+ margin: 5px 0;
+ }
+}
+
+.comments {
+ .comment {
+ padding: 15px;
+ margin: 0 -15px;
+ border-bottom: 1px solid #f0f0f0;
+ &:first-child { padding-top: 0; }
+ &:last-child { border: 0; padding-bottom: 0;}
+
+ .info {
+ font-size: 13px;
+ color: #999;
+ }
+ }
+}
+
+.panel-body {
+ .heading { font-size: 16px; color: #777; font-weight: bold; padding-bottom: 10px; border-bottom: 1px solid #eee; margin-bottom: 15px; }
+ form {
+ margin-bottom: 25px;
+ &:last-child { margin-bottom: 0; }
+ }
+}
+
+.reward-image {
+ border-radius: 3px;
+ padding: 20px;
+ background: #FFF;
+ text-align: center;
+ img { max-width: 240px; }
+}
+
+#reward-modal {
+ padding-top: 50px;
+ text-align: center;
+ .modal-dialog {
+ max-width: 750px;
+ min-height: 300px;
+ }
+ .reward-image { display: inline-block; padding: 0px 20px 10px 20px; }
+ .message {
+ margin: 10px auto 0 auto;
+ max-width: 580px;
+ font-size: 16px;
+ text-align: center;
+ .user-info { margin-bottom: 15px; }
+ .media-object { display: inline-block; }
+ i.fa { color: #aaa; }
+ }
+}
diff --git a/app/assets/stylesheets/highlight.css b/app/assets/stylesheets/highlight.css
new file mode 100644
index 000000000..990855217
--- /dev/null
+++ b/app/assets/stylesheets/highlight.css
@@ -0,0 +1,61 @@
+.highlight .hll { background-color: #ffffcc }
+.highlight .c { color: #B0B2B0; font-style: italic } /* Comment */
+.highlight .err { } /* Error */
+.highlight .k { color: #AA22FF; font-weight: bold } /* Keyword */
+.highlight .o { color: #666666 } /* Operator */
+.highlight .cm { color: #B0B2B0; font-style: italic } /* Comment.Multiline */
+.highlight .cp { color: #B0B2B0 } /* Comment.Preproc */
+.highlight .c1 { color: #B0B2B0; font-style: italic } /* Comment.Single */
+.highlight .cs { color: #B0B2B0; font-weight: bold } /* Comment.Special */
+.highlight .gd { color: #A00000 } /* Generic.Deleted */
+.highlight .ge { font-style: italic } /* Generic.Emph */
+.highlight .gr { color: #FF0000 } /* Generic.Error */
+.highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */
+.highlight .gi { color: #00A000 } /* Generic.Inserted */
+.highlight .go { color: #808080 } /* Generic.Output */
+.highlight .gp { color: #000080; font-weight: bold } /* Generic.Prompt */
+.highlight .gs { font-weight: bold } /* Generic.Strong */
+.highlight .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
+.highlight .gt { color: #0040D0 } /* Generic.Traceback */
+.highlight .kc { color: #AA22FF; font-weight: bold } /* Keyword.Constant */
+.highlight .kd { color: #AA22FF; font-weight: bold } /* Keyword.Declaration */
+.highlight .kn { color: #AA22FF; font-weight: bold } /* Keyword.Namespace */
+.highlight .kp { color: #AA22FF } /* Keyword.Pseudo */
+.highlight .kr { color: #AA22FF; font-weight: bold } /* Keyword.Reserved */
+.highlight .kt { color: #00BB00; font-weight: bold } /* Keyword.Type */
+.highlight .m { color: #666666 } /* Literal.Number */
+.highlight .s { color: #BB4444 } /* Literal.String */
+.highlight .na { color: #BB4444 } /* Name.Attribute */
+.highlight .nb { color: #AA22FF } /* Name.Builtin */
+.highlight .nc { color: #0000FF } /* Name.Class */
+.highlight .no { color: #880000 } /* Name.Constant */
+.highlight .nd { color: #AA22FF } /* Name.Decorator */
+.highlight .ni { color: #999999; font-weight: bold } /* Name.Entity */
+.highlight .ne { color: #D2413A; font-weight: bold } /* Name.Exception */
+.highlight .nf { color: #00A000 } /* Name.Function */
+.highlight .nl { color: #A0A000 } /* Name.Label */
+.highlight .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */
+.highlight .nt { color: #008000; font-weight: bold } /* Name.Tag */
+.highlight .nv { color: #B8860B } /* Name.Variable */
+.highlight .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */
+.highlight .w { color: #bbbbbb } /* Text.Whitespace */
+.highlight .mf { color: #666666 } /* Literal.Number.Float */
+.highlight .mh { color: #666666 } /* Literal.Number.Hex */
+.highlight .mi { color: #666666 } /* Literal.Number.Integer */
+.highlight .mo { color: #666666 } /* Literal.Number.Oct */
+.highlight .sb { color: #BB4444 } /* Literal.String.Backtick */
+.highlight .sc { color: #BB4444 } /* Literal.String.Char */
+.highlight .sd { color: #BB4444; font-style: italic } /* Literal.String.Doc */
+.highlight .s2 { color: #BB4444 } /* Literal.String.Double */
+.highlight .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */
+.highlight .sh { color: #BB4444 } /* Literal.String.Heredoc */
+.highlight .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */
+.highlight .sx { color: #008000 } /* Literal.String.Other */
+.highlight .sr { color: #BB6688 } /* Literal.String.Regex */
+.highlight .s1 { color: #BB4444 } /* Literal.String.Single */
+.highlight .ss { color: #B8860B } /* Literal.String.Symbol */
+.highlight .bp { color: #AA22FF } /* Name.Builtin.Pseudo */
+.highlight .vc { color: #B8860B } /* Name.Variable.Class */
+.highlight .vg { color: #B8860B } /* Name.Variable.Global */
+.highlight .vi { color: #B8860B } /* Name.Variable.Instance */
+.highlight .il { color: #666666 } /* Literal.Number.Integer.Long */
\ No newline at end of file
diff --git a/app/assets/stylesheets/home.scss b/app/assets/stylesheets/home.scss
new file mode 100644
index 000000000..57e6e5f50
--- /dev/null
+++ b/app/assets/stylesheets/home.scss
@@ -0,0 +1,88 @@
+@import "vars";
+
+#home_index { line-height:160%; }
+
+.home-icons {
+ .item {
+ text-align: center;
+ margin-bottom: 15px;
+ border-radius: 3px;
+ background: #FFF;
+ border: 1px;
+ border-color: #e5e6e9 #dfe0e4 #d0d1d5 #dfe0e4;
+
+ .icon {
+ display: block;
+ a { display: block; padding: 20px 15px; }
+ .fa { font-size: 60px; }
+ }
+ .text {
+ display: block;
+ text-align: left;
+ background: #F5F5F5;
+ border-top: 1px solid #E9E9E9;
+ font-size: 14px; font-weight:bold;
+
+ a { display:block; color: #666; padding: 6px 15px; }
+ a:hover { text-decoration: none; }
+ .fa { margin-top: 3px; }
+ border-radius: 0 0 3px 3px;
+ }
+ &:hover {
+ opacity: 0.75;
+ }
+ }
+ .item1 {
+ .icon {
+ .fa { color: $redLight; }
+ }
+ }
+ .item3 {
+ .icon {
+ .fa { color: $blueLight; }
+ }
+ }
+ .item2 {
+ .icon {
+ .fa { color: $yellowLight; }
+ }
+ }
+ .item4 {
+ .icon {
+ .fa { color: $greenLight; }
+ }
+ }
+
+}
+
+h2 {
+ font-size: 12px;
+ color: #999;
+ line-height: 100%;
+ margin-bottom: 10px;
+ text-align: center;
+}
+
+#last_topics { float:left; width:450px; }
+#hot_topics { float:right; width:450px; }
+
+.node-topics {
+ margin-bottom:0px;
+ .head { display:none; }
+}
+.location-list {
+ .name {
+ a { color: #666; margin: 6px; display: inline-block; }
+ }
+}
+
+.home_suggest_topics {
+ .topics {
+ .topic {
+ .title {
+ height: 30px;
+ overflow: hidden;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/notifications.scss b/app/assets/stylesheets/notifications.scss
new file mode 100644
index 000000000..4d558f08c
--- /dev/null
+++ b/app/assets/stylesheets/notifications.scss
@@ -0,0 +1,61 @@
+.notifications {
+ .panel-heading {
+ font-size: 16px;
+ line-height: 32px;
+ }
+ .panel-body {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+
+ .notification-group {
+ padding: 10px 0;
+
+ .group-title {
+ color: #aaa;
+ border-bottom: 1px solid #eee;
+ padding: 5px 0;
+ }
+ }
+
+ .notification {
+ margin: 0 -15px;
+ padding: 10px 15px;
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ a { color: #555; }
+
+ &.unread {
+ color: #444;
+
+ .media-heading {
+ font-weight: bold;
+ }
+
+ a { color: #222; text-decoration: underline; }
+ }
+
+
+ .media-content {
+ color: #444;
+
+ a { color: #999 !important; }
+ p { font-size: 14px; margin-bottom: 6px; }
+ p:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ .user-avatar {
+ img { width: 32px; height: 32px; border-radius: 120px; }
+ }
+
+ .media-right {
+ min-width: 40px;
+ color: #AAA;
+ font-size: 13px;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/search.scss b/app/assets/stylesheets/search.scss
new file mode 100644
index 000000000..54a516bb3
--- /dev/null
+++ b/app/assets/stylesheets/search.scss
@@ -0,0 +1,35 @@
+@import "vars";
+
+.search-results {
+ .result {
+ margin-bottom: 20px;
+
+ em { color: $red; font-style: normal;}
+
+ .title {
+ font-size: 15px;
+ line-height: 160%;
+
+ .badge { background: $grayLabel; color: $grayLabelText; font-weight: normal; font-size: 12px; margin-left: 4px; }
+ }
+ .info {
+ margin-bottom: 6px;
+ font-size: 12px;
+ .url a { color: $greenText; }
+
+ .date { color: #999; margin-left: 8px; }
+ }
+
+ .desc {
+ color: #666;
+ font-size: 13px;
+ word-break: break-all;
+ em { color: $redLight; }
+ }
+ }
+
+ .user {
+ .info { margin-top: 4px; font-size: 14px; }
+ .info.number { color: #666; font-size: 13px; }
+ }
+}
diff --git a/app/assets/stylesheets/teams.scss b/app/assets/stylesheets/teams.scss
new file mode 100644
index 000000000..2f191945d
--- /dev/null
+++ b/app/assets/stylesheets/teams.scss
@@ -0,0 +1,82 @@
+@import 'vars';
+
+.team-header {
+ background: #fafafa;
+ padding-top: 15px;
+ padding-bottom: 15px;
+ border-bottom: #d0d1d5;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
+
+ .media {
+ .media-left {
+ padding-left: 20px; padding-right: 20px;
+ vertical-align: middle;
+ }
+ .media-right {
+ padding: 0 10px;
+
+ .icons {
+ width: 140px;
+ text-align: right;
+ display: block;
+ padding-top: 13px;
+ a {
+ margin-right: 10px; font-size: 18px; color: #666;
+ &:hover {
+ color: #333;
+ }
+ }
+ }
+ }
+ .media-heading {
+ font-size: 22px;
+ padding-top: 13px;
+ span.location {
+ color: #666;
+ margin-left: 14px; font-weight: normal; font-size: 14px;
+ }
+ }
+ }
+}
+
+.team-menu {
+ border-bottom: 0px;
+ li {
+ a {
+ &:hover {
+ border-bottom: 0px;
+ }
+ }
+ }
+}
+
+.team-sidebar {
+ .members {
+ .media-object { display: inline-block; margin: 5px; }
+ }
+}
+
+.team-users {
+ .table {
+ margin-top: 15px;
+ }
+ tr.team-user {
+ td { padding: 14px; vertical-align: middle; }
+ padding: 14px;
+ margin-top: 0px;
+ margin-bottom: 0px;
+ border-bottom: 1px solid #e0e0e0;
+
+ &:last-child { border-bottom: 0px; }
+
+ .avatar { width: 48px; }
+ .name { font-size: 14px; }
+
+ .role { min-width: 100px;}
+ .buttons { width: 180px; }
+ }
+}
+
+.team-users-count {
+ margin-left: 5px;
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/toc.scss b/app/assets/stylesheets/toc.scss
new file mode 100644
index 000000000..650a69654
--- /dev/null
+++ b/app/assets/stylesheets/toc.scss
@@ -0,0 +1,66 @@
+@import "vars";
+.toc-container {
+ display: none;
+ z-index: 999;
+ margin-left: 32px;
+ margin-bottom: 32px;
+
+ .dropdown-menu {
+ background: #FFF;
+ border-radius: 3px;
+ padding: 15px;
+ border: 1px solid #FFF;
+ width: 300px;
+ z-index: 1031;
+ }
+
+ .list-container {
+ max-height: 400px;
+ overflow-y: scroll;
+
+ .list {
+ padding: 0;
+ margin-bottom: 0 !important;
+ }
+ }
+}
+
+.toc-item {
+ &.toc-level-2 {
+ margin-left: 0;
+ }
+
+ &.toc-level-3 {
+ margin-left: 15px;
+ }
+
+ &.toc-level-4 {
+ margin-left: 30px;
+ }
+
+ &.toc-level-5 {
+ margin-left: 45px;
+ }
+
+ &.toc-level-6 {
+ margin-left: 60px;
+ }
+}
+
+@media (max-width: 768px) {
+ .toc-container {
+ .dropdown-menu {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ max-height: 100%;
+ padding: 15px;
+
+ .list-container {
+ max-height: 100%;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/turbolinks-app.scss b/app/assets/stylesheets/turbolinks-app.scss
new file mode 100644
index 000000000..376b86341
--- /dev/null
+++ b/app/assets/stylesheets/turbolinks-app.scss
@@ -0,0 +1,339 @@
+@import "vars";
+
+$primary: #e23c28;
+$primary-dark: #af2913;
+
+.hide-ios { display: none !important; }
+body {
+ padding-top: 0;
+ background: #e9e3e3;
+}
+
+.btn-primary {
+ background: $primary !important;
+ border-color: $primary-dark !important;
+
+ &.active,
+ &:active {
+ background: $primary-dark !important;
+ border-color: $primary-dark !important;
+ }
+}
+
+.btn-danger {
+ background: #555 !important;
+ border-color: #333 !important;
+
+ &:active {
+ background: #333 !important;
+ border-color: #111 !important;
+ }
+}
+
+.header, .footer,
+.index-sections, .index-locations { display: none; }
+#node-selector .panel {
+ display: block;
+ padding: 0px;
+ box-shadow: 0 0 0;
+ border: 0px;
+ .panel-body { border:0; }
+ .media-left { display: none; }
+}
+
+.container {
+ padding: 0;
+}
+
+.sidebar { padding: 0; display: none; }
+#main > .row {
+ margin: 0;
+ .col-md-9,
+ .col-md-8,
+ .col-md-6,
+ .col-md-5,
+ .col-md-4,
+ .col-md-3 { padding: 0; }
+}
+
+.panel {
+ border-radius: 0;
+ margin-bottom: 0 !important;
+ box-shadow: 0 0 0;
+ border-top: 0px solid #f0f0f0;
+ border-bottom: 0px solid #ece2d7;
+
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ .panel-heading,
+ .panel-footer {
+ background: #fffbf8;
+ border-color: #ece2d7;
+ border-radius: 0 !important;
+ }
+}
+
+.container > .alert {
+ margin-right: 0;
+}
+.alert {
+ border-radius: 0;
+ border: 0;
+ box-shadow: 0;
+ padding: 10px;
+ font-size: 14px;
+ border-top: 0;
+ margin-bottom: 0px;
+
+ .close {
+ opacity: .4;
+ margin-top: 2px;
+ }
+}
+.alert-success {
+ background: #c3ffb8;
+ border-color: #b6f3b8;
+ color: #00a21c;
+}
+.alert-warning {
+ background: #fff8b8;
+ color: #CD8546;
+ border-color: #faebcc;
+}
+
+.form-control {
+ -webkit-appearance: none;
+
+ &:focus {
+ border-color: #ccc;
+ }
+}
+
+.pagination { display: none; }
+
+.markdown {
+ pre {
+ margin-left: -15px;
+ margin-right: -15px;
+
+ code {
+ margin: 8px;
+ }
+ }
+}
+
+.topics {
+ .topic-list-heading {
+ display: none;
+ }
+}
+
+.editor-toolbar {
+ .dropdown-menu {
+ left: auto;
+ right: 0;
+ }
+}
+
+.topic-editor {
+ height: 140px;
+}
+
+.no-result { display: none; }
+
+.new_reply {
+ .submit-buttons {
+ #reply-button { display: block; width: 100%; }
+ .help-inline,
+ .pull-right { display: none; }
+ }
+}
+
+.edit-reply {
+ .panel-heading { display: none; }
+ .col-xs-2 { display: block; width: 50%; }
+}
+
+.topics {
+ .topic {
+ min-height: 60px;
+ &:active {
+ background: #f5f5f5;
+ }
+
+ .avatar {
+ padding-top: 3px;
+ }
+
+ .title {
+ margin-bottom: 0px;
+ a {
+ font-size: 14px;
+ line-height: 22px !important;
+ }
+ a:hover,
+ a:active {
+ text-decoration: none;
+ }
+ }
+
+ .info {
+ a.node {
+ color: #909090;
+ }
+ }
+
+ &.topic-visited {
+ .title {
+ a:link,
+ a:visited { color: #666; }
+ }
+ .info {
+ color: #bbb;
+ a { color: #bbb; }
+ }
+ .count {
+ a:link,
+ a:visited {
+ color: #ddd;
+ }
+ }
+ }
+
+ .count {
+ padding-top: 0px;
+ padding-left: 0;
+ width: auto;
+ text-align: right;
+ a:link,
+ a:hover,
+ a:visited {
+ line-height: 11px;
+ min-width: 28px;
+ text-align: right;
+ border-radius: 80px;
+ padding: 0;
+ display: inline-block;
+ text-decoration: none;
+ }
+
+ a:link { background: transparent; color: #666;}
+ a:hover { background: transparent; }
+ a.state-true,
+ a:visited { background: transparent; color: #ddd; }
+ }
+ }
+}
+
+.topic-detail {
+ .label-awesome {
+ padding: 10px 15px;
+ margin-top: -1px;
+ background: #fff2c4;
+ border: 0;
+ color: $primary;
+ .fa.awesome {
+ color: $primary;
+ }
+ a { color: #cccaa2; }
+ }
+
+ .panel-heading {
+ h1 {
+ font-size: 18px;
+ }
+ }
+
+ .info {
+ color: #999;
+
+ .user-name {
+ color: #666;
+ }
+ .node { color: $primary; }
+ }
+
+ .markdown {
+ .zoom-image {
+ overflow: hidden;
+ display: block;
+ margin: 8px -15px;
+ img { width: 100%; }
+ }
+
+ pre {
+ background: #fffefc !important;
+ border-color: #f9f4f0 !important;
+ code {
+ background: #fffefc !important;
+ }
+ }
+ }
+}
+
+.notifications {
+ .notification-group {
+ .group-title {
+ margin: 0 -15px;
+ padding-left: 15px;
+ padding-right: 15px;
+ }
+ }
+}
+
+.opts {
+ a:link,
+ a:visited {
+ color: #444 !important;
+ }
+ a.active {
+ .fa { color: $primary; }
+ }
+}
+
+.page-topics {
+ .panel {
+ &.topic-detail,
+ replies,
+ reply {
+ border-bottom-width: 1px;
+ }
+ }
+}
+
+.page-pages {
+ .wiki-sidebar { display: block; }
+}
+
+.page-users {
+ .sidebar {
+ display: block;
+ .profile {
+ .opts { display: none; }
+ }
+ }
+
+ #user_github_repos { display: none; }
+ .nav-tabs {
+ margin-top: 15px;
+ li > a {
+ border: 0;
+ padding: 8px 8px;
+ }
+
+ }
+
+ .node-topics {
+ border-bottom: 1px solid #ddd;
+ }
+}
+
+.setting-menu {
+ padding-left: 0;
+ .nav-stacked.nav-pills > li a {
+ border-radius: 0 !important;
+ border-left: 0;
+ }
+}
diff --git a/app/assets/stylesheets/users.scss b/app/assets/stylesheets/users.scss
new file mode 100644
index 000000000..c8eefb991
--- /dev/null
+++ b/app/assets/stylesheets/users.scss
@@ -0,0 +1,266 @@
+@import "vars";
+
+.subnav {
+ margin-bottom:-18px;
+ .nav-tabs {
+ border-bottom:0px;
+ padding-left:20px;
+ }
+ .nav-tabs > li > a:hover {
+ border-color:transparent;
+ background:none;
+ text-decoration:underline;
+ }
+ .nav-tabs > .active > a, .nav-tabs > .active > a:hover {
+ color: #555555;
+ background-color: #ffffff;
+ border: 1px solid #ddd;
+ border-bottom-color: transparent;
+ cursor: default;
+ }
+}
+
+.page-users {
+ .nav-tabs {
+ border-bottom: 0px;
+ }
+}
+
+.node-topics {
+ border-bottom:1px solid #ddd;
+ tbody > tr > td { padding: 8px; color: #666; }
+ td.title {
+ a:link,
+ a:visited { font-size: 15px; text-decoration: none; }
+ a:hover { text-decoration: underline; }
+ em { font-style:normal; font-size:12px; color:#bbb; }
+ i.icon { margin-bottom: -1px; }
+ }
+ td.node {
+ a { color: #666; }
+ }
+ tr.head {
+ td { border-top:none; padding-top:14px; color:#CCC; font-weight:bold; font-size:12px; }
+ }
+ tr.odd {
+ td { background: #fafafa; }
+ }
+ tr.topic {
+ td.author {
+ width:80px;
+ a { color:#666; font-weight:bold; }
+ }
+ }
+}
+
+.recent-topics {
+ ul {
+ li {
+ .title {
+ font-size:14px;
+ }
+ i.icon { margin-bottom: -1px; }
+ i.fa-diamond { color: $red; }
+ .info { margin-top:3px; font-size:12px; color:#bbb; }
+ .node {
+ margin-right:5px;
+ color: #777; margin-right: 3px;
+ }
+ }
+ }
+}
+
+.recent-replies {
+ padding: 0 15px;
+ margin-bottom: 0;
+ li {
+ &.list-group-item {
+ border-radius: 0 !important;
+ border-left: 0px;
+ border-right: 0px;
+ padding: 10px 0;
+ border-color: #ddd;
+ }
+ &.list-group-item:even {
+ background: #f9f9f9;
+ }
+ .title {
+ font-size:15px;
+ .info { font-size:12px; color:#bbb; }
+ }
+ .body {
+ a { color: #666; }
+ margin-top:6px; color: #666;
+ p { font-size:13px; }
+ img { max-width:680px; }
+ }
+ }
+}
+
+
+.row > .span13 { margin-left:0;}
+#main .userinfo h1 { text-align: left; display:inline; }
+.userinfo {
+ .tagline { text-align:left; margin-top:-8px; margin-bottom:20px;}
+ .media-right {
+ padding-left: 15px;
+ text-align: center;
+ .avatar {
+ margin-bottom: 10px;
+ }
+ }
+ .list-group { margin-bottom: 0px; }
+ li {
+ border-color: #f0f0f0;
+ font-size:13px;
+ label { color:#999; margin-right:8px; display:inline-block; width:80px; text-align: right;}
+ }
+
+ .panel-footer {
+ }
+}
+
+.bio {
+ font-size:12px; line-height:180%;
+ p:last-child { margin-bottom:0; }
+}
+
+.replies ul {
+ margin:0;
+ h6 { color:#999; font-weight:normal; }
+ li { line-height:180%; border-bottom:1px solid #ddd; list-style: none;}
+ blockquote { line-height:160%; }
+}
+
+.content > .tabs {
+ border-bottom:2px solid #ccc;
+ .active { margin-bottom:0;}
+}
+
+table.node-topics {
+ td {
+ a { color:#333; font-weight:normal; }
+ i.fa-diamond { color: $red; }
+ }
+ td.replied-at { width:80px; }
+}
+
+.user-list {
+ h2 { font-size:14px; margin:0; }
+ .user {
+ text-align:center; margin-bottom:20px;
+ overflow: hidden;;
+ .avatar {
+ img { width:48px; height:48px; margin: 0 auto;}
+ margin-bottom: 5px;
+ }
+ .name {
+ a { color: #333; }
+ }
+ }
+}
+
+.bloced-users {
+ .item {
+ text-align: left; margin-bottom: 10px;
+ .media-object { display: inline; }
+ }
+}
+
+.sidebar {
+ padding-right: 0;
+ .profile {
+ .avatar {
+ .level { margin-top: 6px; text-align: center; }
+ }
+ .item { margin-bottom: 5px; }
+ .item a { color: #666; }
+ .number { color: #999; }
+ .counts {
+ color: #999;
+ span { color: #666; }
+ }
+ .follow-info {
+ border-top: 1px solid #f0f0f0;
+ text-align: center; margin-top: 15px; padding-top: 15px;
+ a { display:block; text-decoration: none; }
+ a.counter {
+ font-size: 32px; color: $blue;
+ &:hover { color: $blueLight; }
+ }
+ a.text {
+ color: #999;
+ }
+ }
+ .buttons {
+ border-top: 1px solid #f0f0f0;
+ margin-top: 15px; padding-top: 15px;
+ }
+ .social {
+ font-size: 18px;
+ a { color: #999; margin-right: 8px; }
+ a:hover { color: #666; }
+ }
+ .tagline {
+ border-top: 1px solid #f0f0f0;
+ margin-top: 10px;
+ color: #999;
+ line-height: 100%;
+ padding: 10px; padding-bottom: 0;
+ }
+ }
+
+ .user-teams {
+ .media-object { display: inline-block; margin:4px 2px; }
+ }
+}
+
+.user-card {
+ margin-bottom: 15px;
+ padding-left: 15px;
+ .media-heading {
+ font-weight:bold;
+ a { color: #333; }
+ }
+ .infos {
+ color:#999; font-size: 12px;
+ .item { margin-top: 5px; }
+ }
+}
+
+.user-profile-fields {
+ margin-top: 20px;
+ border-top: 1px solid #eee;
+ padding-top: 20px;
+
+ .field {
+ padding: 2px 0;
+ label { color: #666; display: inline-block; width: 100px; margin-right: 10px; }
+ .value {
+ a { text-decoration: underline; }
+ }
+ }
+}
+
+#user_github_repos {
+ .more { text-align:right; }
+ ul { margin:0; }
+ li {
+ .title {
+ position:relative; margin-bottom: 5px;
+ a { color:#333; font-weight:bold; }
+ .watchers { position:absolute; top:2px; right:0; color: #999; }
+ }
+ .desc { font-size:12px; color:#888; padding: 0; margin: 0; }
+ }
+}
+
+.user-activity-graph {
+ overflow-x: scroll;
+ text-align: center;
+ svg { margin: 0 auto; }
+}
+
+.avatar-preview {
+ .media-object { display: inline-block; }
+}
diff --git a/app/assets/stylesheets/vars.scss b/app/assets/stylesheets/vars.scss
new file mode 100644
index 000000000..353ec2e5e
--- /dev/null
+++ b/app/assets/stylesheets/vars.scss
@@ -0,0 +1,89 @@
+$black: #222527;
+$blackLight: #9CA4A9;
+
+$grayLabel: #EBEDEE;
+$gray: #F0F4F6;
+$grayDark: #EEF3F5;
+$grayLabelText: #9A9DA0;
+
+$red: #EB5424;
+$redLight: #F86334;
+$redDark: #CD3A14;
+$redText: #8B2523;
+$redLabel: #FF6969;
+$redLabelText: #8B2523;
+
+$yellow: #FFCB00;
+$yellowDark: #F5C21C;
+$yellowText: #B58B03;
+$yellowLight: #FFD52F;
+$yellowLabel: #FFF280;
+$yellowLabelText: #CD8546;
+
+$blueText: #A1C3EE;
+$blue: #356DD0;
+$blueLight: #317DDA;
+$blueDark: #0D54AB;
+$blueLabel: #ADD0FF;
+$blueLabelText: #4A5E9E;
+
+$green: #45C722;
+$greenLight: #3BD54E;
+$greenDark: #39B618;
+$greenText: #23863F;
+$greenLabel: #81D573;
+$greenLabelText: #1B8909;
+
+
+/* Animations */
+.animated {
+ -webkit-animation-duration: 0.5s;
+ animation-duration: 0.5s;
+ -webkit-animation-fill-mode: both;
+ animation-fill-mode: both;
+}
+
+@-webkit-keyframes bounceIn {
+ 0% {
+ opacity: 0;
+ -webkit-transform: scale(.5);
+ }
+
+ 50% {
+ opacity: 1;
+ -webkit-transform: scale(1.5);
+ }
+
+ 70% {
+ -webkit-transform: scale(.9);
+ }
+
+ 100% {
+ -webkit-transform: scale(1);
+ }
+}
+
+@keyframes bounceIn {
+ 0% {
+ opacity: 0;
+ transform: scale(.5);
+ }
+
+ 50% {
+ opacity: 1;
+ transform: scale(1.5);
+ }
+
+ 70% {
+ transform: scale(.9);
+ }
+
+ 100% {
+ transform: scale(1);
+ }
+}
+
+.bounceIn {
+ -webkit-animation-name: bounceIn;
+ animation-name: bounceIn;
+}
\ No newline at end of file
diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb
new file mode 100644
index 000000000..d67269728
--- /dev/null
+++ b/app/channels/application_cable/channel.rb
@@ -0,0 +1,4 @@
+module ApplicationCable
+ class Channel < ActionCable::Channel::Base
+ end
+end
diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb
new file mode 100644
index 000000000..17644fe73
--- /dev/null
+++ b/app/channels/application_cable/connection.rb
@@ -0,0 +1,15 @@
+module ApplicationCable
+ class Connection < ActionCable::Connection::Base
+ identified_by :current_user_id
+
+ def connect
+ self.current_user_id = find_verified_user_id
+ end
+
+ protected
+
+ def find_verified_user_id
+ cookies.signed[:user_id] || nil
+ end
+ end
+end
diff --git a/app/channels/notifications_channel.rb b/app/channels/notifications_channel.rb
new file mode 100644
index 000000000..910fd36e2
--- /dev/null
+++ b/app/channels/notifications_channel.rb
@@ -0,0 +1,8 @@
+class NotificationsChannel < ApplicationCable::Channel
+ def subscribed
+ logger.info "current connections: #{ActionCable.server.connections.count}"
+ if self.current_user_id
+ stream_from "notifications_count/#{self.current_user_id}"
+ end
+ end
+end
diff --git a/app/channels/replies_channel.rb b/app/channels/replies_channel.rb
new file mode 100644
index 000000000..963253c5e
--- /dev/null
+++ b/app/channels/replies_channel.rb
@@ -0,0 +1,5 @@
+class RepliesChannel < ApplicationCable::Channel
+ def follow(data)
+ stream_from "topics/#{data['topic_id']}/replies"
+ end
+end
diff --git a/app/controllers/account_controller.rb b/app/controllers/account_controller.rb
new file mode 100644
index 000000000..dc9447440
--- /dev/null
+++ b/app/controllers/account_controller.rb
@@ -0,0 +1,31 @@
+# Devise User Controller
+class AccountController < Devise::RegistrationsController
+ before_action :require_no_sso!, only: [:new, :create]
+
+ def new
+ super
+ end
+
+ def edit
+ redirect_to setting_path
+ end
+
+ # POST /resource
+ def create
+ build_resource(sign_up_params)
+ resource.login = params[resource_name][:login]
+ resource.email = params[resource_name][:email]
+ if verify_rucaptcha?(resource) && resource.save
+ sign_in(resource_name, resource)
+ end
+ end
+
+ private
+
+ # Overwrite the default url to be used after updating a resource.
+ # It should be edit_user_registration_path
+ # Note: resource param can't miss, because it's the super caller way.
+ def after_update_path_for(_)
+ edit_user_registration_path
+ end
+end
diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb
new file mode 100644
index 000000000..d11f2bed3
--- /dev/null
+++ b/app/controllers/admin/application_controller.rb
@@ -0,0 +1,17 @@
+module Admin
+ class ApplicationController < ::ApplicationController
+ layout "admin"
+
+ before_action :authenticate_user!
+ before_action :require_admin
+ before_action :set_active_menu
+
+ def require_admin
+ render_404 unless current_user.admin?
+ end
+
+ def set_active_menu
+ @current = ["/" + ["admin", controller_name].join("/")]
+ end
+ end
+end
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
new file mode 100644
index 000000000..c23ad0b66
--- /dev/null
+++ b/app/controllers/admin/applications_controller.rb
@@ -0,0 +1,59 @@
+module Admin
+ class ApplicationsController < Admin::ApplicationController
+ before_action :set_application, only: [:show, :edit, :update, :destroy]
+
+ def index
+ @applications = Doorkeeper::Application.all
+ if params[:q].present?
+ qstr = "%#{params[:q].downcase}%"
+ @applications = @applications.where("name LIKE ?", qstr)
+ end
+ if params[:level].present?
+ @applications = @applications.where(level: params[:level])
+ end
+ if params[:uid].present?
+ @applications = @applications.where(uid: params[:uid])
+ end
+ @applications = @applications.order(id: :desc).page(params[:page])
+ end
+
+ def show
+ end
+
+ def new
+ @application = Doorkeeper::Application.new
+ end
+
+ def edit
+ end
+
+ def create
+ @application = Doorkeeper::Application.new(params[:doorkeeper_application].permit!)
+
+ if @application.save
+ redirect_to(admin_applications_path, notice: "Application 创建成功。")
+ else
+ render action: "new"
+ end
+ end
+
+ def update
+ if @application.update(params[:doorkeeper_application].permit!)
+ redirect_to(admin_applications_path, notice: "Application 更新成功。")
+ else
+ render action: "edit"
+ end
+ end
+
+ def destroy
+ @application.destroy
+ redirect_to(admin_applications_path, notice: "删除成功。")
+ end
+
+ private
+
+ def set_application
+ @application = Doorkeeper::Application.find(params[:id])
+ end
+ end
+end
diff --git a/app/controllers/admin/comments_controller.rb b/app/controllers/admin/comments_controller.rb
new file mode 100644
index 000000000..2b98319cc
--- /dev/null
+++ b/app/controllers/admin/comments_controller.rb
@@ -0,0 +1,35 @@
+module Admin
+ class CommentsController < Admin::ApplicationController
+ before_action :set_comment, only: [:show, :edit, :update, :destroy]
+ respond_to :js, :html, only: [:destroy]
+
+ def index
+ @comments = Comment.recent.includes(:user).page(params[:page])
+ end
+
+ def edit
+ end
+
+ def update
+ if @comment.update(params[:comment].permit!)
+ redirect_to admin_comments_path(@admin_comment), notice: "Comment was successfully updated."
+ else
+ render action: "edit"
+ end
+ end
+
+ def destroy
+ @comment.destroy
+ respond_with do |format|
+ format.html { redirect_to admin_comments_path }
+ format.js { render layout: false }
+ end
+ end
+
+ private
+
+ def set_comment
+ @comment = Comment.find(params[:id])
+ end
+ end
+end
diff --git a/app/controllers/admin/home_controller.rb b/app/controllers/admin/home_controller.rb
new file mode 100644
index 000000000..6ac90a439
--- /dev/null
+++ b/app/controllers/admin/home_controller.rb
@@ -0,0 +1,7 @@
+module Admin
+ class HomeController < Admin::ApplicationController
+ def index
+ @recent_topics = Topic.recent.limit(5)
+ end
+ end
+end
diff --git a/app/controllers/admin/locations_controller.rb b/app/controllers/admin/locations_controller.rb
new file mode 100644
index 000000000..05feba012
--- /dev/null
+++ b/app/controllers/admin/locations_controller.rb
@@ -0,0 +1,26 @@
+module Admin
+ class LocationsController < Admin::ApplicationController
+ before_action :set_location, only: [:show, :edit, :update, :destroy]
+
+ def index
+ @locations = Location.hot.page(params[:page])
+ end
+
+ def edit
+ end
+
+ def update
+ if @location.update(params[:location].permit!)
+ redirect_to(admin_locations_path, notice: "Location 更新成功。")
+ else
+ render action: "edit"
+ end
+ end
+
+ private
+
+ def set_location
+ @location = Location.find(params[:id])
+ end
+ end
+end
diff --git a/app/controllers/admin/nodes_controller.rb b/app/controllers/admin/nodes_controller.rb
new file mode 100644
index 000000000..af35d71bb
--- /dev/null
+++ b/app/controllers/admin/nodes_controller.rb
@@ -0,0 +1,48 @@
+module Admin
+ class NodesController < Admin::ApplicationController
+ before_action :set_node, only: [:show, :edit, :update, :destroy]
+
+ def index
+ @nodes = Node.sorted.includes(:section)
+ end
+
+ def show
+ end
+
+ def new
+ @node = Node.new
+ end
+
+ def edit
+ end
+
+ def create
+ @node = Node.new(params[:node].permit!)
+
+ if @node.save
+ redirect_to(admin_nodes_path, notice: "Node was successfully created.")
+ else
+ render action: "new"
+ end
+ end
+
+ def update
+ if @node.update(params[:node].permit!)
+ redirect_to(admin_nodes_path, notice: "Node was successfully updated.")
+ else
+ render action: "edit"
+ end
+ end
+
+ def destroy
+ @node.destroy
+ redirect_to(admin_nodes_url)
+ end
+
+ private
+
+ def set_node
+ @node = Node.find(params[:id])
+ end
+ end
+end
diff --git a/app/controllers/admin/photos_controller.rb b/app/controllers/admin/photos_controller.rb
new file mode 100644
index 000000000..844c7b34f
--- /dev/null
+++ b/app/controllers/admin/photos_controller.rb
@@ -0,0 +1,20 @@
+module Admin
+ class PhotosController < Admin::ApplicationController
+ before_action :set_photo, only: [:show, :destroy]
+
+ def index
+ @photos = Photo.recent.includes(:user).page(params[:page])
+ end
+
+ def destroy
+ @photo.destroy
+ redirect_to(admin_photos_url)
+ end
+
+ private
+
+ def set_photo
+ @photo = Photo.find(params[:id])
+ end
+ end
+end
diff --git a/app/controllers/admin/replies_controller.rb b/app/controllers/admin/replies_controller.rb
new file mode 100644
index 000000000..32788d28e
--- /dev/null
+++ b/app/controllers/admin/replies_controller.rb
@@ -0,0 +1,35 @@
+module Admin
+ class RepliesController < Admin::ApplicationController
+ before_action :set_reply, only: [:show, :edit, :update, :destroy]
+
+ def index
+ @replies = Reply.unscoped
+ if params[:q].present?
+ qstr = "%#{params[:q].downcase}%"
+ @replies = @replies.where("body LIKE ?", qstr)
+ end
+ if params[:login].present?
+ u = User.find_by_login(params[:login])
+ @replies = @replies.where("user_id = ?", u.try(:id))
+ end
+ @replies = @replies.order(id: :desc).includes(:topic, :user)
+ @replies = @replies.page(params[:page])
+ end
+
+ def show
+ if @reply.topic.blank?
+ redirect_to admin_replies_path, alert: "帖子已经不存在"
+ end
+ end
+
+ def destroy
+ @reply.destroy
+ end
+
+ private
+
+ def set_reply
+ @reply = Reply.unscoped.find(params[:id])
+ end
+ end
+end
diff --git a/app/controllers/admin/sections_controller.rb b/app/controllers/admin/sections_controller.rb
new file mode 100644
index 000000000..28e5885e0
--- /dev/null
+++ b/app/controllers/admin/sections_controller.rb
@@ -0,0 +1,49 @@
+module Admin
+ class SectionsController < Admin::ApplicationController
+ before_action :set_section, only: [:show, :edit, :update, :destroy]
+
+ def index
+ @sections = Section.all
+ end
+
+ def show
+ end
+
+ def new
+ @section = Section.new
+ end
+
+ def edit
+ end
+
+ def create
+ @section = Section.new(params[:section].permit!)
+
+ if @section.save
+ redirect_to(admin_sections_path, notice: "Section was successfully created.")
+ else
+ render action: "new"
+ end
+ end
+
+ def update
+ if @section.update(params[:section].permit!)
+ redirect_to(admin_sections_path, notice: "Section was successfully updated.")
+ else
+ render action: "edit"
+ end
+ end
+
+ def destroy
+ @section.destroy
+
+ redirect_to(admin_sections_url)
+ end
+
+ private
+
+ def set_section
+ @section = Section.find(params[:id])
+ end
+ end
+end
diff --git a/app/controllers/admin/site_configs_controller.rb b/app/controllers/admin/site_configs_controller.rb
new file mode 100644
index 000000000..3c65a4aa6
--- /dev/null
+++ b/app/controllers/admin/site_configs_controller.rb
@@ -0,0 +1,32 @@
+module Admin
+ class SiteConfigsController < Admin::ApplicationController
+ before_action :set_setting, only: [:edit, :update]
+
+ def index
+ end
+
+ def edit
+ end
+
+ def update
+ if @site_config.value != setting_param[:value]
+ @site_config.value = setting_param[:value]
+ @site_config.save
+ @site_config.expire_cache
+ redirect_to admin_site_configs_path, notice: "保存成功."
+ else
+ redirect_to admin_site_configs_path
+ end
+ end
+
+ def set_setting
+ @site_config = Setting.find_by(var: params[:id]) || Setting.new(var: params[:id])
+ end
+
+ private
+
+ def setting_param
+ params[:setting].permit!
+ end
+ end
+end
diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb
new file mode 100644
index 000000000..7a296303a
--- /dev/null
+++ b/app/controllers/admin/stats_controller.rb
@@ -0,0 +1,19 @@
+module Admin
+ class StatsController < Admin::ApplicationController
+ # GET /stats
+ # params:
+ # model - Model 名称
+ # by - day, week, month
+ def index
+ result = { model: params[:model] }
+ result[:count] = klass.unscoped.count
+ result[:week_count] = klass.unscoped.where("created_at >= ?", Date.today.beginning_of_week).count
+ result[:month_count] = klass.unscoped.where("created_at >= ?", Date.today.beginning_of_month).count
+ render json: result.as_json
+ end
+
+ def klass
+ params[:model].camelize.safe_constantize
+ end
+ end
+end
diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb
new file mode 100644
index 000000000..beabec2a1
--- /dev/null
+++ b/app/controllers/admin/topics_controller.rb
@@ -0,0 +1,74 @@
+module Admin
+ class TopicsController < Admin::ApplicationController
+ before_action :set_topic, only: [:show, :edit, :update, :destroy, :undestroy, :suggest, :unsuggest]
+
+ def index
+ @topics = Topic.unscoped
+ if params[:q].present?
+ qstr = "%#{params[:q].downcase}%"
+ @topics = @topics.where("title LIKE ?", qstr)
+ end
+ if params[:login].present?
+ u = User.find_by_login(params[:login])
+ @topics = @topics.where("user_id = ?", u.try(:id))
+ end
+ @topics = @topics.order(id: :desc)
+ @topics = @topics.includes(:user).page(params[:page])
+ end
+
+ def show
+ end
+
+ def new
+ @topic = Topic.new
+ end
+
+ def edit
+ end
+
+ def create
+ @topic = Topic.new(params[:topic].permit!)
+
+ if @topic.save
+ redirect_to(admin_topics_path, notice: "Topic was successfully created.")
+ else
+ render action: "new"
+ end
+ end
+
+ def update
+ if @topic.update(params[:topic].permit!)
+ redirect_to(admin_topics_path, notice: "Topic was successfully updated.")
+ else
+ render action: "edit"
+ end
+ end
+
+ def destroy
+ @topic.destroy_by(current_user)
+
+ redirect_to(admin_topics_path)
+ end
+
+ def undestroy
+ @topic.update_attribute(:deleted_at, nil)
+ redirect_to(admin_topics_path)
+ end
+
+ def suggest
+ @topic.update_attribute(:suggested_at, Time.now)
+ redirect_to(@topic, notice: "Topic:#{params[:id]} suggested.")
+ end
+
+ def unsuggest
+ @topic.update_attribute(:suggested_at, nil)
+ redirect_to(@topic, notice: "Topic:#{params[:id]} unsuggested.")
+ end
+
+ private
+
+ def set_topic
+ @topic = Topic.unscoped.find(params[:id])
+ end
+ end
+end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
new file mode 100644
index 000000000..1b3c140c6
--- /dev/null
+++ b/app/controllers/admin/users_controller.rb
@@ -0,0 +1,82 @@
+module Admin
+ class UsersController < Admin::ApplicationController
+ def index
+ @users = User.all
+ if params[:q].present?
+ qstr = "%#{params[:q].downcase}%"
+ @users = @users.where("lower(login) LIKE ? or lower(email) LIKE ?", qstr, qstr)
+ end
+ if params[:type].present?
+ @users = @users.where(type: params[:type])
+ end
+ @users = @users.order(id: :desc).page(params[:page])
+ end
+
+ def show
+ @user = User.find(params[:id])
+ end
+
+ def new
+ @user = User.new
+ @user._id = nil
+ end
+
+ def edit
+ @user = User.find(params[:id])
+ end
+
+ def create
+ @user = User.new(params[:user].permit!)
+ @user.email = params[:user][:email]
+ @user.login = params[:user][:login]
+ @user.state = params[:user][:state]
+ @user.verified = params[:user][:verified]
+
+ if @user.save
+ redirect_to(admin_users_path, notice: "User was successfully created.")
+ else
+ render action: "new"
+ end
+ end
+
+ def update
+ @user = User.find_by_login!(params[:id])
+ @user.email = params[:user][:email]
+ @user.login = params[:user][:login]
+ @user.state = params[:user][:state]
+ @user.verified = params[:user][:verified]
+
+ if @user.update(params[:user].permit!)
+ redirect_to(edit_admin_user_path(@user.id), notice: "User was successfully updated.")
+ else
+ render action: "edit"
+ end
+ end
+
+ def destroy
+ @user = User.find(params[:id])
+ if @user.user_type == :user
+ @user.soft_delete
+ else
+ @user.destroy
+ end
+
+ redirect_to(admin_users_url)
+ end
+
+ def clean
+ @user = User.find_by_login!(params[:id])
+ if params[:type] == "replies"
+ # 为了避免误操作删除大量,限制一次清理 10 条,这个数字对刷垃圾回复的够用了。
+ ids = Reply.unscoped.where(user_id: @user.id).recent.limit(10).pluck(:id)
+ replies = Reply.unscoped.where(id: ids)
+ topics = Topic.where(id: replies.collect(&:topic_id))
+ replies.delete_all
+ topics.each(&:touch)
+
+ count = Reply.unscoped.where(user_id: @user.id).count
+ redirect_to edit_admin_user_path(@user.id), notice: "最近 10 条删除,成功 #{@user.login} 还有 #{count} 条回帖。"
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v3/application_controller.rb b/app/controllers/api/v3/application_controller.rb
new file mode 100644
index 000000000..61f54ea59
--- /dev/null
+++ b/app/controllers/api/v3/application_controller.rb
@@ -0,0 +1,95 @@
+module Api
+ module V3
+ # @abstract
+ class ApplicationController < ActionController::API
+ include ActionController::Caching
+ include ActionView::Helpers::OutputSafetyHelper
+ include ApplicationHelper
+
+ helper_method :can?, :current_user, :current_ability, :meta
+ helper_method :admin?, :owner?, :markdown, :raw
+
+ # 参数值不在允许的范围内
+ # HTTP Status 400
+ #
+ # { error: 'ParameterInvalid', message: '原因' }
+ class ParameterValueNotAllowed < ActionController::ParameterMissing
+ attr_reader :values
+ def initialize(param, values) # :nodoc:
+ @param = param
+ @values = values
+ super("param: #{param} value only allowed in: #{values}")
+ end
+ end
+
+ # 无权限返回信息
+ # HTTP Status 403
+ #
+ # { error: 'AccessDenied', message: '原因' }
+ class AccessDenied < StandardError; end
+
+ # 数据不存在
+ # HTTP Status 404
+ #
+ # { error: 'ResourceNotFound', message: '原因' }
+ class PageNotFound < StandardError; end
+
+ rescue_from(ActionController::ParameterMissing) do |err|
+ render json: { error: "ParameterInvalid", message: err }, status: 400
+ end
+ rescue_from(ActiveRecord::RecordInvalid) do |err|
+ render json: { error: "RecordInvalid", message: err }, status: 400
+ end
+ rescue_from(AccessDenied) do |err|
+ render json: { error: "AccessDenied", message: err }, status: 403
+ end
+ rescue_from(ActiveRecord::RecordNotFound) do
+ render json: { error: "ResourceNotFound" }, status: 404
+ end
+
+ def requires!(name, opts = {})
+ opts[:require] = true
+ optional!(name, opts)
+ end
+
+ def optional!(name, opts = {})
+ if opts[:require] && !params.key?(name)
+ raise ActionController::ParameterMissing.new(name)
+ end
+
+ if opts[:values] && params.key?(name)
+ values = opts[:values].to_a
+ if !values.include?(params[name]) && !values.include?(params[name].to_i)
+ raise ParameterValueNotAllowed.new(name, opts[:values])
+ end
+ end
+
+ params[name] ||= opts[:default]
+ end
+
+ def error!(data, status_code = 400)
+ render json: data, status: status_code
+ end
+
+ def error_404!
+ error!({ "error" => "Page not found" }, 404)
+ end
+
+ def current_user
+ @current_user ||= User.find_by_id(doorkeeper_token.resource_owner_id) if doorkeeper_token
+ end
+
+ def current_ability
+ @current_ability ||= Ability.new(current_user)
+ end
+
+ def can?(*args)
+ current_ability.can?(*args)
+ end
+
+ def meta
+ @meta || {}
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v3/devices_controller.rb b/app/controllers/api/v3/devices_controller.rb
new file mode 100644
index 000000000..7eb2550ef
--- /dev/null
+++ b/app/controllers/api/v3/devices_controller.rb
@@ -0,0 +1,45 @@
+module Api
+ module V3
+ class DevicesController < Api::V3::ApplicationController
+ before_action :doorkeeper_authorize!
+
+ before_action do
+ requires! :platform, type: String, values: %w(ios android)
+ requires! :token, type: String
+ end
+
+ # 记录用户 Device 信息,用于 Push 通知。
+ #
+ # POST /api/v3/devices
+ #
+ # @note 请在每次用户打开 App 的时候调用此 API 以便更新 Token 的 last_actived_at 让服务端知道这个设备还活着。
+ # Push 将会忽略那些超过两周的未更新的设备。
+ #
+ # @param platform [String] 平台类型 [ios, android]
+ # @param token [String] 用于 Push 的设备信息
+ def create
+ requires! :platform, type: String, values: %w(ios android)
+ requires! :token, type: String
+
+ @device = current_user.devices.find_or_initialize_by(platform: params[:platform].downcase,
+ token: params[:token])
+ @device.last_actived_at = Time.now
+ @device.save!
+
+ render json: { ok: 1 }
+ end
+
+ # 删除 Device 信息,请注意在用户登出或删除应用的时候调用,以便能确保清理掉
+ #
+ # DELETE /api/v3/devices
+ #
+ # @param (see #create)
+ def destroy
+ requires! :platform, type: String, values: %w(ios android)
+ requires! :token, type: String
+ current_user.devices.where(platform: params[:platform].downcase, token: params[:token]).delete_all
+ render json: { ok: 1 }
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v3/likes_controller.rb b/app/controllers/api/v3/likes_controller.rb
new file mode 100644
index 000000000..f39bffbe2
--- /dev/null
+++ b/app/controllers/api/v3/likes_controller.rb
@@ -0,0 +1,53 @@
+module Api
+ module V3
+ class LikesController < Api::V3::ApplicationController
+ before_action :doorkeeper_authorize!
+
+ before_action do
+ requires! :obj_type, values: %w(topic reply)
+ requires! :obj_id
+ end
+
+ # 赞一个信息
+ #
+ # POST /api/v3/likes
+ #
+ # @param obj_type [String] 类型 [topic, reply]
+ # @param obj_id [Integer] 对应数据的编号
+ #
+ # == returns
+ # - count [Integer] 已赞的数量
+ def create
+ current_user.like(likeable)
+ likeable.reload
+ data = { obj_type: params[:obj_type], obj_id: likeable.id, count: likeable.likes_count }
+ render json: data
+ end
+
+ # 取消之前的赞
+ #
+ # DELETE /api/v3/likes
+ #
+ # @param (see #create)
+ # @return (see #create)
+ def destroy
+ current_user.unlike(likeable)
+ likeable.reload
+ data = { obj_type: params[:obj_type], obj_id: likeable.id, count: likeable.likes_count }
+ render json: data
+ end
+
+ private
+
+ def likeable
+ return @likeable if defined? @likeable
+ @likeable =
+ if params[:obj_type] == "topic"
+ Topic.find(params[:obj_id])
+ else
+ Reply.find(params[:obj_id])
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v3/nodes_controller.rb b/app/controllers/api/v3/nodes_controller.rb
new file mode 100644
index 000000000..5238a4566
--- /dev/null
+++ b/app/controllers/api/v3/nodes_controller.rb
@@ -0,0 +1,23 @@
+module Api
+ module V3
+ class NodesController < Api::V3::ApplicationController
+ # 获取 Nodes 列表
+ #
+ # GET /api/v3/nodes
+ # @return [Array]
+ def index
+ @nodes = Node.includes(:section).all
+ @meta = { total: Node.count }
+ end
+
+ ##
+ # 获取单个 Node 详情
+ #
+ # GET /api/v3/nodes/:id
+ # @return [NodeSerializer]
+ def show
+ @node = Node.find(params[:id])
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v3/notifications_controller.rb b/app/controllers/api/v3/notifications_controller.rb
new file mode 100644
index 000000000..b18afd7f6
--- /dev/null
+++ b/app/controllers/api/v3/notifications_controller.rb
@@ -0,0 +1,65 @@
+module Api
+ module V3
+ class NotificationsController < Api::V3::ApplicationController
+ before_action :doorkeeper_authorize!
+
+ # 获取用户的通知列表
+ #
+ # GET /api/v3/notifications
+ #
+ # @param offset [Integer] default: 0
+ # @param limit [Integer] default: 20, range: 1..150
+ # @return [Array]
+ def index
+ optional! :offset, default: 0
+ optional! :limit, default: 20, values: 1..150
+
+ @notifications = Notification.where(user_id: current_user.id).order("id desc")
+ .offset(params[:offset])
+ .limit(params[:limit])
+ end
+
+ # 将当前用户的一些通知设成已读状态
+ #
+ # POST /api/v3/notifications/read
+ #
+ # @param ids [Array] of Notification id, [required]
+ def read
+ requires! :ids
+
+ if params[:ids].any?
+ @notifications = current_user.notifications.where(id: params[:ids])
+ Notification.read!(@notifications.collect(&:id))
+ end
+
+ render json: { ok: 1 }
+ end
+
+ # 删除当前用户的所有通知
+ #
+ # DELETE /api/v3/notifications/all
+ def all
+ current_user.notifications.delete_all
+ render json: { ok: 1 }
+ end
+
+ # 获得未读通知数量
+ #
+ # GET /api/v3/notifications/unread_count
+ # == returns
+ # - count [Integer] 消息数量
+ def unread_count
+ render json: { count: Notification.unread_count(current_user) }
+ end
+
+ # 删除当前用户的某个通知
+ #
+ # DELETE /api/v3/notifications/:id
+ def destroy
+ @notification = current_user.notifications.find(params[:id])
+ @notification.destroy
+ render json: { ok: 1 }
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v3/photos_controller.rb b/app/controllers/api/v3/photos_controller.rb
new file mode 100644
index 000000000..0a51436f2
--- /dev/null
+++ b/app/controllers/api/v3/photos_controller.rb
@@ -0,0 +1,24 @@
+module Api
+ module V3
+ class PhotosController < Api::V3::ApplicationController
+ before_action :doorkeeper_authorize!
+
+ # 上传图片,请使用 Multipart 的方式提交图片文件
+ #
+ # POST /api/v3/photos
+ #
+ # @param file - 文件信息, [required]
+ #
+ # == returns
+ # - image_url 图片 URL
+ def create
+ requires! :file
+
+ @photo = Photo.new
+ @photo.image = params[:file]
+ @photo.user_id = current_user.id
+ @photo.save!
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v3/replies_controller.rb b/app/controllers/api/v3/replies_controller.rb
new file mode 100644
index 000000000..3803a38b6
--- /dev/null
+++ b/app/controllers/api/v3/replies_controller.rb
@@ -0,0 +1,47 @@
+module Api
+ module V3
+ class RepliesController < Api::V3::ApplicationController
+ before_action :doorkeeper_authorize!, only: [:update, :destroy]
+ before_action :set_reply, only: [:show, :update, :destroy]
+
+ # 获取回帖的详细内容(一般用于编辑回帖的时候)
+ #
+ # GET /api/v3/replies/:id
+ # @return [ReplyDetailSerializer]
+ def show
+ end
+
+ # 更新回帖
+ #
+ # POST /api/v3/replies/:id
+ #
+ # @param body [String] 回帖内容 [required]
+ # @return [ReplyDetailSerializer] 更新过后的数据
+ def update
+ raise AccessDenied unless can?(:update, @reply)
+
+ requires! :body
+
+ @reply.body = params[:body]
+ @reply.save!
+ render "show"
+ end
+
+ # 删除回帖
+ #
+ # DELETE /api/v3/replies/:id
+ def destroy
+ raise AccessDenied unless can?(:destroy, @reply)
+
+ @reply.destroy
+ render json: { ok: 1 }
+ end
+
+ private
+
+ def set_reply
+ @reply = Reply.find(params[:id])
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v3/root_controller.rb b/app/controllers/api/v3/root_controller.rb
new file mode 100644
index 000000000..257d5057f
--- /dev/null
+++ b/app/controllers/api/v3/root_controller.rb
@@ -0,0 +1,26 @@
+module Api
+ module V3
+ class RootController < Api::V3::ApplicationController
+ before_action :doorkeeper_authorize!, only: [:hello]
+
+ def not_found
+ raise ActiveRecord::RecordNotFound
+ end
+
+ # 简单的 API 测试接口,需要验证,便于快速测试 OAuth 以及其他 API 的基本格式是否正确
+ #
+ # GET /api/v3/hello
+ #
+ # @param limit - API token
+ # @return [UserDetailSerializer]
+ def hello
+ optional! :limit, values: 0..100
+
+ @meta = { time: Time.now }
+ @user = current_user
+
+ render "api/v3/users/show"
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v3/topics_controller.rb b/app/controllers/api/v3/topics_controller.rb
new file mode 100644
index 000000000..ce4a38e35
--- /dev/null
+++ b/app/controllers/api/v3/topics_controller.rb
@@ -0,0 +1,257 @@
+module Api
+ module V3
+ class TopicsController < Api::V3::ApplicationController
+ before_action :doorkeeper_authorize!, except: [:index, :show, :replies]
+ before_action :set_topic, except: [:index, :create]
+
+ # 获取话题列表,类似网站的 /topics 的结构,支持多种排序方式。
+ #
+ # GET /api/v3/topics
+ #
+ # @param type [String] 排序类型, default: `last_actived`, %w(last_actived recent no_reply popular excellent)
+ # @param node_id [Integer] 节点编号,如果有给,就会只去节点下的话题
+ # @param offset [Integer] default: 0
+ # @param limit [Integer] default: 20, range: 1..150
+ #
+ # @return [Array]
+ def index
+ optional! :type, default: "last_actived"
+ optional! :node_id
+ optional! :offset, default: 0
+ optional! :limit, default: 20, values: 1..150
+
+ params[:type].downcase!
+
+ if params[:node_id].blank?
+ @topics = Topic
+ if current_user
+ @topics = @topics.without_nodes(current_user.block_node_ids)
+ @topics = @topics.without_users(current_user.block_user_ids)
+ else
+ @topics = @topics.without_hide_nodes
+ end
+ else
+ @node = Node.find(params[:node_id])
+ @topics = @node.topics
+ end
+
+ @topics = @topics.fields_for_list.includes(:user).send(scope_method_by_type)
+ if %w(no_reply popular).index(params[:type])
+ @topics = @topics.last_actived
+ elsif params[:type] == "excellent"
+ @topics = @topics.recent
+ end
+
+ @topics = @topics.offset(params[:offset]).limit(params[:limit])
+ end
+
+ # 获取话题详情(不含回帖)
+ #
+ # GET /api/v3/topics/:id
+ #
+ # @param id [Integer] 话题编号
+ # @return [TopicDetailSerializer] 此外 meta 里面包含当前用户对此话题的状态
+ #
+ # ```json
+ # { followed: '是否已关注', liked: '是否已赞', favorited: '是否已收藏' }
+ # ```
+ def show
+ @topic.hits.incr(1)
+ @meta = { followed: false, liked: false, favorited: false }
+
+ if current_user
+ # 处理通知
+ current_user.read_topic(@topic)
+ @meta[:followed] = current_user.follow_topic?(@topic)
+ @meta[:liked] = current_user.like_topic?(@topic)
+ @meta[:favorited] = current_user.favorite_topic?(@topic)
+ end
+ end
+
+ # 创建新话题
+ #
+ # POST /api/v3/topics
+ #
+ # @param title [String] 标题, [required]
+ # @param node_id [Integer] 节点编号, [required]
+ # @param body [Markdown] 格式的正文, [required]
+ # @return [TopicDetailSerializer]
+ def create
+ requires! :title
+ requires! :body
+ requires! :node_id
+
+ raise AccessDenied.new("当前登录的用户没有发帖权限,具体请参考官网的相关说明。") unless can?(:create, Topic)
+
+ @topic = current_user.topics.new(title: params[:title], body: params[:body])
+ @topic.node_id = params[:node_id]
+ @topic.save!
+
+ render "show"
+ end
+
+ # 更新话题
+ #
+ # POST /api/v3/topics/:id
+ #
+ # @param title [String] 标题, [required]
+ # @param node_id [Integer] 节点编号, [required]
+ # @param body [String] Markdown 格式的正文, [required]
+ # @return [TopicDetailSerializer]
+ def update
+ requires! :title
+ requires! :body
+ requires! :node_id
+
+ raise AccessDenied unless can?(:update, @topic)
+
+ if @topic.lock_node == false || admin?
+ # 锁定接点的时候,只有管理员可以修改节点
+ @topic.node_id = params[:node_id]
+
+ if admin? && @topic.node_id_changed?
+ # 当管理员修改节点的时候,锁定节点
+ @topic.lock_node = true
+ end
+ end
+ @topic.title = params[:title]
+ @topic.body = params[:body]
+ @topic.save!
+
+ render "show"
+ end
+
+ # 删除话题
+ #
+ # DELETE /api/v3/topics/:id
+ def destroy
+ raise AccessDenied unless can?(:destroy, @topic)
+ @topic.destroy_by(current_user)
+ render json: { ok: 1 }
+ end
+
+ # 获取话题的回帖列表
+ #
+ # GET /api/v3/topics/:id/replies
+ #
+ # @param offset [Integer] default: 0
+ # @param limit [Integer] default: 20, range: 1..150
+ # @return [Array
+ def replies
+ if request.post?
+ create_replies
+ return
+ end
+
+ params[:limit] ||= 20
+
+ @replies = Reply.unscoped.where(topic_id: @topic.id).order(:id).includes(:user)
+ @replies = @replies.offset(params[:offset].to_i).limit(params[:limit].to_i)
+ @user_liked_reply_ids = current_user&.like_reply_ids_by_replies(@replies) || []
+ @meta = { user_liked_reply_ids: @user_liked_reply_ids }
+ end
+
+ # 创建对话题的回帖
+ #
+ # POST /api/v3/topics/:id/replies
+ #
+ # @param body [String] 回帖内容,[required]
+ # @return [ReplySerializer] 创建的回帖信息
+ def create_replies
+ doorkeeper_authorize!
+
+ requires! :body
+
+ raise AccessDenied.new("当前用户没有回帖权限,具体请参考官网的说明。") unless can?(:create, Reply)
+
+ @reply = @topic.replies.build(body: params[:body])
+ @reply.user_id = current_user.id
+ @reply.save!
+ render "api/v3/replies/show"
+ end
+
+ # 关注话题
+ #
+ # POST /api/v3/topics/:id/follow
+ def follow
+ current_user.follow_topic(@topic)
+ render json: { ok: 1 }
+ end
+
+ # 取消关注话题
+ #
+ # POST /api/v3/topics/:id/unfollow
+ def unfollow
+ current_user.unfollow_topic(@topic)
+ render json: { ok: 1 }
+ end
+
+ # 收藏话题
+ #
+ # POST /api/v3/topics/:id/favorite
+ def favorite
+ current_user.favorite_topic(@topic.id)
+ render json: { ok: 1 }
+ end
+
+ # 取消收藏话题
+ #
+ # POST /api/v3/topics/:id/unfavorite
+ def unfavorite
+ current_user.unfavorite_topic(@topic.id)
+ render json: { ok: 1 }
+ end
+
+ # 屏蔽话题,移到 NoPoint 节点 (Admin only)
+ # [废弃] 请用 POST /api/v3/topics/:id/action
+ #
+ # POST /api/v3/topics/:id/ban
+ def ban
+ raise AccessDenied.new("当前用户没有屏蔽别人话题的权限,具体请参考官网的说明。") unless can?(:ban, @topic)
+ @topic.ban!
+ render json: { ok: 1 }
+ end
+
+ # 更多功能
+ # 注意类型有不同的权限,详见 GET /api/v3/topics/:id 返回的 abilities
+ #
+ # POST /api/v3/topics/:id/action?type=:type
+ # @param type [String] 动作类型, ban - 屏蔽话题, excellent - 加精华, unexcellent - 去掉精华, close - 关闭回复, open - 开启回复
+ def action
+ raise AccessDenied unless can?(params[:type].to_sym, @topic)
+
+ case params[:type]
+ when "excellent"
+ @topic.excellent!
+ when "unexcellent"
+ @topic.unexcellent!
+ when "ban"
+ @topic.ban!
+ when "close"
+ @topic.close!
+ when "open"
+ @topic.open!
+ end
+ render json: { ok: 1 }
+ end
+
+ private
+
+ def set_topic
+ @topic = Topic.find(params[:id])
+ end
+
+ def scope_method_by_type
+ case params[:type]
+ when "last_actived" then :last_actived
+ when "recent" then :recent
+ when "no_reply" then :no_reply
+ when "popular" then :popular
+ when "excellent" then :excellent
+ else
+ :last_actived
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v3/users_controller.rb b/app/controllers/api/v3/users_controller.rb
new file mode 100644
index 000000000..349ecabfc
--- /dev/null
+++ b/app/controllers/api/v3/users_controller.rb
@@ -0,0 +1,183 @@
+module Api
+ module V3
+ class UsersController < Api::V3::ApplicationController
+ before_action :doorkeeper_authorize!, only: [:me, :follow, :unfollow, :block, :unblock, :blocked]
+ before_action :set_user, except: [:index, :me]
+
+ # 获取热门用户
+ #
+ # GET /api/v3/users
+ #
+ # @param limit [Integer] default: 20,range: 1..100
+ # @return [Array]
+ def index
+ optional! :limit, default: 20, values: 1..100
+
+ limit = params[:limit].to_i
+ limit = 100 if limit > 100
+ @users = User.fields_for_list.hot.limit(limit)
+ end
+
+ # 获取当前用户的完整信息,用于个人设置修改资料
+ #
+ # GET /api/v3/users/me
+ def me
+ @user = current_user
+ render "show"
+ end
+
+ # 获取某个用户的详细信息
+ #
+ # GET /api/v3/users/:id
+ # @return [UserDetailSerializer]
+ def show
+ @meta = { followed: false, blocked: false }
+
+ if current_user
+ @meta[:followed] = current_user.follow_user?(@user)
+ @meta[:blocked] = current_user.block_user?(@user)
+ end
+ end
+
+ # 获取某个用户的话题列表
+ #
+ # GET /api/v3/users/:id/topics
+ #
+ # @param order [String] 排序方式, default: 'recent', range: %w(recent likes replies)
+ # @param offset [Integer] default: 0
+ # @param limit [Integer] default: 20, range: 1..150
+ #
+ # @return [Array] 话题列表
+ def topics
+ optional! :order, type: String, default: "recent", values: %w(recent likes replies)
+ optional! :offset, type: Integer, default: 0
+ optional! :limit, type: Integer, default: 20, values: 1..150
+
+ @topics = @user.topics.fields_for_list
+ @topics =
+ if params[:order] == "likes"
+ @topics.high_likes
+ elsif params[:order] == "replies"
+ @topics.high_replies
+ else
+ @topics.recent
+ end
+ @topics = @topics.includes(:user).offset(params[:offset]).limit(params[:limit])
+ end
+
+ # 获取某个用户的回帖列表
+ #
+ # GET /api/v3/users/:id/replies
+ # == params:
+ #
+ # @param order [String] 排序方式, default: 'recent', range: %w(recent)
+ # @param offset [Integer] default: 0
+ # @param limit [Integer] default: 20, range: 1..150
+ #
+ # @return [Array]
+ def replies
+ optional! :order, type: String, default: "recent", values: %w(recent)
+ optional! :offset, type: Integer, default: 0
+ optional! :limit, type: Integer, default: 20, values: 1..150
+
+ @replies = @user.replies.recent
+ @replies = @replies.includes(:user, :topic).offset(params[:offset]).limit(params[:limit])
+ end
+
+ # 获取某个用户的收藏列表
+ #
+ # GET /api/v3/users/:id/favorites
+ #
+ # @param offset [Integer] default: 0
+ # @param limit [Integer] default: 20, range: 1..150
+ # @return 收藏的话题列表
+ def favorites
+ optional! :offset, type: Integer, default: 0
+ optional! :limit, type: Integer, default: 20, values: 1..150
+
+ @topics = @user.favorite_topics.includes(:user).order("actions.id desc").offset(params[:offset]).limit(params[:limit])
+ render "topics"
+ end
+
+ # 获取某个用户关注的人的列表
+ #
+ # GET /api/v3/users/:id/followers
+ #
+ # @param offset [Integer] default: 0
+ # @param limit [Integer] default: 20, range: 1..150
+ # @return 用户列表
+ def followers
+ optional! :offset, type: Integer, default: 0
+ optional! :limit, type: Integer, default: 20, values: 1..150
+
+ @users = @user.follow_by_users.fields_for_list.order("actions.id asc").offset(params[:offset]).limit(params[:limit])
+ end
+
+ # 获取某个用户的关注者列表
+ #
+ # GET /api/v3/users/:id/following
+ #
+ # @param (see #followers)
+ # @return (see #followers)
+ def following
+ optional! :offset, type: Integer, default: 0
+ optional! :limit, type: Integer, default: 20, values: 1..150
+
+ @users = @user.follow_users.fields_for_list.order("actions.id asc").offset(params[:offset]).limit(params[:limit])
+ end
+
+ # 获取用户的已屏蔽的人(只能获取自己的)
+ #
+ # GET /api/v3/users/:id/blocked
+ #
+ # @param (see #followers)
+ # @return (see #followers)
+ def blocked
+ optional! :offset, type: Integer, default: 0
+ optional! :limit, type: Integer, default: 20, values: 1..150
+
+ raise AccessDenied.new("不可以获取其他人的 block_users 列表。") if current_user.id != @user.id
+
+ @users = current_user.block_users.fields_for_list.order("actions.id asc").offset(params[:offset]).limit(params[:limit])
+ end
+
+ # 关注用户
+ #
+ # POST /api/v3/users/:id/follow
+ def follow
+ current_user.follow_user(@user)
+ render json: { ok: 1 }
+ end
+
+ # 取消关注用户
+ #
+ # POST /api/v3/users/:id/unfollow
+ def unfollow
+ current_user.unfollow_user(@user)
+ render json: { ok: 1 }
+ end
+
+ # 屏蔽用户
+ #
+ # POST /api/v3/users/:id/block
+ def block
+ current_user.block_user(@user.id)
+ render json: { ok: 1 }
+ end
+
+ # 取消屏蔽用户
+ #
+ # POST /api/v3/users/:id/unblock
+ def unblock
+ current_user.unblock_user(@user.id)
+ render json: { ok: 1 }
+ end
+
+ private
+
+ def set_user
+ @user = User.find_by_login!(params[:id])
+ end
+ end
+ end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index c4c6db021..8d0b3b6d2 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,81 +1,163 @@
-# coding: utf-8
+# ApplicationController
class ApplicationController < ActionController::Base
- protect_from_forgery
- helper_method :current_user_session, :current_user
- before_filter :init
- def init
- current_user
+ protect_from_forgery prepend: true
+ helper_method :unread_notify_count
+ helper_method :turbolinks_app?, :turbolinks_ios?, :turbolinks_app_version
+
+ # Addition contents for etag
+ etag { current_user.try(:id) }
+ etag { unread_notify_count }
+ etag { flash }
+ etag { Setting.navbar_html }
+ etag { Setting.footer_html }
+ etag { Rails.env.development? ? Time.now : Date.current }
+
+ before_action do
+ resource = controller_name.singularize.to_sym
+ method = "#{resource}_params"
+ params[resource] &&= send(method) if respond_to?(method, true)
+
+ if devise_controller?
+ devise_parameter_sanitizer.permit(:sign_in) { |u| u.permit(*User::ACCESSABLE_ATTRS) }
+ devise_parameter_sanitizer.permit(:account_update) do |u|
+ if current_user.email_locked?
+ u.permit(*User::ACCESSABLE_ATTRS)
+ else
+ u.permit(:email, *User::ACCESSABLE_ATTRS)
+ end
+ end
+ devise_parameter_sanitizer.permit(:sign_up) { |u| u.permit(*User::ACCESSABLE_ATTRS) }
+ end
+
+ User.current = current_user
+ cookies.signed[:user_id] ||= current_user.try(:id)
+
+ # hit unread_notify_count
+ unread_notify_count
+ end
+
+ before_action :set_active_menu
+ def set_active_menu
+ @current = case controller_name
+ when "pages"
+ ["/wiki"]
+ else
+ ["/#{controller_name}"]
+ end
+ end
+
+ before_action :set_locale
+ def set_locale
+ I18n.locale = user_locale
+
+ # after store current locale
+ cookies[:locale] = params[:locale] if params[:locale]
+ rescue I18n::InvalidLocale
+ I18n.locale = I18n.default_locale
end
def render_404
render_optional_error_file(404)
end
-
+
+ def render_403
+ render_optional_error_file(403)
+ end
+
def render_optional_error_file(status_code)
status = status_code.to_s
- if ["404", "422", "500"].include?(status)
- render :template => "/errors/#{status}.html.erb", :status => status, :layout => "application"
- else
- render :template => "/errors/unknown.html.erb", :status => status, :layout => "application"
+ fname = %w(404 403 422 500).include?(status) ? status : "unknown"
+
+ respond_to do |format|
+ format.html { render template: "/errors/#{fname}", handler: [:erb], status: status, layout: "application" }
+ format.all { render nothing: true, status: status }
end
end
- def notice_success(msg)
- flash[:notice] = msg
+ rescue_from CanCan::AccessDenied do |_exception|
+ redirect_to main_app.root_path, alert: t("common.access_denied")
end
- def notice_error(msg)
- flash[:notice] = msg
+ def store_location
+ session[:return_to] = request.url
end
- def notice_warring(msg)
- flash[:notice] = msg
+ def redirect_back_or_default(default)
+ redirect_to(session[:return_to] || default)
+ session[:return_to] = nil
end
- def set_seo_meta(title = '',meta_keywords = '', meta_description = '')
- if title.length > 0
- @page_title = "#{title} » "
- end
- @meta_keywords = meta_keywords
- @meta_description = meta_description
+ def redirect_referrer_or_default(default)
+ redirect_to(request.referrer || default)
end
-
- private
- def current_user_session
- return @current_user_session if defined?(@current_user_session)
- @current_user_session = UserSession.find
- end
-
- def current_user
- return @current_user if defined?(@current_user)
- @current_user = current_user_session && current_user_session.record
+
+ def unread_notify_count
+ return 0 if current_user.blank?
+ @unread_notify_count ||= Notification.unread_count(current_user)
+ end
+
+ def authenticate_user!(opts = {})
+ return if current_user
+ if turbolinks_app?
+ render plain: "401 Unauthorized", status: 401
+ return
end
-
- def require_user
- unless current_user
- store_location
- flash[:notice] = "You must be logged in to access this page"
- redirect_to login_path
- return false
- end
+
+ store_location
+
+ super(opts)
+ end
+
+ def current_user
+ if doorkeeper_token
+ return @current_user if defined? @current_user
+ @current_user ||= User.find_by_id(doorkeeper_token.resource_owner_id)
+ sign_in @current_user
+ @current_user
+ else
+ super
end
+ end
+
+ def turbolinks_app?
+ @turbolinks_app ||= request.user_agent.to_s.include?("turbolinks-app")
+ end
- def require_no_user
- if current_user
- store_location
- flash[:notice] = "You must be logged out to access this page"
- redirect_to root_path
- return false
+ def turbolinks_ios?
+ @turbolinks_ios ||= turbolinks_app? && request.user_agent.to_s.include?("iOS")
+ end
+
+ # read turbolinks app version
+ # example: version:2.1
+ def turbolinks_app_version
+ return "" unless turbolinks_app?
+ return @turbolinks_app_version if defined? @turbolinks_app_version
+ version_str = request.user_agent.to_s.match(/version:[\d\.]+/).to_s
+ @turbolinks_app_version = version_str.split(":").last
+ @turbolinks_app_version
+ end
+
+ # Require Setting enabled module, else will render 404 page.
+ def self.require_module_enabled!(name)
+ before_action do
+ unless Setting.has_module?(name)
+ render_404
end
end
-
- def store_location
- session[:return_to] = request.request_uri
- end
-
- def redirect_back_or_default(default)
- redirect_to(session[:return_to] || default)
- session[:return_to] = nil
- end
+ end
+
+ def require_no_sso!
+ redirect_to auth_sso_path if Setting.sso_enabled?
+ end
+ private
+
+ def user_locale
+ params[:locale] || cookies[:locale] || http_head_locale || Setting.default_locale || I18n.default_locale
+ end
+
+ def http_head_locale
+ return nil if Setting.auto_locale == false
+ http_accept_language.language_region_compatible_from(I18n.available_locales)
+ end
end
diff --git a/app/controllers/auth/omniauth_callbacks_controller.rb b/app/controllers/auth/omniauth_callbacks_controller.rb
new file mode 100644
index 000000000..4445e039e
--- /dev/null
+++ b/app/controllers/auth/omniauth_callbacks_controller.rb
@@ -0,0 +1,33 @@
+module Auth
+ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
+ def self.provides_callback_for(*providers)
+ providers.each do |provider|
+ class_eval %{
+ def #{provider}
+ if not current_user.blank?
+ current_user.bind_service(request.env["omniauth.auth"])#Add an auth to existing
+ redirect_to account_setting_path, notice: "成功绑定了 #{provider} 帐号。"
+ else
+ @user = User.find_or_create_for_#{provider}(request.env["omniauth.auth"])
+
+ if @user.persisted?
+ flash[:notice] = t('devise.sessions.signed_in')
+ sign_in_and_redirect @user, event: :authentication
+ else
+ redirect_to new_user_registration_url
+ end
+ end
+ end
+ }
+ end
+ end
+
+ provides_callback_for :github, :twitter, :douban, :google
+
+ # This is solution for existing accout want bind Google login but current_user is always nil
+ # https://github.com/intridea/omniauth/issues/185
+ def handle_unverified_request
+ true
+ end
+ end
+end
diff --git a/app/controllers/auth/sso_controller.rb b/app/controllers/auth/sso_controller.rb
new file mode 100644
index 000000000..bbdfdfbe6
--- /dev/null
+++ b/app/controllers/auth/sso_controller.rb
@@ -0,0 +1,78 @@
+module Auth
+ class SSOController < ApplicationController
+ def show
+ return render_404 unless Setting.sso_enabled?
+
+ destination_url = cookies.delete(:destination_url)
+ return_path = params[:return_path] || root_path
+
+ if destination_url && return_path == root_path
+ uri = URI.parse(destination_url)
+ return_path = "#{uri.path}#{uri.query ? '?' << uri.query : ''}"
+ end
+
+ sso = Homeland::SSO.generate_sso(return_path)
+ Rails.logger.warn("Verbose SSO log: Started SSO process\n\n#{sso.diagnostics}")
+ redirect_to sso.to_url
+ end
+
+ def login
+ return render_404 unless Setting.sso_enabled?
+
+ sso = Homeland::SSO.parse(request.query_string)
+ unless sso.nonce_valid?
+ return render(plain: I18n.t("sso.timeout_expired"), status: 419)
+ end
+
+ return_path = sso.return_path
+ sso.expire_nonce!
+
+ begin
+ user = sso.find_or_create_user(request)
+ sign_in :user, user
+ rescue => e
+ message = sso.diagnostics
+ message << "\n\n" << "-" * 100 << "\n\n"
+ message << e.message
+ message << "\n\n" << "-" * 100 << "\n\n"
+ message << e.backtrace.join("\n")
+
+ puts message
+
+ ExceptionTrack::Log.create(title: "SSO Failed to create or lookup user:", body: message)
+ render plain: I18n.t("sso.unknown_error"), status: 500
+ return
+ end
+
+ unless user
+ render plain: I18n.t("sso.not_found"), status: 500
+ return
+ end
+
+ redirect_to return_path
+ end
+
+ def provider
+ return render_404 unless Setting.sso_provider_enabled?
+
+ payload ||= request.query_string
+
+ unless current_user
+ store_location
+ redirect_to new_session_path(:user)
+ return
+ end
+
+ sso = SingleSignOn.parse(payload, Setting.sso["secret"])
+ sso.name = current_user.name
+ sso.username = current_user.login
+ sso.email = current_user.email
+ sso.bio = current_user.bio
+ sso.external_id = current_user.id.to_s
+ sso.admin = current_user.admin?
+ sso.avatar_url = current_user.avatar.url(:lg)
+
+ redirect_to sso.to_url(sso.return_sso_url)
+ end
+ end
+end
diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb
new file mode 100644
index 000000000..19577a136
--- /dev/null
+++ b/app/controllers/comments_controller.rb
@@ -0,0 +1,13 @@
+class CommentsController < ApplicationController
+ before_action :authenticate_user!
+
+ def create
+ @comment = Comment.new(comment_params)
+ @comment.user = current_user
+ @success = @comment.save
+ end
+
+ def comment_params
+ params.require(:comment).permit(:commentable_type, :commentable_id, :body)
+ end
+end
diff --git a/app/controllers/cpanel/application_controller.rb b/app/controllers/cpanel/application_controller.rb
deleted file mode 100644
index eb5df307d..000000000
--- a/app/controllers/cpanel/application_controller.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# coding: utf-8
-class Cpanel::ApplicationController < ApplicationController
- layout "cpanel"
- before_filter :require_user
- before_filter :require_admin
-
- def require_admin
- if not APP_CONFIG['admin_emails'].index(@current_user.email)
- render_404
- end
- end
-end
diff --git a/app/controllers/cpanel/home_controller.rb b/app/controllers/cpanel/home_controller.rb
deleted file mode 100644
index 8f66b8b86..000000000
--- a/app/controllers/cpanel/home_controller.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-# coding: utf-8
-class Cpanel::HomeController < Cpanel::ApplicationController
- def index
- @recent_topics = Topic.recents.all(:limit => 5)
- end
-end
diff --git a/app/controllers/cpanel/nodes_controller.rb b/app/controllers/cpanel/nodes_controller.rb
deleted file mode 100644
index 2a54691c1..000000000
--- a/app/controllers/cpanel/nodes_controller.rb
+++ /dev/null
@@ -1,84 +0,0 @@
-# coding: utf-8
-class Cpanel::NodesController < Cpanel::ApplicationController
- # GET /nodes
- # GET /nodes.xml
- def index
- @nodes = Node.all(:include => :section)
-
- respond_to do |format|
- format.html # index.html.erb
- format.xml { render :xml => @nodes }
- end
- end
-
- # GET /nodes/1
- # GET /nodes/1.xml
- def show
- @node = Node.find(params[:id])
-
- respond_to do |format|
- format.html # show.html.erb
- format.xml { render :xml => @node }
- end
- end
-
- # GET /nodes/new
- # GET /nodes/new.xml
- def new
- @node = Node.new
-
- respond_to do |format|
- format.html # new.html.erb
- format.xml { render :xml => @node }
- end
- end
-
- # GET /nodes/1/edit
- def edit
- @node = Node.find(params[:id])
- end
-
- # POST /nodes
- # POST /nodes.xml
- def create
- @node = Node.new(params[:node])
-
- respond_to do |format|
- if @node.save
- format.html { redirect_to(cpanel_nodes_path, :notice => 'Node was successfully created.') }
- format.xml { render :xml => @node, :status => :created, :location => @node }
- else
- format.html { render :action => "new" }
- format.xml { render :xml => @node.errors, :status => :unprocessable_entity }
- end
- end
- end
-
- # PUT /nodes/1
- # PUT /nodes/1.xml
- def update
- @node = Node.find(params[:id])
-
- respond_to do |format|
- if @node.update_attributes(params[:node])
- format.html { redirect_to(cpanel_nodes_path, :notice => 'Node was successfully updated.') }
- format.xml { head :ok }
- else
- format.html { render :action => "edit" }
- format.xml { render :xml => @node.errors, :status => :unprocessable_entity }
- end
- end
- end
-
- # DELETE /nodes/1
- # DELETE /nodes/1.xml
- def destroy
- @node = Node.find(params[:id])
- @node.destroy
-
- respond_to do |format|
- format.html { redirect_to(cpanel_nodes_url) }
- format.xml { head :ok }
- end
- end
-end
diff --git a/app/controllers/cpanel/photos_controller.rb b/app/controllers/cpanel/photos_controller.rb
deleted file mode 100644
index fef576fe8..000000000
--- a/app/controllers/cpanel/photos_controller.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-# coding: utf-8
-class Cpanel::PhotosController < Cpanel::ApplicationController
- # GET /photos
- # GET /photos.xml
- def index
- @photos = Photo.paginate :page => params[:page], :per_page => 20, :order => "id desc", :include => [:user]
- end
-
- # GET /photos/1
- # GET /photos/1.xml
- def show
- @photo = Photo.find(params[:id])
- end
-
- # GET /photos/new
- # GET /photos/new.xml
- def new
- @photo = Photo.new
- end
-
- # GET /photos/1/edit
- def edit
- @photo = Photo.find(params[:id])
- end
-
- # POST /photos
- # POST /photos.xml
- def create
- @photo = Photo.new(params[:photo])
- @photo.user_id = @current_user.id
- if @photo.save
- redirect_to(cpanel_photo_path(@photo), :notice => 'Photo was successfully created.')
- else
- render :action => "new"
- end
- end
-
- # PUT /photos/1
- # PUT /photos/1.xml
- def update
- @photo = Photo.find(params[:id])
- if @photo.update_attributes(params[:photo])
- redirect_to(cpanel_photo_path(@photo), :notice => 'Photo was successfully updated.')
- else
- render :action => "edit"
- end
- end
-
- # DELETE /photos/1
- # DELETE /photos/1.xml
- def destroy
- @photo = Photo.find(params[:id])
- @photo.destroy
-
- redirect_to(cpanel_photos_url)
- end
-end
diff --git a/app/controllers/cpanel/replies_controller.rb b/app/controllers/cpanel/replies_controller.rb
deleted file mode 100644
index 2ba284b69..000000000
--- a/app/controllers/cpanel/replies_controller.rb
+++ /dev/null
@@ -1,84 +0,0 @@
-# coding: utf-8
-class Cpanel::RepliesController < Cpanel::ApplicationController
- # GET /replies
- # GET /replies.xml
- def index
- @replies = Reply.paginate :page => params[:page], :per_page => 30, :order => "id desc",:include => [:topic,:user]
-
- respond_to do |format|
- format.html # index.html.erb
- format.xml { render :xml => @replies }
- end
- end
-
- # GET /replies/1
- # GET /replies/1.xml
- def show
- @reply = Reply.find(params[:id])
-
- respond_to do |format|
- format.html # show.html.erb
- format.xml { render :xml => @reply }
- end
- end
-
- # GET /replies/new
- # GET /replies/new.xml
- def new
- @reply = Reply.new
-
- respond_to do |format|
- format.html # new.html.erb
- format.xml { render :xml => @reply }
- end
- end
-
- # GET /replies/1/edit
- def edit
- @reply = Reply.find(params[:id])
- end
-
- # POST /replies
- # POST /replies.xml
- def create
- @reply = Reply.new(params[:reply])
-
- respond_to do |format|
- if @reply.save
- format.html { redirect_to(cpanel_replies_path, :notice => 'Reply was successfully created.') }
- format.xml { render :xml => @reply, :status => :created, :location => @reply }
- else
- format.html { render :action => "new" }
- format.xml { render :xml => @reply.errors, :status => :unprocessable_entity }
- end
- end
- end
-
- # PUT /replies/1
- # PUT /replies/1.xml
- def update
- @reply = Reply.find(params[:id])
-
- respond_to do |format|
- if @reply.update_attributes(params[:reply])
- format.html { redirect_to(cpanel_replies_path, :notice => 'Reply was successfully updated.') }
- format.xml { head :ok }
- else
- format.html { render :action => "edit" }
- format.xml { render :xml => @reply.errors, :status => :unprocessable_entity }
- end
- end
- end
-
- # DELETE /replies/1
- # DELETE /replies/1.xml
- def destroy
- @reply = Reply.find(params[:id])
- @reply.destroy
-
- respond_to do |format|
- format.html { redirect_to(cpanel_replies_path) }
- format.xml { head :ok }
- end
- end
-end
diff --git a/app/controllers/cpanel/sections_controller.rb b/app/controllers/cpanel/sections_controller.rb
deleted file mode 100644
index c9bf4b430..000000000
--- a/app/controllers/cpanel/sections_controller.rb
+++ /dev/null
@@ -1,84 +0,0 @@
-# coding: utf-8
-class Cpanel::SectionsController < Cpanel::ApplicationController
- # GET /sections
- # GET /sections.xml
- def index
- @sections = Section.all
-
- respond_to do |format|
- format.html # index.html.erb
- format.xml { render :xml => @sections }
- end
- end
-
- # GET /sections/1
- # GET /sections/1.xml
- def show
- @section = Section.find(params[:id])
-
- respond_to do |format|
- format.html # show.html.erb
- format.xml { render :xml => @section }
- end
- end
-
- # GET /sections/new
- # GET /sections/new.xml
- def new
- @section = Section.new
-
- respond_to do |format|
- format.html # new.html.erb
- format.xml { render :xml => @section }
- end
- end
-
- # GET /sections/1/edit
- def edit
- @section = Section.find(params[:id])
- end
-
- # POST /sections
- # POST /sections.xml
- def create
- @section = Section.new(params[:section])
-
- respond_to do |format|
- if @section.save
- format.html { redirect_to(cpanel_sections_path, :notice => 'Section was successfully created.') }
- format.xml { render :xml => @section, :status => :created, :location => @section }
- else
- format.html { render :action => "new" }
- format.xml { render :xml => @section.errors, :status => :unprocessable_entity }
- end
- end
- end
-
- # PUT /sections/1
- # PUT /sections/1.xml
- def update
- @section = Section.find(params[:id])
-
- respond_to do |format|
- if @section.update_attributes(params[:section])
- format.html { redirect_to(cpanel_sections_path, :notice => 'Section was successfully updated.') }
- format.xml { head :ok }
- else
- format.html { render :action => "edit" }
- format.xml { render :xml => @section.errors, :status => :unprocessable_entity }
- end
- end
- end
-
- # DELETE /sections/1
- # DELETE /sections/1.xml
- def destroy
- @section = Section.find(params[:id])
- @section.destroy
-
- respond_to do |format|
- format.html { redirect_to(cpanel_sections_url) }
- format.xml { head :ok }
- end
- end
-end
diff --git a/app/controllers/cpanel/topics_controller.rb b/app/controllers/cpanel/topics_controller.rb
deleted file mode 100644
index 9fcf68c42..000000000
--- a/app/controllers/cpanel/topics_controller.rb
+++ /dev/null
@@ -1,84 +0,0 @@
-# coding: utf-8
-class Cpanel::TopicsController < Cpanel::ApplicationController
- # GET /topics
- # GET /topics.xml
- def index
- @topics = Topic.paginate :page => params[:page], :per_page => 30, :order => "id desc",:include => [:node,:user]
-
- respond_to do |format|
- format.html # index.html.erb
- format.xml { render :xml => @topics }
- end
- end
-
- # GET /topics/1
- # GET /topics/1.xml
- def show
- @topic = Topic.find(params[:id])
-
- respond_to do |format|
- format.html # show.html.erb
- format.xml { render :xml => @topic }
- end
- end
-
- # GET /topics/new
- # GET /topics/new.xml
- def new
- @topic = Topic.new
-
- respond_to do |format|
- format.html # new.html.erb
- format.xml { render :xml => @topic }
- end
- end
-
- # GET /topics/1/edit
- def edit
- @topic = Topic.find(params[:id])
- end
-
- # POST /topics
- # POST /topics.xml
- def create
- @topic = Topic.new(params[:topic])
-
- respond_to do |format|
- if @topic.save
- format.html { redirect_to(cpanel_topics_path, :notice => 'Topic was successfully created.') }
- format.xml { render :xml => @topic, :status => :created, :location => @topic }
- else
- format.html { render :action => "new" }
- format.xml { render :xml => @topic.errors, :status => :unprocessable_entity }
- end
- end
- end
-
- # PUT /topics/1
- # PUT /topics/1.xml
- def update
- @topic = Topic.find(params[:id])
-
- respond_to do |format|
- if @topic.update_attributes(params[:topic])
- format.html { redirect_to(cpanel_topics_path, :notice => 'Topic was successfully updated.') }
- format.xml { head :ok }
- else
- format.html { render :action => "edit" }
- format.xml { render :xml => @topic.errors, :status => :unprocessable_entity }
- end
- end
- end
-
- # DELETE /topics/1
- # DELETE /topics/1.xml
- def destroy
- @topic = Topic.find(params[:id])
- @topic.destroy
-
- respond_to do |format|
- format.html { redirect_to(cpanel_topics_path) }
- format.xml { head :ok }
- end
- end
-end
diff --git a/app/controllers/cpanel/users_controller.rb b/app/controllers/cpanel/users_controller.rb
deleted file mode 100644
index 9ef139d99..000000000
--- a/app/controllers/cpanel/users_controller.rb
+++ /dev/null
@@ -1,84 +0,0 @@
-# coding: utf-8
-class Cpanel::UsersController < Cpanel::ApplicationController
- # GET /users
- # GET /users.xml
- def index
- @users = User.paginate :page => params[:page], :per_page => 30, :order => "id desc"
-
- respond_to do |format|
- format.html # index.html.erb
- format.xml { render :xml => @users }
- end
- end
-
- # GET /users/1
- # GET /users/1.xml
- def show
- @user = User.find(params[:id])
-
- respond_to do |format|
- format.html # show.html.erb
- format.xml { render :xml => @user }
- end
- end
-
- # GET /users/new
- # GET /users/new.xml
- def new
- @user = User.new
-
- respond_to do |format|
- format.html # new.html.erb
- format.xml { render :xml => @user }
- end
- end
-
- # GET /users/1/edit
- def edit
- @user = User.find(params[:id])
- end
-
- # POST /users
- # POST /users.xml
- def create
- @user = User.new(params[:user])
-
- respond_to do |format|
- if @user.save
- format.html { redirect_to(cpanel_users_path, :notice => 'User was successfully created.') }
- format.xml { render :xml => @user, :status => :created, :location => @user }
- else
- format.html { render :action => "new" }
- format.xml { render :xml => @user.errors, :status => :unprocessable_entity }
- end
- end
- end
-
- # PUT /users/1
- # PUT /users/1.xml
- def update
- @user = User.find(params[:id])
-
- respond_to do |format|
- if @user.update_attributes(params[:user])
- format.html { redirect_to(cpanel_users_path, :notice => 'User was successfully updated.') }
- format.xml { head :ok }
- else
- format.html { render :action => "edit" }
- format.xml { render :xml => @user.errors, :status => :unprocessable_entity }
- end
- end
- end
-
- # DELETE /users/1
- # DELETE /users/1.xml
- def destroy
- @user = User.find(params[:id])
- @user.destroy
-
- respond_to do |format|
- format.html { redirect_to(cpanel_users_url) }
- format.xml { head :ok }
- end
- end
-end
diff --git a/app/controllers/devices_controller.rb b/app/controllers/devices_controller.rb
new file mode 100644
index 000000000..7eef2d3d0
--- /dev/null
+++ b/app/controllers/devices_controller.rb
@@ -0,0 +1,9 @@
+class DevicesController < ApplicationController
+ before_action :authenticate_user!
+
+ def destroy
+ @device = current_user.devices.find(params[:id])
+ @device.delete
+ redirect_to oauth_applications_path, notice: "设备信息已删除"
+ end
+end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index a1b43074c..b2a843414 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -1,54 +1,33 @@
-# coding: utf-8
class HomeController < ApplicationController
- before_filter :require_no_user, :only => [:login, :login_create]
- before_filter :require_user, :only => :logout
-
-
def index
- if !fragment_exist? "home/last_topics"
- @last_topics = Topic.recents.limit(10)
- end
- if !fragment_exist? "home/actived_topics"
- @actived_topics = Topic.last_actived.limit(10)
- end
+ @excellent_topics = Topic.excellent.recent.fields_for_list.limit(20).to_a
end
-
- def login
- @user_session = UserSession.new
- end
-
- def login_create
- @user_session = UserSession.new(params[:user_session])
- if @user_session.save
- redirect_back_or_default root_path
+
+ def uploads
+ return render_404 if Rails.env.production?
+
+ # This is a temporary solution for help generate image thumb
+ # that when you use :file upload_provider and you have no Nginx image_filter configurations.
+ # DO NOT use this in production environment.
+ format, version = params[:format].split("!")
+ filename = [params[:path], format].join(".")
+ pragma = request.headers["Pragma"] == "no-cache"
+ thumb = Homeland::ImageThumb.new(filename, version, pragma: pragma)
+ if thumb.exists?
+ send_file thumb.outpath, type: "image/jpeg", disposition: "inline"
else
- render :action => :login
+ render plain: "File not found", status: 404
end
end
-
- def logout
- current_user_session.destroy
- redirect_back_or_default root_path
+
+ def api
+ redirect_to "/api-doc/"
end
- def auth_callback
- auth = request.env["omniauth.auth"]
- redirect_to root_path if auth.blank?
+ def error_404
+ render_404
+ end
- @auth = Authorization.find_from_hash(auth)
- if current_user
- current_user.authorizations.create(:provider => auth['provider'], :uid => auth['uid']) #Add an auth to existing
- flash[:notice] = "成功绑定了 #{auth['provider']} 帐号。"
- redirect_to setting_path
- elsif @auth
- UserSession.create(@auth.user, true) #User is present. Login the user with his social account
- flash[:notice] = "登陆成功。"
- redirect_to root_url
- else
- @new_auth = Authorization.create_from_hash(auth, current_user) #Create a new user
- UserSession.create(@new_auth.user, true) #Log the authorizing user in.
- flash[:notice] = "欢迎来自 #{auth['provider']} 的用户,你的帐号已经创建成功。"
- redirect_to root_url
- end
- end
+ def markdown
+ end
end
diff --git a/app/controllers/likes_controller.rb b/app/controllers/likes_controller.rb
new file mode 100644
index 000000000..21c45d69f
--- /dev/null
+++ b/app/controllers/likes_controller.rb
@@ -0,0 +1,36 @@
+class LikesController < ApplicationController
+ before_action :authenticate_user!, only: [:create, :destroy]
+ before_action :set_likeable
+
+ def index
+ @users = @item.like_by_users.order("actions.id asc")
+ render :index, layout: false
+ end
+
+ def create
+ current_user.like(@item)
+ render plain: @item.reload.likes_count
+ end
+
+ def destroy
+ current_user.unlike(@item)
+ render plain: @item.reload.likes_count
+ end
+
+ private
+
+ def set_likeable
+ @success = false
+ @element_id = "likeable_#{params[:type]}_#{params[:id]}"
+
+ defined_action = User.find_defined_action(:like, params[:type])
+
+ if defined_action.blank?
+ render plain: "-1"
+ return false
+ end
+
+ @item = defined_action[:target_klass].find_by(id: params[:id])
+ render plain: "-2" if @item.blank?
+ end
+end
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
new file mode 100644
index 000000000..1ddaa2584
--- /dev/null
+++ b/app/controllers/nodes_controller.rb
@@ -0,0 +1,18 @@
+class NodesController < ApplicationController
+ before_action :authenticate_user!, only: [:block, :unblock]
+
+ def index
+ @nodes = Node.all
+ render json: @nodes, only: [:name], methods: [:id]
+ end
+
+ def block
+ current_user.block_node(params[:id])
+ render json: { code: 0 }
+ end
+
+ def unblock
+ current_user.unblock_node(params[:id])
+ render json: { code: 0 }
+ end
+end
diff --git a/app/controllers/notes_controller.rb b/app/controllers/notes_controller.rb
deleted file mode 100644
index 359b652f1..000000000
--- a/app/controllers/notes_controller.rb
+++ /dev/null
@@ -1,70 +0,0 @@
-# coding: utf-8
-class NotesController < ApplicationController
- before_filter :require_user
- # GET /notes
- # GET /notes.xml
- def index
- @notes = @current_user.notes.paginate :page => params[:page], :per_page => 20
- set_seo_meta("记事本")
- end
-
- # GET /notes/1
- # GET /notes/1.xml
- def show
- @note = Note.find(params[:id])
- set_seo_meta("查看 » 记事本")
- end
-
- # GET /notes/new
- # GET /notes/new.xml
- def new
- @note = Note.new
- set_seo_meta("新建 » 记事本")
- end
-
- # GET /notes/1/edit
- def edit
- @note = Note.find(params[:id])
- set_seo_meta("修改 » 记事本")
- end
-
- # POST /notes
- # POST /notes.xml
- def create
- @note = Note.new(params[:note])
- @note.user_id = @current_user.id
-
- if @note.save
- redirect_to(@note, :notice => '创建成功。')
- else
- render :action => "new"
- end
- end
-
- # PUT /notes/1
- # PUT /notes/1.xml
- def update
- @note = Note.find(params[:id])
- if @note.user_id != @current_user.id
- render_404
- end
-
- if @note.update_attributes(params[:note])
- redirect_to(@note, :notice => '修改成功。')
- else
- render :action => "edit"
- end
- end
-
- # DELETE /notes/1
- # DELETE /notes/1.xml
- def destroy
- @note = Note.find(params[:id])
- if @note.user_id != @current_user.id
- render_404
- end
- @note.destroy
-
- redirect_to(notes_url)
- end
-end
diff --git a/app/controllers/notifications/notifications_controller.rb b/app/controllers/notifications/notifications_controller.rb
new file mode 100644
index 000000000..a0d6d20bd
--- /dev/null
+++ b/app/controllers/notifications/notifications_controller.rb
@@ -0,0 +1,25 @@
+module Notifications
+ class NotificationsController < Notifications::ApplicationController
+ def index
+ @notifications = notifications.includes(:actor).order("id desc").page(params[:page])
+
+ unread_ids = @notifications.reject(&:read?).select(&:id)
+ Notification.read!(unread_ids)
+
+ @notification_groups = @notifications.group_by { |note| note.created_at.to_date }
+
+ Notification.realtime_push_to_client(current_user)
+ end
+
+ def clean
+ notifications.delete_all
+ redirect_to notifications_path
+ end
+
+ private
+
+ def notifications
+ Notification.where(user_id: current_user.id)
+ end
+ end
+end
diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb
new file mode 100644
index 000000000..6dd0461ef
--- /dev/null
+++ b/app/controllers/oauth/applications_controller.rb
@@ -0,0 +1,31 @@
+module Oauth
+ class ApplicationsController < Doorkeeper::ApplicationsController
+ before_action :authenticate_user!
+ helper_method :unread_notify_count
+
+ def index
+ @applications = current_user.oauth_applications
+ @authorized_applications = Doorkeeper::Application.authorized_for(current_user)
+ @devices = current_user.devices.all
+ end
+
+ # only needed if each application must have some owner
+ def create
+ @application = Doorkeeper::Application.new(application_params)
+ @application.uid = SecureRandom.hex(4)
+ @application.owner = current_user if Doorkeeper.configuration.confirm_application_owner?
+
+ if @application.save
+ flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create])
+ redirect_to oauth_application_url(@application)
+ else
+ render :new
+ end
+ end
+
+ def unread_notify_count
+ return 0 if current_user.blank?
+ @unread_notify_count ||= Notification.unread_count(current_user)
+ end
+ end
+end
diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb
new file mode 100644
index 000000000..36f8aa8ae
--- /dev/null
+++ b/app/controllers/oauth/authorized_applications_controller.rb
@@ -0,0 +1,10 @@
+module Oauth
+ class AuthorizedApplicationsController < Doorkeeper::ApplicationController
+ before_action :authenticate_resource_owner!
+
+ def destroy
+ Doorkeeper::AccessToken.revoke_all_for params[:id].to_i, current_resource_owner
+ redirect_to oauth_applications_url, notice: I18n.t(:notice, scope: [:doorkeeper, :flash, :authorized_applications, :destroy])
+ end
+ end
+end
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
new file mode 100644
index 000000000..a8af126e9
--- /dev/null
+++ b/app/controllers/passwords_controller.rb
@@ -0,0 +1,18 @@
+class PasswordsController < Devise::PasswordsController
+ before_action :require_no_sso!
+
+ def create
+ self.resource = resource_class.find_or_initialize_with_errors(Devise.reset_password_keys, resource_params, :not_found)
+ if self.resource.persisted? && verify_rucaptcha?(resource)
+ self.resource.send_reset_password_instructions
+ end
+
+ yield resource if block_given?
+
+ if successfully_sent?(resource)
+ respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name))
+ else
+ respond_with(resource)
+ end
+ end
+end
diff --git a/app/controllers/photos_controller.rb b/app/controllers/photos_controller.rb
index 086736c2d..e6da8cf03 100644
--- a/app/controllers/photos_controller.rb
+++ b/app/controllers/photos_controller.rb
@@ -1,107 +1,20 @@
-# coding: utf-8
class PhotosController < ApplicationController
- before_filter :require_user, :only => [:tiny_new,:new,:edit,:create,:update,:destroy]
- # GET /photos
- # GET /photos.xml
- def index
- @photos = Photo.all
- end
-
- # GET /photos/1
- # GET /photos/1.xml
- def show
- @photo = Photo.find(params[:id])
- end
-
- # GET /photos/new
- # GET /photos/new.xml
- def tiny_new
- @photo = Photo.new
- render :action => "tiny_new", :layout => "window"
- end
-
- # GET /photos/new
- # GET /photos/new.xml
- def new
- @photo = Photo.new
- end
-
- # GET /photos/1/edit
- def edit
- @photo = Photo.find(params[:id])
- if @photo.user_id != @current_user.id
- render_404
- end
- end
+ load_and_authorize_resource
- # POST /photos
- # POST /photos.xml
def create
# 浮动窗口上传
- if params[:tiny] == '1'
- photos = []
- if !params[:image1].blank?
- photo1 = Photo.new
- photo1.image = params[:image1]
- photos << photo1
- end
- if !params[:image2].blank?
- photo2 = Photo.new
- photo2.image = params[:image2]
- photos << photo2
- end
- if !params[:image3].blank?
- photo3 = Photo.new
- photo3.image = params[:image3]
- photos << photo3
- end
-
- @photos = []
- photos.each do |p|
- p.user_id = @current_user.id
- p.title = "未命名"
- if not p.save
- redirect_to(tiny_new_photos_path, :notice => p.errors.full_messages.join(" "))
- return
- else
- @photos << p
- end
- end
- render :action => :create, :layout => "window"
- else
- # 普通上传
- @photo = Photo.new(params[:photo])
- if @photo.save
- redirect_to(@photo, :notice => 'Photo was successfully created.')
- else
- return render :action => "new"
- end
- end
- end
-
- # PUT /photos/1
- # PUT /photos/1.xml
- def update
- @photo = Photo.find(params[:id])
- if @photo.user_id != @current_user.id
- render_404
- end
- if @photo.update_attributes(params[:photo])
- redirect_to(@photo, :notice => 'Photo was successfully updated.')
- else
- render :action => "edit"
+ @photo = Photo.new
+ @photo.image = params[:file]
+ if @photo.image.blank?
+ render json: { ok: false }, status: 400
+ return
end
- end
- # DELETE /photos/1
- # DELETE /photos/1.xml
- def destroy
- @photo = Photo.find(params[:id])
- if @photo.user_id != @current_user.id
- render_404
+ @photo.user_id = current_user.id
+ if @photo.save
+ render json: { ok: true, url: @photo.image.url(:large) }
+ else
+ render json: { ok: false }
end
- @photo.destroy
-
- redirect_to(photos_url)
end
end
diff --git a/app/controllers/replies_controller.rb b/app/controllers/replies_controller.rb
new file mode 100644
index 000000000..aab30be0e
--- /dev/null
+++ b/app/controllers/replies_controller.rb
@@ -0,0 +1,69 @@
+class RepliesController < ApplicationController
+ load_and_authorize_resource :reply
+
+ before_action :set_topic
+ before_action :set_reply, only: [:edit, :reply_to, :update, :destroy]
+
+ def create
+ @reply = Reply.new(reply_params)
+ @reply.topic_id = @topic.id
+ @reply.user_id = current_user.id
+
+ if @reply.save
+ current_user.read_topic(@topic)
+ @msg = t("topics.reply_success")
+ else
+ @msg = @reply.errors.full_messages.join(" ")
+ end
+ end
+
+ def index
+ last_id = params[:last_id].to_i
+ if last_id == 0
+ render plain: ""
+ return
+ end
+
+ @replies = Reply.unscoped.where("topic_id = ? and id > ?", @topic.id, last_id).order(:id).all
+ current_user&.read_topic(@topic, replies_ids: @replies.collect(&:id))
+ end
+
+ def show
+ end
+
+ def reply_to
+ respond_to do |format|
+ format.html { render_404 }
+ format.js
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ @reply.update(reply_params)
+ end
+
+ def destroy
+ if @reply.destroy
+ redirect_to(topic_path(@reply.topic_id), notice: "回帖删除成功。")
+ else
+ redirect_to(topic_path(@reply.topic_id), alert: "程序异常,删除失败。")
+ end
+ end
+
+ protected
+
+ def set_topic
+ @topic = Topic.find(params[:topic_id])
+ end
+
+ def set_reply
+ @reply = Reply.find(params[:id])
+ end
+
+ def reply_params
+ params.require(:reply).permit(:body, :reply_to_id)
+ end
+end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
new file mode 100644
index 000000000..b521e9cbb
--- /dev/null
+++ b/app/controllers/search_controller.rb
@@ -0,0 +1,31 @@
+class SearchController < ApplicationController
+ before_action :authenticate_user!, only: [:users]
+
+ def index
+ params[:q] ||= ""
+
+ search_modules = [Topic, User]
+ search_modules << Page if Setting.has_module?(:wiki)
+ search_params = {
+ query: {
+ simple_query_string: {
+ query: params[:q],
+ default_operator: "AND",
+ minimum_should_match: "70%",
+ fields: %w(title body name login)
+ }
+ },
+ highlight: {
+ pre_tags: ["[h]"],
+ post_tags: ["[/h]"],
+ fields: { title: {}, body: {}, name: {}, login: {} }
+ }
+ }
+ @result = Elasticsearch::Model.search(search_params, search_modules).page(params[:page])
+ end
+
+ def users
+ @result = User.search(params[:q], user: current_user, limit: params[:limit] || 10)
+ render json: @result.collect { |u| { login: u.login, name: u.name, avatar_url: u.large_avatar_url } }
+ end
+end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
new file mode 100644
index 000000000..6571a7198
--- /dev/null
+++ b/app/controllers/sessions_controller.rb
@@ -0,0 +1,25 @@
+class SessionsController < Devise::SessionsController
+ skip_before_action :set_locale, only: [:create]
+ before_action :require_no_sso!, only: [:new, :create]
+
+ def create
+ resource = warden.authenticate!(scope: resource_name, recall: "#{controller_path}#new")
+ set_flash_message(:notice, :signed_in) if is_navigational_format?
+ sign_in(resource_name, resource)
+ respond_to do |format|
+ format.html { redirect_back_or_default(root_url) }
+ format.json { render status: "201", json: resource.as_json(only: [:login, :email]) }
+ end
+ end
+
+ private
+
+ # If not bind to a domain, request.domain is nil.
+ def domain_or_host
+ request.domain || request.host
+ end
+
+ def respond_to_on_destroy
+ redirect_to topics_url
+ end
+end
diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb
new file mode 100644
index 000000000..053c49edd
--- /dev/null
+++ b/app/controllers/settings_controller.rb
@@ -0,0 +1,109 @@
+class SettingsController < ApplicationController
+ before_action :authenticate_user!
+ before_action :set_user
+
+ def show
+ end
+
+ def account
+ end
+
+ def profile
+ end
+
+ def password
+ render_404 if Setting.sso_enabled?
+ end
+
+ def reward
+ end
+
+ def update
+ case params[:by]
+ when "password"
+ update_password
+ when "profile"
+ update_profile
+ when "reward"
+ update_reward
+ else
+ update_basic
+ end
+ end
+
+ def destroy
+ current_password = params[:user][:current_password]
+
+ unless @user.valid_password?(current_password)
+ @user.errors.add(:current_password, :invalid)
+ render "show"
+ return
+ end
+
+ @user.soft_delete
+ sign_out
+ redirect_to root_path, notice: "账号删除成功。"
+ end
+
+ def auth_unbind
+ provider = params[:provider]
+ if current_user.authorizations.count <= 1
+ redirect_to account_setting_path, notice: t("users.unbind_warning")
+ return
+ end
+
+ current_user.authorizations.where(provider: provider).delete_all
+ redirect_to account_setting_path, notice: t("users.unbind_success", provider: provider.titleize)
+ end
+
+ private
+
+ def set_user
+ @user = current_user
+ end
+
+ def user_params
+ params.require(:user).permit(*User::ACCESSABLE_ATTRS)
+ end
+
+ def update_basic
+ if @user.update(user_params)
+ redirect_to setting_path, notice: "更新成功"
+ else
+ render "show"
+ end
+ end
+
+ def update_profile
+ if @user.update(user_params)
+ @user.update_profile_fields(params[:user][:profiles])
+ redirect_to profile_setting_path, notice: "更新成功"
+ else
+ render "profile"
+ end
+ end
+
+ def update_reward
+ reward_fields = params[:user][:rewards] || {}
+
+ res = {}
+ reward_fields.keys.each do |key|
+ photo = Photo.create(image: reward_fields[key])
+ res[key] = photo.image.url
+ end
+
+ if @user.update_reward_fields(res)
+ redirect_to reward_setting_path, notice: "更新成功"
+ else
+ render "reward"
+ end
+ end
+
+ def update_password
+ if @user.update_with_password(user_params)
+ redirect_to new_session_path(:user), notice: "密码更新成功,现在你需要重新登陆。"
+ else
+ render "password"
+ end
+ end
+end
diff --git a/app/controllers/team_users_controller.rb b/app/controllers/team_users_controller.rb
new file mode 100644
index 000000000..87442148a
--- /dev/null
+++ b/app/controllers/team_users_controller.rb
@@ -0,0 +1,83 @@
+class TeamUsersController < ApplicationController
+ require_module_enabled! :team
+
+ before_action :set_team
+ before_action :set_team_user, only: [:edit, :update, :destroy]
+ before_action :authorize_team_owner!, except: [:index, :accept, :reject, :show]
+ load_and_authorize_resource only: [:accept, :reject, :show]
+
+ def index
+ @team_users = @team.team_users
+ if cannot? :update, @team
+ @team_users = @team_users.accepted
+ end
+ @team_users = @team_users.order("id asc").includes(:user).page(params[:page])
+ end
+
+ def new
+ @team_user = @team.team_users.new
+ @team_user.role = :member
+ end
+
+ def create
+ @team_user = TeamUser.new(team_user_params)
+ @team_user.team_id = @team.id
+ @team_user.actor_id = current_user.id
+ @team_user.status = :pendding
+ if @team_user.save(context: :invite)
+ redirect_to(user_team_users_path(@team), notice: "邀请成功。")
+ else
+ render action: "new"
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if @team_user.update(params.require(:team_user).permit(:role))
+ redirect_to(user_team_users_path(@team), notice: "保存成功")
+ else
+ render action: "edit"
+ end
+ end
+
+ def destroy
+ @team_user.destroy
+ redirect_to(user_team_users_path(@team), notice: "移除成功")
+ end
+
+ def show
+ if @team_user.accepted?
+ redirect_to user_team_users_path(@team)
+ end
+ end
+
+ def accept
+ @team_user.accepted!
+ redirect_to(user_team_users_path(@team), notice: "接受成功,已加入组织")
+ end
+
+ def reject
+ @team_user.destroy
+ redirect_to(user_team_users_path(@team), notice: "已拒绝成功")
+ end
+
+ private
+
+ def authorize_team_owner!
+ authorize! :update, @team
+ end
+
+ def set_team_user
+ @team_user = @team.team_users.find(params[:id])
+ end
+
+ def set_team
+ @team = Team.find_by_login!(params[:user_id])
+ end
+
+ def team_user_params
+ params.require(:team_user).permit(:login, :user_id, :role)
+ end
+end
diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb
new file mode 100644
index 000000000..a142ae5ec
--- /dev/null
+++ b/app/controllers/teams_controller.rb
@@ -0,0 +1,51 @@
+class TeamsController < ApplicationController
+ require_module_enabled! :team
+ load_resource find_by: :login
+ load_and_authorize_resource
+
+ before_action :set_team, only: [:show, :edit, :update, :destroy]
+
+ def index
+ @total_team_count = Team.count
+ @active_teams = Team.fields_for_list.hot.limit(100)
+ end
+
+ def show
+ redirect_to user_path(params[:id])
+ end
+
+ def new
+ @team = Team.new
+ end
+
+ def create
+ @team = Team.new(team_params)
+ @team.owner_id = current_user.id
+ if @team.save
+ redirect_to(edit_team_path(@team), notice: "创建成功")
+ else
+ render action: "new"
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if @team.update(team_params)
+ redirect_to(edit_team_path(@team), notice: t("common.update_success"))
+ else
+ render action: "edit"
+ end
+ end
+
+ private
+
+ def team_params
+ params.require(:team).permit(:login, :name, :email, :email_public, :bio, :website, :twitter, :github, :location, :avatar)
+ end
+
+ def set_team
+ @team = Team.find_by_login!(params[:id])
+ end
+end
diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb
index 988fa5bec..3b46b0ded 100644
--- a/app/controllers/topics_controller.rb
+++ b/app/controllers/topics_controller.rb
@@ -1,143 +1,235 @@
-# coding: utf-8
class TopicsController < ApplicationController
- before_filter :require_user, :only => [:new,:edit,:create,:update,:destroy,:reply]
- before_filter :init_list_sidebar, :only => [:index,:recent,:show,:cate,:search]
- caches_page :feed, :expires_in => 1.hours
+ before_action :authenticate_user!, only: [:new, :edit, :create, :update, :destroy,
+ :favorite, :unfavorite, :follow, :unfollow,
+ :action, :favorites]
+ load_and_authorize_resource only: [:new, :edit, :create, :update, :destroy,
+ :favorite, :unfavorite, :follow, :unfollow]
- private
- def init_list_sidebar
- if !fragment_exist? "topic/init_list_sidebar/hot_nodes"
- @hot_nodes = Node.hots.all(:limit => 20)
- end
- if @current_user
- @user_last_nodes = Node.find_last_visited_by_user(@current_user.id)
- end
- end
+ before_action :set_topic, only: [:ban, :edit, :update, :destroy, :follow,
+ :unfollow, :action, :ban]
- public
- # GET /topics
- # GET /topics.xml
def index
- @topics = Topic.last_actived.all(:limit => 10)
- @sections = Section.all(:include => [:nodes])
- set_seo_meta("论坛","#{APP_CONFIG['app_name']}论坛,#{APP_CONFIG['app_name']}小区论坛,#{APP_CONFIG['app_name']}业主论坛")
+ @suggest_topics = []
+ if params[:page].to_i <= 1
+ @suggest_topics = Topic.without_hide_nodes.suggest.fields_for_list.limit(3)
+ end
+ @topics = Topic.last_actived.without_suggest
+ @topics =
+ if current_user
+ @topics.without_nodes(current_user.block_node_ids)
+ .without_users(current_user.block_user_ids)
+ else
+ @topics.without_hide_nodes
+ end
+ @topics = @topics.fields_for_list
+ @topics = @topics.page(params[:page])
+ @page_title = t("menu.topics")
+ @read_topic_ids = []
+ if current_user
+ @read_topic_ids = current_user.filter_readed_topics(@topics + @suggest_topics)
+ end
end
-
+
def feed
- @topics = Topic.recents.all(:limit => 20)
- response.headers['Content-Type'] = 'application/rss+xml'
- render :layout => false
+ @topics = Topic.without_hide_nodes.recent.limit(20).includes(:node, :user, :last_reply_user)
+ render layout: false if stale?(@topics)
end
def node
@node = Node.find(params[:id])
- if @current_user
- Node.set_user_last_visited(@current_user.id, @node.id)
+ @topics = @node.topics.last_actived.fields_for_list
+ @topics = @topics.includes(:user).page(params[:page])
+ @page_title = "#{@node.name} » #{t('menu.topics')}"
+ @page_title = [@node.name, t("menu.topics")].join(" · ")
+ render action: "index"
+ end
+
+ def node_feed
+ @node = Node.find(params[:id])
+ @topics = @node.topics.recent.limit(20)
+ render layout: false if stale?([@node, @topics])
+ end
+
+ %w(no_reply popular).each do |name|
+ define_method(name) do
+ @topics = Topic.without_hide_nodes.send(name.to_sym).last_actived.fields_for_list.includes(:user)
+ @topics = @topics.page(params[:page])
+
+ @page_title = [t("topics.topic_list.#{name}"), t("menu.topics")].join(" · ")
+ render action: "index"
end
- @topics = @node.topics.last_actived.paginate(:page => params[:page],:per_page => 50)
- set_seo_meta("#{@node.name} » 社区论坛","#{APP_CONFIG['app_name']}社区#{@node.name}",@node.summary)
- render :action => "index"
+ end
+
+ # GET /topics/favorites
+ def favorites
+ @topics = current_user.favorite_topics.includes(:user)
+ @topics = @topics.page(params[:page])
+ render action: "index"
end
def recent
- @topics = Topic.recents.all(:limit => 50)
- set_seo_meta("最近活跃的50个帖子 » 社区论坛")
- render :action => "index"
+ @topics = Topic.without_hide_nodes.recent.fields_for_list.includes(:user)
+ @topics = @topics.page(params[:page])
+ @page_title = [t("topics.topic_list.recent"), t("menu.topics")].join(" · ")
+ render action: "index"
end
- def search
- @topics = Topic.search(params[:key], :page => params[:page], :per_page => 50, :include => [:user, :last_reply_user])
- set_seo_meta("搜索#{params[:s]} » 社区论坛")
- render :action => "index"
+ def excellent
+ @topics = Topic.excellent.recent.fields_for_list.includes(:user)
+ @topics = @topics.page(params[:page])
+
+ @page_title = [t("topics.topic_list.excellent"), t("menu.topics")].join(" · ")
+ render action: "index"
end
def show
- @topic = Topic.find(params[:id])
- if @current_user
- Node.set_user_last_visited(@current_user.id, @topic.node_id)
- @topic.user_readed(@current_user.id)
- end
+ @topic = Topic.unscoped.includes(:user).find(params[:id])
+ render_404 if @topic.deleted?
+
+ @topic.hits.incr(1)
@node = @topic.node
- @replies = @topic.replies.all
- set_seo_meta("#{@topic.title} » 社区论坛")
+ @show_raw = params[:raw] == "1"
+ @can_reply = can?(:create, Reply)
+
+ @replies = Reply.unscoped.where(topic_id: @topic.id).order(:id).all
+ @user_like_reply_ids = current_user&.like_reply_ids_by_replies(@replies) || []
+
+ check_current_user_status_for_topic
+ set_special_node_active_menu
end
- # GET /topics/new
- # GET /topics/new.xml
def new
- @topic = Topic.new
- @topic.node_id = params[:node]
- @node = Node.find(params[:node])
- if @node.blank?
- render_404
- end
- set_seo_meta("发帖子 » 社区论坛")
- end
-
- def reply
- @topic = Topic.find(params[:id])
- @reply = @topic.replies.build(params[:reply])
- @reply.topic_id = params[:id]
- @reply.user_id = @current_user.id
- if @reply.save
- flash[:notice] = "回复成功。"
- else
- flash[:notice] = @reply.errors.full_messages.join(" ")
+ @topic = Topic.new(user_id: current_user.id)
+ unless params[:node].blank?
+ @topic.node_id = params[:node]
+ @node = Node.find_by_id(params[:node])
+ render_404 if @node.blank?
end
- redirect_to topic_path(params[:id],:anchor => 'reply')
end
- # GET /topics/1/edit
def edit
- @topic = Topic.find(params[:id])
- if @topic.user_id != @current_user.id
- return render_404
- end
- set_seo_meta("改帖子 » 社区论坛")
+ @node = @topic.node
end
- # POST /topics
- # POST /topics.xml
def create
- pt = params[:topic]
- @topic = Topic.new(pt)
- @topic.user_id = @current_user.id
- @topic.node_id = params[:node] || pt[:node_id]
-
- if @topic.save
- redirect_to(topic_path(@topic.id), :notice => '帖子创建成功.')
- else
- render :action => "new"
+ @topic = Topic.new(topic_params)
+ @topic.user_id = current_user.id
+ @topic.node_id = params[:node] || topic_params[:node_id]
+ @topic.team_id = ability_team_id
+ @topic.save
+ end
+
+ def preview
+ @body = params[:body]
+
+ respond_to do |format|
+ format.json
end
end
- # PUT /topics/1
- # PUT /topics/1.xml
def update
- @topic = Topic.find(params[:id])
- if @topic.user_id != @current_user.id
- return render_404
- end
- pt = params[:topic]
- @topic.node_id = pt[:node_id]
- @topic.title = pt[:title]
- @topic.body = pt[:body]
-
- if @topic.save
- redirect_to(topics_path, :notice => '帖子修改成功.')
- else
- render :action => "edit"
+ @topic.admin_editing = true if current_user.admin?
+
+ if can?(:change_node, @topic)
+ # 锁定接点的时候,只有管理员可以修改节点
+ @topic.node_id = topic_params[:node_id]
+
+ if current_user.admin? && @topic.node_id_changed?
+ # 当管理员修改节点的时候,锁定节点
+ @topic.lock_node = true
+ end
end
+ @topic.team_id = ability_team_id
+ @topic.title = topic_params[:title]
+ @topic.body = topic_params[:body]
+ @topic.save
end
- # DELETE /topics/1
- # DELETE /topics/1.xml
def destroy
- @topic = Topic.find(params[:id])
- if @topic.user_id != @current_user.id
- return render_404
+ @topic.destroy_by(current_user)
+ redirect_to(topics_path, notice: t("topics.delete_topic_success"))
+ end
+
+ def favorite
+ current_user.favorite_topic(params[:id])
+ render plain: "1"
+ end
+
+ def unfavorite
+ current_user.unfavorite_topic(params[:id])
+ render plain: "1"
+ end
+
+ def follow
+ current_user.follow_topic(@topic)
+ render plain: "1"
+ end
+
+ def unfollow
+ current_user.unfollow_topic(@topic)
+ render plain: "1"
+ end
+
+ def action
+ authorize! params[:type].to_sym, @topic
+
+ case params[:type]
+ when "excellent"
+ @topic.excellent!
+ redirect_to @topic, notice: "加精成功。"
+ when "unexcellent"
+ @topic.unexcellent!
+ redirect_to @topic, notice: "加精已经取消。"
+ when "ban"
+ params[:reason_text] ||= params[:reason] || ""
+ @topic.ban!(reason: params[:reason_text].strip)
+ redirect_to @topic, notice: "已转移到 NoPoint 节点。"
+ when "close"
+ @topic.close!
+ redirect_to @topic, notice: "话题已关闭,将不再接受任何新的回复。"
+ when "open"
+ @topic.open!
+ redirect_to @topic, notice: "话题已重启开启。"
+ end
+ end
+
+ def ban
+ authorize! :ban, @topic
+ end
+
+ private
+
+ def set_topic
+ @topic ||= Topic.find(params[:id])
+ end
+
+ def topic_params
+ params.require(:topic).permit(:title, :body, :node_id, :team_id)
+ end
+
+ def ability_team_id
+ team = Team.find_by_id(topic_params[:team_id])
+ return nil if team.blank?
+ return nil if cannot?(:show, team)
+ team.id
+ end
+
+ def check_current_user_status_for_topic
+ return false unless current_user
+ # 通知处理
+ current_user.read_topic(@topic, replies_ids: @replies.collect(&:id))
+ # 是否关注过
+ @has_followed = current_user.follow_topic?(@topic)
+ # 是否收藏
+ @has_favorited = current_user.favorite_topic?(@topic)
+ end
+
+ def set_special_node_active_menu
+ if Setting.has_module?(:jobs)
+ # FIXME: Monkey Patch for homeland-jobs
+ if @node&.id == 25
+ @current = ["/jobs"]
+ end
end
- @topic.destroy
- redirect_to(topics_path, :notice => '帖子删除成功.')
end
end
diff --git a/app/controllers/users/team_actions.rb b/app/controllers/users/team_actions.rb
new file mode 100644
index 000000000..991bfb8e0
--- /dev/null
+++ b/app/controllers/users/team_actions.rb
@@ -0,0 +1,24 @@
+module Users
+ module TeamActions
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :set_team, only: [:show]
+ end
+
+ private
+
+ def team_show
+ @topics = Topic.where(user_id: @team.user_ids).fields_for_list.last_actived.includes(:user)
+ @topics = @topics.page(params[:page])
+ end
+
+ def only_team!
+ render_404 if @user_type != :team
+ end
+
+ def set_team
+ @team = @user
+ end
+ end
+end
diff --git a/app/controllers/users/user_actions.rb b/app/controllers/users/user_actions.rb
new file mode 100644
index 000000000..357ea6418
--- /dev/null
+++ b/app/controllers/users/user_actions.rb
@@ -0,0 +1,87 @@
+module Users
+ module UserActions
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :authenticate_user!, only: [:block, :unblock, :blocked, :follow, :unfollow]
+ before_action :only_user!, only: [:topics, :replies, :favorites,
+ :block, :unblock, :follow, :unfollow,
+ :followers, :following, :calendar, :reward]
+ end
+
+ def topics
+ @topics = @user.topics.fields_for_list.recent
+ @topics = @topics.page(params[:page])
+ end
+
+ def replies
+ @replies = @user.replies.without_system.fields_for_list.recent
+ @replies = @replies.page(params[:page])
+ end
+
+ def favorites
+ @topics = @user.favorite_topics.order("actions.id desc")
+ @topics = @topics.page(params[:page])
+ end
+
+ def block
+ current_user.block_user(@user.id)
+ render json: { code: 0 }
+ end
+
+ def unblock
+ current_user.unblock_user(@user.id)
+ render json: { code: 0 }
+ end
+
+ def blocked
+ if current_user.id != @user.id
+ render_404
+ end
+
+ @block_users = @user.block_users.order("actions.id asc").page(params[:page])
+ end
+
+ def follow
+ current_user.follow_user(@user)
+ render json: { code: 0, data: { followers_count: @user.followers_count } }
+ end
+
+ def unfollow
+ current_user.unfollow_user(@user)
+ render json: { code: 0, data: { followers_count: @user.followers_count } }
+ end
+
+ def followers
+ @users = @user.follow_by_users.order("actions.id asc")
+ @users = @users.page(params[:page]).per(60)
+ end
+
+ def following
+ @users = @user.follow_users.order("actions.id asc")
+ @users = @users.page(params[:page]).per(60)
+ render template: "/users/followers"
+ end
+
+ def calendar
+ data = @user.calendar_data
+ render json: data if stale?(data)
+ end
+
+ def reward
+ end
+
+ private
+
+ def only_user!
+ render_404 if @user_type != :user
+ end
+
+ def user_show
+ # 排除掉几个非技术的节点
+ without_node_ids = [21, 22, 23, 31, 49, 51, 57, 25]
+ @topics = @user.topics.fields_for_list.without_node_ids(without_node_ids).high_likes.limit(20)
+ @replies = @user.replies.without_system.fields_for_list.recent.includes(:topic).limit(10)
+ end
+ end
+end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 33983e1c5..b9e73b3cb 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,60 +1,52 @@
-# coding: utf-8
class UsersController < ApplicationController
- before_filter :require_no_user, :only => [:new, :create]
- before_filter :require_user, :only => [:edit, :update, :setting, :password]
-
- def new
- @user = User.new
- set_seo_meta("加入社区")
+ before_action :set_user, except: [:index, :city]
+
+ etag { @user }
+ etag { @user&.teams if @user&.user_type == :user }
+
+ include Users::TeamActions
+ include Users::UserActions
+
+ def index
+ @total_user_count = User.count
+ @active_users = User.without_team.fields_for_list.hot.limit(100)
end
-
- def create
- up = params[:user]
- @user = User.new(up)
- @user.email = up[:email]
- @user.name = up[:name]
- if @user.save
- flash[:notice] = "会员注册成功."
- redirect_back_or_default root_path
- else
- render :action => :new
- end
+
+ def city
+ location = Location.location_find_by_name(params[:id])
+ return render_404 if location.nil?
+ @users = User.where(location_id: location.id).without_team.fields_for_list
+ @users = @users.order(replies_count: :desc).page(params[:page]).per(60)
+
+ render_404 if @users.count == 0
end
-
+
def show
- @user = User.find(params[:id])
- if !fragment_exist? "users/show/#{params[:id]}/last_topics"
- @last_topics = @user.topics.recents.limit(20)
- end
- if !fragment_exist? "users/show/#{params[:id]}/last_replies"
- @last_replies = @user.replies.recents.all(:group => :topic_id, :limit => 50, :include => [:topic])
- end
- set_seo_meta("#{@user.name}")
- end
-
- def setting
- @user = @current_user
+ @user_type == :team ? team_show : user_show
end
- def edit
- @user = @current_user
- end
-
- def update
- @user = @current_user # makes our views "cleaner" and more consistent
- if @user.update_attributes(params[:user])
- flash[:notice] = "更改成功!"
- redirect_to setting_path
- else
- if params[:change_pwd]
- render :action => :password
- else
- render :action => :edit
- end
+ protected
+
+ def set_user
+ @user = User.find_by_login!(params[:id])
+
+ # 转向正确的拼写
+ if @user.login != params[:id]
+ redirect_to user_path(@user.login), status: 301
+ return
end
+
+ render_404 if @user.deleted?
+
+ @user_type = @user.user_type
end
-
- def password
- @user = @current_user
+
+ # Override render method to render difference view path
+ def render(*args)
+ options = args.extract_options!
+ if @user_type
+ options[:template] ||= "/#{@user_type.to_s.tableize}/#{params[:action]}"
+ end
+ super(*(args << options))
end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index eb1835b82..ffab35d12 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1,27 +1,155 @@
-# coding: utf-8
module ApplicationHelper
- # return the formatted flash[:notice] html
- def notice_message()
- if flash[:notice]
- result = ''+flash[:notice]+'
'
- else
- result = ''
+ EMPTY_STRING = "".freeze
+
+ def markdown(text)
+ return nil if text.blank?
+ Rails.cache.fetch(["markdown", Digest::MD5.hexdigest(text)]) do
+ sanitize_markdown(Homeland::Markdown.call(text))
+ end
+ end
+
+ def sanitize_markdown(html)
+ raw Sanitize.fragment(html, Homeland::Sanitize::DEFAULT)
+ end
+
+ def notice_message
+ flash_messages = []
+
+ flash.each do |type, message|
+ type = :success if type.to_sym == :notice
+ type = :danger if type.to_sym == :alert
+ text = content_tag(:div, link_to(raw(' '), "#", :class => "close", "data-dismiss" => "alert") + message, class: "alert alert-#{type}")
+ flash_messages << text if message
end
-
- return raw(result)
- end
-
- def admin?(user)
- return true if APP_CONFIG['admin_emails'].index(user.email)
- return false
- end
-
- def cachable_time_ago_in_words(from)
- if request.xhr?
- raw time_ago_in_words from
+
+ flash_messages.join("\n").html_safe
+ end
+
+ def admin?(user = nil)
+ user ||= current_user
+ user.try(:admin?)
+ end
+
+ def wiki_editor?(user = nil)
+ user ||= current_user
+ user.try(:wiki_editor?)
+ end
+
+ def owner?(item)
+ return false if item.blank? || current_user.blank?
+ if item.is_a?(User)
+ item.id == current_user.id
else
- js_call = javascript_tag "document.write(DateHelper.timeAgoInWords(#{(from.to_i * 1000).to_json}));"
- raw "on #{from.to_formatted_s(:long)} #{js_call}"
+ item.user_id == current_user.id
end
end
+
+ def timeago(time, options = {})
+ return "" if time.blank?
+ options[:class] = options[:class].blank? ? "timeago" : [options[:class], "timeago"].join(" ")
+ options[:title] = time.iso8601
+ text = l time.to_date, format: :long
+ content_tag(:abbr, text, options)
+ end
+
+ def title_tag(str)
+ content_for :title, raw("#{str} · #{Setting.app_name}")
+ end
+
+ MOBILE_USER_AGENTS = "palm|blackberry|nokia|phone|midp|mobi|symbian|chtml|ericsson|minimo|" \
+ "audiovox|motorola|samsung|telit|upg1|windows ce|ucweb|astel|plucker|" \
+ "x320|x240|j2me|sgh|portable|sprint|docomo|kddi|softbank|android|mmp|" \
+ 'pdxgw|netfront|xiino|vodafone|portalmmm|sagem|mot-|sie-|ipod|up\\.b|' \
+ "webos|amoi|novarra|cdm|alcatel|pocket|iphone|mobileexplorer|mobile"
+ def mobile?
+ agent_str = request.user_agent.to_s.downcase
+ return true if turbolinks_app?
+ return false if agent_str.match?(/ipad/)
+ agent_str =~ Regexp.new(MOBILE_USER_AGENTS)
+ end
+
+ # 可按需修改
+ LANGUAGES_LISTS = {
+ "Ruby" => "ruby",
+ "HTML / ERB" => "erb",
+ "CSS / SCSS" => "scss",
+ "JavaScript" => "js",
+ "YAML" => "yml",
+ "CoffeeScript" => "coffee",
+ "Nginx / Redis (.conf) " => "conf",
+ "Python" => "python",
+ "PHP" => "php",
+ "Java" => "java",
+ "Erlang" => "erlang",
+ "Shell / Bash" => "shell"
+ }
+
+ def insert_code_menu_items_tag
+ lang_list = []
+ LANGUAGES_LISTS.each do |k, l|
+ lang_list << content_tag(:li) do
+ link_to raw(k), "#", data: { lang: l }
+ end
+ end
+ raw lang_list.join(EMPTY_STRING)
+ end
+
+ def birthday_tag
+ return "" if Setting.app_name != "Ruby China"
+ t = Time.now
+ return "" unless t.month == 10 && t.day == 28
+ age = t.year - 2011
+ title = markdown(":tada: :birthday: :cake: Ruby China 创立 #{age} 周年纪念日 :cake: :birthday: :tada:")
+ raw %(#{title}
)
+ end
+
+ def random_tips
+ tips = Setting.tips
+ return EMPTY_STRING if tips.blank?
+ tips.split("\n").sample
+ end
+
+ def icon_tag(name, opts = {})
+ label = EMPTY_STRING
+ if opts[:label]
+ label = %(#{opts[:label]} )
+ end
+ raw " #{label}"
+ end
+
+ # Override cache helper for support multiple I18n locale
+ def cache(name = {}, options = {}, &block)
+ options ||= {}
+ super([I18n.locale, name], options, &block)
+ end
+
+ def render_list(opts = {})
+ list = []
+ yield(list)
+ list_items = render_list_items(list)
+ content_tag("ul", list_items, opts)
+ end
+
+ def render_list_items(list = [])
+ yield(list) if block_given?
+ items = []
+ list.each do |link|
+ item_class = EMPTY_STRING
+ urls = link.match(/href=(["'])(.*?)(\1)/) || []
+ url = urls.length > 2 ? urls[2] : nil
+ if url && current_page?(url) || (@current && @current.include?(url))
+ item_class = "active"
+ end
+ items << content_tag("li", raw(link), class: item_class)
+ end
+ raw items.join(EMPTY_STRING)
+ end
+
+ def highlight(text)
+ text = escape_once(text)
+ text.gsub!("[h]", "")
+ text.gsub!("[/h]", " ")
+ text.gsub!(/\\n|\\r/, "")
+ raw text
+ end
end
diff --git a/app/helpers/likes_helper.rb b/app/helpers/likes_helper.rb
new file mode 100644
index 000000000..e86da8213
--- /dev/null
+++ b/app/helpers/likes_helper.rb
@@ -0,0 +1,44 @@
+module LikesHelper
+ # Likeable Helper
+ #
+ # 参数
+ # - likeable - Like 的对象
+ # - :cache - 当为 true 时将不会监测用户是否赞过,直接返回未赞过的状态,以用于 cache 的场景
+ # - :class - 增加 a 标签的 css class, 例如 "btn btn-default"
+ def likeable_tag(likeable, opts = {})
+ return "" if likeable.blank?
+
+ label = "#{likeable.likes_count} 个赞"
+ label = "" if likeable.likes_count == 0
+
+ liked = false
+
+ if opts[:cache].blank? && current_user
+ target_type = likeable.class.name
+ defined_action = User.find_defined_action(:like, target_type)
+ return "" unless defined_action
+
+ liked = current_user.send("like_#{defined_action[:action_name]}_ids").include?(likeable.id)
+ end
+
+ title, state, icon_name =
+ if opts[:cache].blank? && liked
+ %w(取消赞 active heart)
+ else
+ ["赞", "", "heart"]
+ end
+
+ icon_label = icon_tag(icon_name, label: label)
+ css_classes = ["likeable", state]
+ css_classes << opts[:class] if opts[:class]
+
+ data = {
+ count: likeable.likes_count,
+ state: state,
+ type: likeable.class.name,
+ id: likeable.id
+ }
+
+ link_to(icon_label, "#", title: title, data: data, class: css_classes.join(" "))
+ end
+end
diff --git a/app/helpers/locations_helper.rb b/app/helpers/locations_helper.rb
new file mode 100644
index 000000000..92d169328
--- /dev/null
+++ b/app/helpers/locations_helper.rb
@@ -0,0 +1,7 @@
+module LocationsHelper
+ def location_name_tag(location, _options = {})
+ return "" if location.blank?
+ name = location.is_a?(String) == true ? location : location.name
+ link_to(name, location_users_path(name))
+ end
+end
diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb
index 05c2006f2..673b56114 100644
--- a/app/helpers/nodes_helper.rb
+++ b/app/helpers/nodes_helper.rb
@@ -1,3 +1,2 @@
-# coding: utf-8
module NodesHelper
end
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
deleted file mode 100644
index 8078f7303..000000000
--- a/app/helpers/notes_helper.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-module NotesHelper
-end
diff --git a/app/helpers/photos_helper.rb b/app/helpers/photos_helper.rb
deleted file mode 100644
index 0a10d472b..000000000
--- a/app/helpers/photos_helper.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-module PhotosHelper
-end
diff --git a/app/helpers/replies_helper.rb b/app/helpers/replies_helper.rb
index ca659af43..ab5f4ccc7 100644
--- a/app/helpers/replies_helper.rb
+++ b/app/helpers/replies_helper.rb
@@ -1,3 +1,2 @@
-# coding: utf-8
module RepliesHelper
end
diff --git a/app/helpers/sections_helper.rb b/app/helpers/sections_helper.rb
deleted file mode 100644
index c5d06285d..000000000
--- a/app/helpers/sections_helper.rb
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding: utf-8
-module SectionsHelper
-end
diff --git a/app/helpers/teams_helper.rb b/app/helpers/teams_helper.rb
new file mode 100644
index 000000000..9517a1b7a
--- /dev/null
+++ b/app/helpers/teams_helper.rb
@@ -0,0 +1,8 @@
+module TeamsHelper
+ def team_member_counts_tag(team)
+ count_text = I18n.t("teams.team_users_count", count: team.team_users_count)
+
+ content_tag(:i, nil, class: "fa fa-users") +
+ content_tag(:span, count_text, class: "team-users-count")
+ end
+end
diff --git a/app/helpers/topics_helper.rb b/app/helpers/topics_helper.rb
index 9f34e8284..449e36782 100644
--- a/app/helpers/topics_helper.rb
+++ b/app/helpers/topics_helper.rb
@@ -1,44 +1,59 @@
-# coding: utf-8
+require "digest/md5"
module TopicsHelper
- def format_topic_body(text,title = "",allow_image = true)
- text = simple_format(h(text))
- if allow_image
- text = text.gsub(/\[img\](http:\/\/.+?)\[\/img\]/,' ')
- # if mobile?
- # img_regexp = Regexp.new(" ]+)[\"|']?.*?>", Regexp::IGNORECASE)
- # new_text = text
- # new_text.scan(img_regexp) do |m|
- # img_url = m[0]
- # text = text.gsub(img_url, img_url.gsub("large","small"))
- # end
- # end
- # Youku
- text = text.gsub(/http:\/\/player\.youku\.com\/player\.php\/sid\/([\w]+)\/v\.swf/i,
- ' ')
- # Tudou
- text = text.gsub(/http:\/\/www\.tudou\.com\/[v|l]\/([\-\w]+)[\/v\.swf]{0,6}/i,
- ' ')
- # Ku6
- text = text.gsub(/http:\/\/player\.ku6\.com\/refer\/([\-_\w]+)\/v\.swf/i,
- ' ')
+ def topic_favorite_tag(topic, opts = {})
+ return "" if current_user.blank?
+ opts[:class] ||= ""
+ class_name = ""
+ link_title = "收藏"
+ if current_user && current_user.favorite_topic?(topic)
+ class_name = "active"
+ link_title = "取消收藏"
end
- text = auto_link(text,:all, :target => '_blank', :rel => "nofollow")
- text = text.gsub(/@([\d]+)楼\s/,'@\1楼 ')
- return text
+
+ icon = raw(content_tag("i", "", class: "fa fa-bookmark"))
+ if opts[:class].present?
+ class_name += " " + opts[:class]
+ end
+
+ link_to(raw("#{icon} 收藏"), "#", title: link_title, class: "bookmark #{class_name}", "data-id" => topic.id)
+ end
+
+ def topic_follow_tag(topic, opts = {})
+ return "" if current_user.blank?
+ return "" if topic.blank?
+ return "" if owner?(topic)
+ opts[:class] ||= ""
+ class_name = "follow"
+ class_name += " active" if current_user.follow_topic?(topic)
+ if opts[:class].present?
+ class_name += " " + opts[:class]
+ end
+ icon = content_tag("i", "", class: "fa fa-eye")
+ link_to(raw("#{icon} 关注"), "#", "data-id" => topic.id, class: class_name)
end
- def topic_use_readed_text(state)
- case state
- when 0
- "在你读过以后还没有新变化"
+ def topic_title_tag(topic, opts = {})
+ return t("topics.topic_was_deleted") if topic.blank?
+ if opts[:reply]
+ index = topic.floor_of_reply(opts[:reply])
+ path = main_app.topic_path(topic, anchor: "reply#{index}")
else
- "有新内容"
+ path = main_app.topic_path(topic)
end
+ link_to(topic.title, path, title: topic.title)
+ end
+
+ def topic_excellent_tag(topic)
+ return "" unless topic.excellent?
+ content_tag(:i, "", title: "精华帖", class: "fa fa-diamond", data: { toggle: "tooltip" })
+ end
+
+ def topic_close_tag(topic)
+ return "" unless topic.closed?
+ content_tag(:i, "", title: "问题已解决/话题已结束讨论", class: "fa fa-check", data: { toggle: "tooltip" })
+ end
+
+ def render_node_name(name, id)
+ link_to(name, main_app.node_topics_path(id), class: "node")
end
end
diff --git a/app/helpers/user_sessions_helper.rb b/app/helpers/user_sessions_helper.rb
deleted file mode 100644
index f67e57411..000000000
--- a/app/helpers/user_sessions_helper.rb
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding: utf-8
-module UserSessionsHelper
-end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index eec701eb5..164e3952d 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -1,18 +1,130 @@
-# coding: utf-8
+require "digest/md5"
+
module UsersHelper
- def user_name_tag(user,options = {})
- location = options[:location] || false
- result = "#{user.name} "
- if location
- if !user.location.blank?
- result += " [#{user.location}] "
+ include LetterAvatar::AvatarHelper
+
+ # 生成用户 login 的链接,user 参数可接受 user 对象或者 字符串的 login
+ def user_name_tag(user, options = {})
+ return "匿名".freeze if user.blank?
+
+ if user.is_a? String
+ user_type = :user
+ login = user
+ name = login
+ else
+ user_type = user.user_type
+ login = user_type == :team ? user.name : user.login
+ name = user.name
+ end
+
+ name ||= login
+ options[:class] ||= "#{user_type}-name"
+ options["data-name".freeze] = name
+
+ link_to(login, main_app.user_path(user), options)
+ end
+ alias team_name_tag user_name_tag
+
+ def user_avatar_width_for_size(size)
+ case size
+ when :xs then 16
+ when :sm then 32
+ when :md then 48
+ when :lg then 96
+ else size
+ end
+ end
+
+ def user_avatar_tag(user, version = :md, opts = {})
+ width = user_avatar_width_for_size(version)
+ img_class = "media-object avatar-#{width}"
+
+ if user.blank?
+ return image_tag("avatar/#{version}.png", class: img_class)
+ end
+
+ img =
+ if user.avatar?
+ image_url = user.avatar.url(version)
+ image_url += "?t=#{user.updated_at.to_i}" if opts[:timestamp]
+ image_tag(image_url, class: img_class)
+ else
+ image_tag(user.letter_avatar_url(width * 2), class: img_class)
end
+
+ options = {
+ title: user.fullname
+ }
+
+ if opts[:link] != false
+ link_to(raw(img), main_app.user_path(user), options)
+ else
+ raw img
+ end
+ end
+ alias team_avatar_tag user_avatar_tag
+
+ def render_user_level_tag(user)
+ return "" if user.blank?
+ level_class = case user.level
+ when "admin" then "label-danger"
+ when "vip" then "label-success"
+ when "hr" then "label-success"
+ when "blocked" then "label-warning"
+ when "newbie" then "label-default"
+ else "label-info"
+ end
+
+ content_tag(:span, user.level_name, class: "label #{level_class} role")
+ end
+
+ def block_node_tag(node)
+ return "" if current_user.blank?
+ return "" if node.blank?
+ blocked = current_user.block_node?(node)
+ class_names = "btn btn-default btn-sm button-block-node"
+ icon = ' '
+ if blocked
+ link_to raw("#{icon} 取消屏蔽 "), "#", title: "忽略后,社区首页列表将不会显示这里的内容。", "data-id" => node.id, class: "#{class_names} active"
+ else
+ link_to raw("#{icon} 忽略节点 "), "#", title: "", "data-id" => node.id, class: class_names
+ end
+ end
+
+ def block_user_tag(user)
+ return "" if current_user.blank?
+ return "" if user.blank?
+ return "" if current_user.id == user.id
+ blocked = current_user.block_user?(user)
+ class_names = "button-block-user btn btn-default btn-block"
+ icon = ' '
+ if blocked
+ link_to raw("#{icon} 取消屏蔽 "), "#", title: "忽略后,社区首页列表将不会显示此用户发布的内容。", "data-id" => user.login, class: "#{class_names} active"
+ else
+ link_to raw("#{icon} 屏蔽 "), "#", title: "", "data-id" => user.login, class: class_names
+ end
+ end
+
+ def follow_user_tag(user, opts = {})
+ return "" if current_user.blank?
+ return "" if user.blank?
+ return "" if current_user.id == user.id
+ followed = current_user.follow_user_ids.include?(user.id)
+ opts[:class] ||= "btn btn-primary btn-block"
+ class_names = "button-follow-user #{opts[:class]}"
+ icon = ' '
+ login = user.login
+ if followed
+ link_to raw("#{icon} 取消关注 "), "#", "data-id" => login, class: "#{class_names} active"
+ else
+ link_to raw("#{icon} 关注 "), "#", title: "", "data-id" => login, class: class_names
end
- raw(result)
end
-
- def user_avatar_tag(user,size = :normal)
- raw("#{image_tag(user.avatar(size))} ")
+ def reward_user_tag(user, opts = {})
+ return "" if user.blank?
+ return "" unless user.reward_enabled?
+ opts[:class] ||= "btn btn-success"
+ link_to icon_tag("qrcode", label: "打赏支持"), main_app.reward_user_path(user), remote: true, class: opts[:class]
end
end
diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb
new file mode 100644
index 000000000..a009ace51
--- /dev/null
+++ b/app/jobs/application_job.rb
@@ -0,0 +1,2 @@
+class ApplicationJob < ActiveJob::Base
+end
diff --git a/app/jobs/github_repo_fetcher_job.rb b/app/jobs/github_repo_fetcher_job.rb
new file mode 100644
index 000000000..60e7a333b
--- /dev/null
+++ b/app/jobs/github_repo_fetcher_job.rb
@@ -0,0 +1,7 @@
+class GithubRepoFetcherJob < ApplicationJob
+ queue_as :http_request
+
+ def perform(user_id)
+ User.fetch_github_repositories(user_id)
+ end
+end
diff --git a/app/jobs/notify_reply_job.rb b/app/jobs/notify_reply_job.rb
new file mode 100644
index 000000000..1603fb440
--- /dev/null
+++ b/app/jobs/notify_reply_job.rb
@@ -0,0 +1,7 @@
+class NotifyReplyJob < ApplicationJob
+ queue_as :notifications
+
+ def perform(reply_id)
+ Reply.notify_reply_created(reply_id)
+ end
+end
diff --git a/app/jobs/notify_topic_job.rb b/app/jobs/notify_topic_job.rb
new file mode 100644
index 000000000..ec950e73c
--- /dev/null
+++ b/app/jobs/notify_topic_job.rb
@@ -0,0 +1,7 @@
+class NotifyTopicJob < ApplicationJob
+ queue_as :notifications
+
+ def perform(topic_id)
+ Topic.notify_topic_created(topic_id)
+ end
+end
diff --git a/app/jobs/push_job.rb b/app/jobs/push_job.rb
new file mode 100644
index 000000000..c42ff4e8c
--- /dev/null
+++ b/app/jobs/push_job.rb
@@ -0,0 +1,23 @@
+class PushJob < ApplicationJob
+ queue_as :notifications
+
+ # user_ids: 用户编号列表
+ # note: { alert: 'Hello APNS World!', sound: 'true', badge: 1 }
+ def perform(user_ids, note = {})
+ return false if Setting.apns_pem.blank?
+
+ note[:sound] ||= "true"
+ devices = Device.where(user_id: user_ids).all.to_a
+ devices.reject! { |d| !d.alive? }
+ tokens = devices.collect(&:token)
+ return false if tokens.blank?
+
+ notification = RubyPushNotifications::APNS::APNSNotification.new(tokens, aps: note)
+ pusher = RubyPushNotifications::APNS::APNSPusher.new(Setting.apns_pem, !Rails.env.production?)
+ pusher.push [notification]
+ Rails.logger.tagged("PushJob") do
+ Rails.logger.info "send to #{tokens.size} devices #{note} status: #{notification.success}"
+ end
+ notification.success
+ end
+end
diff --git a/app/jobs/search_indexer.rb b/app/jobs/search_indexer.rb
new file mode 100644
index 000000000..a199c0bcd
--- /dev/null
+++ b/app/jobs/search_indexer.rb
@@ -0,0 +1,27 @@
+class SearchIndexer < ApplicationJob
+ queue_as :search_indexer
+
+ def perform(operation, type, id)
+ obj = nil
+ type.downcase!
+
+ case type
+ when "topic"
+ obj = Topic.find_by_id(id)
+ when "page"
+ obj = Page.find_by_id(id)
+ when "user"
+ obj = User.find_by_id(id)
+ end
+
+ return false unless obj
+
+ if operation == "update"
+ obj.__elasticsearch__.update_document
+ elsif operation == "delete"
+ obj.__elasticsearch__.delete_document
+ elsif operation == "index"
+ obj.__elasticsearch__.index_document
+ end
+ end
+end
diff --git a/app/mailers/base_mailer.rb b/app/mailers/base_mailer.rb
index e84646451..aab7fc164 100644
--- a/app/mailers/base_mailer.rb
+++ b/app/mailers/base_mailer.rb
@@ -1,7 +1,9 @@
-# coding: utf-8
class BaseMailer < ActionMailer::Base
- default :from => APP_CONFIG['smtp_username']
- default_url_options[:host] = APP_CONFIG['domain']
- layout 'mailer'
- helper :topics
+ default from: Setting.mailer_sender
+ default charset: "utf-8"
+ default content_type: "text/html"
+ default_url_options[:host] = Setting.domain
+
+ layout "mailer"
+ helper :topics, :users
end
diff --git a/app/mailers/topic_mailer.rb b/app/mailers/topic_mailer.rb
deleted file mode 100644
index 5bbe17d09..000000000
--- a/app/mailers/topic_mailer.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# coding: utf-8
-class TopicMailer < BaseMailer
- def got_reply(topic,reply)
- @topic = topic
- @reply = reply
- # 排除不用发邮件的人
- return false if @topic.user.blank? or @reply.user.blank? or (@reply.user_id == @topic.user_id)
- mail(:to => topic.user.email, :subject => "你发布的贴子[#{topic.title}]收到了回贴")
- end
-end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 7fc010c39..652fbcfb7 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -1,7 +1,7 @@
-# coding: utf-8
-class UserMailer < BaseMailer
- def welcome(user)
- @user = user
- mail(:to => user.email, :subject => "欢迎加入#{APP_CONFIG['app_name']}社区")
+class UserMailer < BaseMailer
+ def welcome(user_id)
+ @user = User.find_by_id(user_id)
+ return false if @user.blank?
+ mail(to: @user.email, subject: t("mail.welcome_subject", app_name: Setting.app_name).to_s)
end
end
diff --git a/app/models/ability.rb b/app/models/ability.rb
new file mode 100644
index 000000000..3b422d258
--- /dev/null
+++ b/app/models/ability.rb
@@ -0,0 +1,99 @@
+class Ability
+ include CanCan::Ability
+
+ attr_reader :user
+
+ def initialize(u)
+ @user = u
+ if @user.blank?
+ roles_for_anonymous
+ elsif @user.roles?(:admin)
+ can :manage, :all
+ elsif @user.roles?(:member)
+ roles_for_members
+ else
+ roles_for_anonymous
+ end
+ end
+
+ protected
+
+ # 普通会员权限
+ def roles_for_members
+ roles_for_topics
+ roles_for_replies
+ roles_for_comments
+ roles_for_photos
+ roles_for_teams
+ roles_for_team_users
+ basic_read_only
+ end
+
+ # 未登录用户权限
+ def roles_for_anonymous
+ cannot :manage, :all
+ basic_read_only
+ end
+
+ def roles_for_topics
+ unless user.newbie?
+ can :create, Topic
+ end
+ can [:favorite, :unfavorite, :follow, :unfollow], Topic
+ can [:update, :open, :close], Topic, user_id: user.id
+ can :change_node, Topic, user_id: user.id, lock_node: false
+ can :destroy, Topic do |topic|
+ topic.user_id == user.id && topic.replies_count == 0
+ end
+ end
+
+ def roles_for_replies
+ # 新手用户晚上禁止回帖,防 spam,可在面板设置是否打开
+ can :create, Reply unless current_lock_reply?
+ can [:update, :destroy], Reply, user_id: user.id
+ cannot [:create, :update, :destroy], Reply, topic: { closed?: true }
+ end
+
+ def current_lock_reply?
+ return false unless user.newbie?
+ return false if Setting.reject_newbie_reply_in_the_evening != "true"
+ Time.zone.now.hour > 22 || Time.zone.now.hour < 9
+ end
+
+ def roles_for_photos
+ can :tiny_new, Photo
+ can :create, Photo
+ can :update, Photo, user_id: user.id
+ can :destroy, Photo, user_id: user.id
+ end
+
+ def roles_for_comments
+ can :create, Comment
+ can :update, Comment, user_id: user.id
+ can :destroy, Comment, user_id: user.id
+ end
+
+ def roles_for_teams
+ if user.roles?(:wiki_editor)
+ can :create, Team
+ end
+ can [:update, :destroy], Team do |team|
+ team.owner?(user)
+ end
+ end
+
+ def roles_for_team_users
+ can :read, TeamUser, user_id: user.id
+ can :accept, TeamUser, user_id: user.id
+ can :reject, TeamUser, user_id: user.id
+ end
+
+ def basic_read_only
+ can [:read, :feed, :node], Topic
+ can [:read, :reply_to], Reply
+ can :read, Photo
+ can :read, Section
+ can :read, Comment
+ can :read, Team
+ end
+end
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
new file mode 100644
index 000000000..fcaf47e25
--- /dev/null
+++ b/app/models/application_record.rb
@@ -0,0 +1,11 @@
+class ApplicationRecord < ActiveRecord::Base
+ self.abstract_class = true
+
+ include Redis::Objects
+
+ scope :recent, -> { order(id: :desc) }
+ scope :exclude_ids, ->(ids) { where.not(id: ids.map(&:to_i)) }
+ scope :by_week, -> { where("created_at > ?", 7.days.ago.utc) }
+
+ delegate :url_helpers, to: "Rails.application.routes"
+end
diff --git a/app/models/authorization.rb b/app/models/authorization.rb
index d2891a00b..938e89bdb 100644
--- a/app/models/authorization.rb
+++ b/app/models/authorization.rb
@@ -1,15 +1,5 @@
-class Authorization < ActiveRecord::Base
+class Authorization < ApplicationRecord
belongs_to :user
- validates_presence_of :user_id, :uid, :provider
- validates_uniqueness_of :uid, :scope => :provider
-
- def self.find_from_hash(hash)
- find_by_provider_and_uid(hash['provider'], hash['uid'])
- end
-
- def self.create_from_hash(hash, user = nil)
- user ||= User.create_from_hash(hash)
- Authorization.create(:user_id => user.id, :uid => hash['uid'], :provider => hash['provider'])
- end
+ validates :uid, :provider, presence: true
+ validates :uid, uniqueness: { scope: :provider }
end
-
diff --git a/app/models/cache_version.rb b/app/models/cache_version.rb
new file mode 100644
index 000000000..a3800ec17
--- /dev/null
+++ b/app/models/cache_version.rb
@@ -0,0 +1,29 @@
+# 用于记录特定的 cache version
+# 比如:
+# 记录最后更新 置顶话题的时间,以用于作为自动变换置顶那个 cache 的 key,以达到自动过期的目的
+# 用法例子:
+# 以上面个场景为例
+# Topic after_suggest ->
+# CacheVersion.topic_last_suggested_at = Time.now
+# View 里面 <% cache("topic/index/sidebar_suggest:#{CacheVersion.topic_last_suggested_at}") do %><% end %>
+class CacheVersion
+ def self.method_missing(method, *args)
+ method_name = method.to_s
+ super(method, *args)
+ rescue NoMethodError
+ if method_name.match?(/=$/)
+ var_name = method_name.sub('='.freeze, ''.freeze)
+ key = CacheVersion.mk_key(var_name)
+ value = args.first.to_s
+ # save
+ Rails.cache.write(key, value)
+ else
+ key = CacheVersion.mk_key(method)
+ Rails.cache.read(key)
+ end
+ end
+
+ def self.mk_key(key)
+ "cache_version:#{key}"
+ end
+end
diff --git a/app/models/comment.rb b/app/models/comment.rb
new file mode 100644
index 000000000..3b957b8df
--- /dev/null
+++ b/app/models/comment.rb
@@ -0,0 +1,50 @@
+class Comment < ApplicationRecord
+ include MarkdownBody
+ include Mentionable
+ include UserAvatarDelegate
+
+ belongs_to :commentable, polymorphic: true
+ belongs_to :user
+
+ validates :body, presence: true
+
+ attr_writer :mentioned_user_ids
+
+ def mentioned_user_ids
+ @mentioned_user_ids ||= []
+ end
+
+ before_create :fix_commentable_id
+ def fix_commentable_id
+ self.commentable_id = commentable_id.to_i
+ end
+
+ after_create :increase_counter_cache
+ def increase_counter_cache
+ return if commentable.blank?
+ commentable.increment!(:comments_count)
+ end
+
+ before_destroy :decrease_counter_cache
+ def decrease_counter_cache
+ return if commentable.blank?
+ commentable.decrement!(:comments_count)
+ end
+
+ after_commit :notify_comment_created, on: [:create]
+ def notify_comment_created
+ return if self.commentable.blank?
+ receiver_id = self.commentable&.user_id
+ return if receiver_id.blank?
+ notified_user_ids = self.mentioned_user_ids || []
+ return if notified_user_ids.include?(receiver_id)
+
+ Notification.create(
+ notify_type: "comment",
+ target: self,
+ second_target: self.commentable,
+ actor_id: self.user_id,
+ user_id: receiver_id
+ )
+ end
+end
diff --git a/app/models/concerns/closeable.rb b/app/models/concerns/closeable.rb
new file mode 100644
index 000000000..f9bbabd07
--- /dev/null
+++ b/app/models/concerns/closeable.rb
@@ -0,0 +1,25 @@
+# 开启关闭帖子功能
+module Closeable
+ extend ActiveSupport::Concern
+
+ included do
+ end
+
+ def closed?
+ closed_at.present?
+ end
+
+ def close!
+ transaction do
+ Reply.create_system_event(action: "close", topic_id: self.id)
+ update!(closed_at: Time.now)
+ end
+ end
+
+ def open!
+ transaction do
+ update!(closed_at: nil)
+ Reply.create_system_event(action: "reopen", topic_id: self.id)
+ end
+ end
+end
diff --git a/app/models/concerns/markdown_body.rb b/app/models/concerns/markdown_body.rb
new file mode 100644
index 000000000..beacaf373
--- /dev/null
+++ b/app/models/concerns/markdown_body.rb
@@ -0,0 +1,9 @@
+module MarkdownBody
+ extend ActiveSupport::Concern
+ include ActionView::Helpers::OutputSafetyHelper
+ include ApplicationHelper
+
+ def body_html
+ markdown(body)
+ end
+end
diff --git a/app/models/concerns/mention_topic.rb b/app/models/concerns/mention_topic.rb
new file mode 100644
index 000000000..d94cc8849
--- /dev/null
+++ b/app/models/concerns/mention_topic.rb
@@ -0,0 +1,36 @@
+module MentionTopic
+ extend ActiveSupport::Concern
+
+ TOPIC_LINK_REGEXP = %r{://#{Setting.domain}/topics/([\d]+)}i
+
+ included do
+ attr_accessor :mentioned_topic_ids
+
+ after_save :create_releated_for_mentioned_topics
+ end
+
+ def create_releated_for_mentioned_topics
+ extract_mentioned_topic_ids
+ return false if self.mentioned_topic_ids.blank?
+ Reply.transaction do
+ self.mentioned_topic_ids.each do |topic_id|
+ topic = Topic.find_by(id: topic_id)
+ next if topic.blank?
+ next if topic.replies.where(target: self).any?
+ Reply.create_system_event(user: self.user,
+ topic: topic,
+ action: "mention",
+ target: self)
+ end
+ end
+ end
+
+ def extract_mentioned_topic_ids
+ matched_ids = body.scan(TOPIC_LINK_REGEXP).flatten
+ current_topic_id = self.class.name == "Topic" ? self.id : self.topic_id
+ if matched_ids.any?
+ matched_ids = matched_ids.map(&:to_i).reject { |id| id == current_topic_id }
+ self.mentioned_topic_ids = Topic.where("id IN (?)", matched_ids).pluck(:id)
+ end
+ end
+end
diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb
new file mode 100644
index 000000000..81d7659b0
--- /dev/null
+++ b/app/models/concerns/mentionable.rb
@@ -0,0 +1,74 @@
+module Mentionable
+ extend ActiveSupport::Concern
+
+ included do
+ before_save :extract_mentioned_users
+ after_create :send_mention_notification
+ after_destroy :delete_notification_mentions
+ end
+
+ def delete_notification_mentions
+ Notification.where(notify_type: "mention", target: self).delete_all
+ end
+
+ def mentioned_users
+ User.without_team.where(id: mentioned_user_ids)
+ end
+
+ def mentioned_user_logins
+ # 用于作为缓存 key
+ ids_md5 = Digest::MD5.hexdigest(mentioned_user_ids.to_s)
+ Rails.cache.fetch("#{self.class.name.downcase}:#{id}:mentioned_user_logins:#{ids_md5}") do
+ self.mentioned_users.pluck(:login)
+ end
+ end
+
+ def extract_mentioned_users
+ logins = body.scan(/@([#{User::LOGIN_FORMAT}]{3,20})/).flatten.map(&:downcase)
+ if logins.any?
+ self.mentioned_user_ids = User.without_team.where("lower(login) IN (?) AND id != (?)", logins, user.id).limit(5).pluck(:id)
+ end
+
+ # add Reply to user_id
+ if self.respond_to?(:reply_to)
+ reply_to_user_id = self.reply_to&.user_id
+ if reply_to_user_id
+ self.mentioned_user_ids << reply_to_user_id
+ end
+ end
+ end
+
+ def no_mention_users
+ [user]
+ end
+
+ def send_mention_notification
+ users = mentioned_users - no_mention_users
+ Notification.bulk_insert(set_size: 100) do |worker|
+ users.each do |user|
+ note = {
+ notify_type: "mention",
+ actor_id: self.user_id,
+ user_id: user.id,
+ target_type: self.class.name,
+ target_id: self.id
+ }
+ if self.class.name == "Reply"
+ note[:second_target_type] = "Topic"
+ note[:second_target_id] = self.send(:topic_id)
+ elsif self.class.name == "Comment"
+ note[:second_target_type] = self.commentable_type
+ note[:second_target_id] = self.commentable_id
+ end
+ worker.add(note)
+ end
+ end
+
+ # Touch push to client
+ # TODO: 确保准确
+ users.each do |u|
+ n = u.notifications.last
+ n.realtime_push_to_client
+ end
+ end
+end
diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb
new file mode 100644
index 000000000..45796570d
--- /dev/null
+++ b/app/models/concerns/searchable.rb
@@ -0,0 +1,24 @@
+module Searchable
+ extend ActiveSupport::Concern
+
+ included do
+ include Elasticsearch::Model
+
+ after_commit on: :create do
+ SearchIndexer.perform_later("index", self.class.name, self.id)
+ end
+
+ after_update do
+ need_update = false
+ if self.respond_to?(:indexed_changed?)
+ need_update = indexed_changed?
+ end
+
+ SearchIndexer.perform_later("index", self.class.name, self.id) if need_update
+ end
+
+ after_commit on: :destroy do
+ SearchIndexer.perform_later("delete", self.class.name, self.id)
+ end
+ end
+end
diff --git a/app/models/concerns/soft_delete.rb b/app/models/concerns/soft_delete.rb
new file mode 100644
index 000000000..421b966b4
--- /dev/null
+++ b/app/models/concerns/soft_delete.rb
@@ -0,0 +1,25 @@
+module SoftDelete
+ extend ActiveSupport::Concern
+
+ included do
+ default_scope -> { where(deleted_at: nil) }
+
+ alias_method :destroy!, :destroy
+ end
+
+ def destroy
+ run_callbacks(:destroy) do
+ if persisted?
+ t = Time.now.utc
+ update_columns(deleted_at: t, updated_at: t)
+ end
+
+ @destroyed = true
+ end
+ freeze
+ end
+
+ def deleted?
+ deleted_at.present?
+ end
+end
diff --git a/app/models/concerns/user_avatar_delegate.rb b/app/models/concerns/user_avatar_delegate.rb
new file mode 100644
index 000000000..449b846ae
--- /dev/null
+++ b/app/models/concerns/user_avatar_delegate.rb
@@ -0,0 +1,7 @@
+module UserAvatarDelegate
+ extend ActiveSupport::Concern
+
+ def user_avatar_raw
+ self.user ? self.user[:avatar] : nil
+ end
+end
diff --git a/app/models/counter.rb b/app/models/counter.rb
deleted file mode 100644
index 83e8921a1..000000000
--- a/app/models/counter.rb
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding: utf-8
-class Counter < ActiveRecord::Base
-end
diff --git a/app/models/device.rb b/app/models/device.rb
new file mode 100644
index 000000000..543f9b9f0
--- /dev/null
+++ b/app/models/device.rb
@@ -0,0 +1,17 @@
+class Device < ApplicationRecord
+ belongs_to :user
+
+ enum platform: %i(ios android)
+
+ validates :platform, :token, presence: true
+ validates :token, uniqueness: { scope: [:user_id, :platform] }
+
+ def alive?
+ return true if last_actived_at.blank?
+ (Date.current - last_actived_at.to_date).to_i <= 14
+ end
+
+ def platform_name
+ @platform_name ||= I18n.t "device_platform.#{self.platform}"
+ end
+end
diff --git a/app/models/location.rb b/app/models/location.rb
new file mode 100644
index 000000000..bf6ce4a13
--- /dev/null
+++ b/app/models/location.rb
@@ -0,0 +1,26 @@
+class Location < ApplicationRecord
+
+ second_level_cache expires_in: 2.weeks
+
+ has_many :users
+
+ scope :hot, -> { order(users_count: :desc) }
+
+ validates :name, presence: true, uniqueness: { case_sensitive: false }
+
+ before_save { |loc| loc.name = loc.name.downcase.strip }
+
+ def self.location_find_by_name(name)
+ return nil if name.blank?
+ name = name.downcase.strip
+ where("name ~* ?", name).first
+ end
+
+ def self.location_find_or_create_by_name(name)
+ name = name.strip
+ unless (location = location_find_by_name(name))
+ location = create(name: name)
+ end
+ location
+ end
+end
diff --git a/app/models/node.rb b/app/models/node.rb
index f0987c64a..e17be1c01 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -1,40 +1,46 @@
-# coding: utf-8
-class Node < ActiveRecord::Base
- validates_presence_of :name, :summary
- validates_uniqueness_of :name
- belongs_to :section
+class Node < ApplicationRecord
+
+ second_level_cache expires_in: 2.weeks
+
+ delegate :name, to: :section, prefix: true, allow_nil: true
+
has_many :topics
+ belongs_to :section
- scope :hots, :order => "topics_count desc"
+ validates :name, :summary, :section, presence: true
+ validates :name, uniqueness: true
- # 存放用户最近访问节点
- def self.set_user_last_visited(user_id,node_id)
- last_visites = get_user_last_visites(user_id)
- last_visites.delete(node_id)
+ scope :hots, -> { order(topics_count: :desc) }
+ scope :sorted, -> { order(sort: :desc) }
- if(last_visites.length == 10)
- last_visites.pop
- end
- last_visites.insert(0,node_id)
- Rails.cache.write("Node:get_user_last_visites:#{user_id}",last_visites)
+ after_save :update_cache_version
+ after_destroy :update_cache_version
+
+ def self.find_builtin_node(id, name)
+ node = self.find_by_id(id)
+ return node if node
+ self.create(id: id, name: name, summary: name, section: Section.default)
end
- # 取得用户最近访问的节点
- def self.get_user_last_visites(user_id)
- last_visites = Rails.cache.read("Node:get_user_last_visites:#{user_id}")
- if last_visites.blank?
- last_visites = []
- Rails.cache.write("Node:get_user_last_visites:#{user_id}", last_visites)
- end
- return last_visites.dup
+ # 内建 [NoPoint] 节点
+ def self.no_point
+ @no_point ||= self.find_builtin_node(61, "NoPoint")
+ end
+
+ # 是否 Summary 过多需要折叠
+ def collapse_summary?
+ @collapse_summary ||= self.summary_html.scan(/\|\
/).size > 2
+ end
+
+ def update_cache_version
+ # 记录节点变更时间,用于清除缓存
+ CacheVersion.section_node_updated_at = Time.now
end
- def self.find_last_visited_by_user(user_id,limit = 10)
- ids = get_user_last_visites(user_id)
- if ids.blank?
- return []
- else
- find(:all, :limit => limit, :conditions => "id in (#{ids.join(',')})")
+ # Markdown 转换过后的 HTML
+ def summary_html
+ Rails.cache.fetch("#{cache_key}/summary_html") do
+ Homeland::Markdown.call(summary)
end
end
end
diff --git a/app/models/note.rb b/app/models/note.rb
index 64a0a041b..64eda850f 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -1,27 +1,31 @@
-# coding: utf-8
# 记事本
-class Note < ActiveRecord::Base
- attr_protected :user_id, :changes_count, :word_count
- belongs_to :user, :counter_cache => true
+class Note < ApplicationRecord
+ second_level_cache version: 1, expires_in: 1.week
- default_scope :order => "id desc"
+ belongs_to :user
+
+ counter :hits, default: 0
+
+ scope :recent_updated, -> { order(updated_at: :desc) }
+ scope :published, -> { where(publish: true) }
+
+ validates :body, presence: true
before_save :auto_set_value
def auto_set_value
- if !self.body.blank?
- self.title = self.body.split("\n").first[0..50]
- self.word_count = self.body.length
+ unless body.blank?
+ self.title = body.split("\n").first[0..50]
+ self.word_count = body.length
end
end
before_update :update_changes_count
def update_changes_count
- self.changes_cout += 1
+ self.changes_count = 0 if changes_count.blank?
+ increment(:changes_count)
end
-
- def self.cached_count
- return Rails.cache.fetch("notes/count",:expires_in => 1.hours) do
- self.count
- end
+
+ def display_title
+ (title || '').gsub(/^[\#]+/, '')
end
end
diff --git a/app/models/notification.rb b/app/models/notification.rb
new file mode 100644
index 000000000..e1bc09dc2
--- /dev/null
+++ b/app/models/notification.rb
@@ -0,0 +1,51 @@
+# Auto generate with notifications gem.
+class Notification < ActiveRecord::Base
+ self.table_name = "new_notifications"
+
+ include Notifications::Model
+
+ after_create :realtime_push_to_client
+ after_update :realtime_push_to_client
+
+ def realtime_push_to_client
+ if user
+ Notification.realtime_push_to_client(user)
+ PushJob.perform_later(user_id, apns_note)
+ end
+ end
+
+ def self.realtime_push_to_client(user)
+ ActionCable.server.broadcast("notifications_count/#{user.id}", count: Notification.unread_count(user))
+ end
+
+ def apns_note
+ @note ||= { alert: notify_title, badge: Notification.unread_count(user) }
+ end
+
+ def notify_title
+ return "" if self.actor.blank?
+ if notify_type == "topic"
+ "#{self.actor.login} 创建了话题 《#{self.target.title}》"
+ elsif notify_type == "topic_reply"
+ "#{self.actor.login} 回复了话题 《#{self.second_target.title}》"
+ elsif notify_type == "follow"
+ "#{self.actor.login} 开始关注你了"
+ elsif notify_type == "mention"
+ "#{self.actor.login} 提及了你"
+ elsif notify_type == "node_changed"
+ "你的话题被移动了节点到 #{self.second_target.name}"
+ else
+ ""
+ end
+ end
+
+ def self.notify_follow(user_id, follower_id)
+ opts = {
+ notify_type: "follow",
+ user_id: user_id,
+ actor_id: follower_id
+ }
+ return if Notification.where(opts).count > 0
+ Notification.create opts
+ end
+end
diff --git a/app/models/page.rb b/app/models/page.rb
new file mode 100644
index 000000000..95803ba95
--- /dev/null
+++ b/app/models/page.rb
@@ -0,0 +1,70 @@
+class Page < ApplicationRecord
+ include MarkdownBody
+ include Searchable
+
+ counter :hits, default: 0
+
+ second_level_cache version: 1, expires_in: 1.week
+
+ has_many :versions, class_name: 'PageVersion'
+
+ attr_accessor :user_id, :change_desc, :version_enable
+
+ validates :slug, :title, :body, presence: true
+ # 当需要记录版本时,如果是更新,那么要求填写 :change_desc
+ validates :user_id, if: proc { |p| p.version_enable == true }, presence: true
+ validates :change_desc, if: proc { |p| p.version_enable == true && !p.new_record? }, presence: true
+ validates :slug, format: /\A[a-z0-9\-_]+\z/
+ validates :slug, uniqueness: true
+
+ before_save :append_editor
+ def append_editor
+ unless editor_ids.include?(user_id.to_i)
+ editor_ids << user_id.to_i
+ end
+ end
+
+ # 记录更新版本
+ after_save :create_version
+ def create_version
+ # 只有当 version_enable 为 true 的时候才记录版本
+ # 以免后台,以及其他的一些 update 时被误调用
+ return true unless version_enable
+ # 只有 body, title, slug 更改了才更新版本
+ if self.body_changed? || self.title_changed? || self.slug_changed?
+ update_column(:version, self.version + 1)
+ PageVersion.create(user_id: user_id,
+ page_id: id,
+ desc: change_desc || '',
+ version: version,
+ body: body,
+ title: title,
+ slug: slug)
+ end
+ end
+
+ def to_param
+ slug
+ end
+
+ # 撤掉到指定版本
+ def revert_version(version)
+ page_version = PageVersion.where(page_id: id, version: version).first
+ return false if page_version.blank?
+ update_attributes(body: page_version.body,
+ title: page_version.title,
+ slug: page_version.slug)
+ end
+
+ def editors
+ User.where(id: editor_ids)
+ end
+
+ def self.find_by_slug(slug)
+ fetch_by_uniq_keys(slug: slug)
+ end
+
+ def as_indexed_json(_options = {})
+ as_json(only: [:title, :body])
+ end
+end
diff --git a/app/models/photo.rb b/app/models/photo.rb
index f27968fc5..c8e28a84f 100644
--- a/app/models/photo.rb
+++ b/app/models/photo.rb
@@ -1,21 +1,8 @@
-# coding: utf-8
-class Photo < ActiveRecord::Base
- attr_protected :user_id
- validates_presence_of :title
+class Photo < ApplicationRecord
belongs_to :user
+
+ validates_presence_of :image
+
# 封面图
- has_attached_file :image,
- :default_style => :normal,
- :styles => {
- :small => "100>",
- :normal => "680>",
- },
- :url => "#{APP_CONFIG['upload_url']}/:class/:attachment/:hashed_path/:id_:style.jpg",
- :path => "#{APP_CONFIG['upload_root']}/:class/:attachment/:hashed_path/:id_:style.jpg",
- :default_url => "photo/:style.jpg"
-
- before_save :default_for_title
- def default_for_title
- self.title = "未命名" if self.title.blank?
- end
+ mount_uploader :image, PhotoUploader
end
diff --git a/app/models/reply.rb b/app/models/reply.rb
index e2eebe541..93cde5694 100644
--- a/app/models/reply.rb
+++ b/app/models/reply.rb
@@ -1,28 +1,147 @@
-# coding: utf-8
-class Reply < ActiveRecord::Base
- attr_protected :user_id, :topic_id
- belongs_to :topic, :counter_cache => true
- belongs_to :user
-
- validates_presence_of :body
- scope :recents, :order => "id desc"
- after_create :update_parent_last_replied
- after_create :send_got_reply_mail
- def update_parent_last_replied
- self.topic.replied_at = Time.now
- self.topic.last_reply_user_id = self.user_id
- self.topic.save
- # 清除用户读过记录
- self.topic.clear_user_readed
- end
- def send_got_reply_mail
- m = TopicMailer.create_got_reply(self.topic,self)
- Thread.new { m.deliver }
- end
-
- def self.cached_count
- return Rails.cache.fetch("replies/count",:expires_in => 1.hours) do
- self.count
+require "digest/md5"
+class Reply < ApplicationRecord
+ include MarkdownBody
+ include SoftDelete
+ include Mentionable
+ include MentionTopic
+ include UserAvatarDelegate
+
+ UPVOTES = %w(+1 :+1: :thumbsup: :plus1: 👍 👍🏻 👍🏼 👍🏽 👍🏾 👍🏿)
+
+ belongs_to :user, counter_cache: true
+ belongs_to :topic, touch: true
+ belongs_to :target, polymorphic: true
+ belongs_to :reply_to, class_name: "Reply"
+
+ delegate :title, to: :topic, prefix: true, allow_nil: true
+ delegate :login, to: :user, prefix: true, allow_nil: true
+
+ scope :without_system, -> { where(action: nil) }
+ scope :fields_for_list, -> { select(:topic_id, :id, :body, :updated_at, :created_at) }
+
+ validates :body, presence: true, unless: -> { system_event? }
+ validates :body, uniqueness: { scope: [:topic_id, :user_id], message: "不能重复提交。" }, unless: -> { system_event? }
+ validate do
+ ban_words = (Setting.ban_words_on_reply || "").split("\n").collect(&:strip)
+ if body.strip.downcase.in?(ban_words)
+ errors.add(:body, "请勿回复无意义的内容,如你想收藏或赞这篇帖子,请用帖子后面的功能。")
end
+
+ if topic&.closed?
+ errors.add(:topic, "已关闭,不再接受回帖或修改回帖。")
+ end
+
+ if reply_to_id
+ self.reply_to_id = nil if reply_to&.topic_id != self.topic_id
+ end
+ end
+
+ after_commit :update_parent_topic, on: :create, unless: -> { system_event? }
+ def update_parent_topic
+ topic.update_last_reply(self) if topic.present?
+ end
+
+ # 删除的时候也要更新 Topic 的 updated_at 以便清理缓存
+ after_destroy :update_parent_topic_updated_at
+ def update_parent_topic_updated_at
+ unless topic.blank?
+ topic.update_deleted_last_reply(self)
+ # FIXME: 本应该 belongs_to :topic, touch: true 来实现的,但貌似有个 Bug 哪里没起作用
+ topic.touch
+ end
+ end
+
+ after_commit :async_create_reply_notify, on: :create, unless: -> { system_event? }
+ def async_create_reply_notify
+ NotifyReplyJob.perform_later(id)
+ end
+
+ after_commit :check_vote_chars_for_like_topic, on: :create, unless: -> { system_event? }
+ def check_vote_chars_for_like_topic
+ return unless self.upvote?
+ user.like(topic)
+ end
+
+ def self.notify_reply_created(reply_id)
+ reply = Reply.find_by_id(reply_id)
+ return if reply.blank?
+ return if reply.system_event?
+ topic = Topic.find_by_id(reply.topic_id)
+ return if topic.blank?
+
+ Notification.bulk_insert(set_size: 100) do |worker|
+ reply.notification_receiver_ids.each do |uid|
+ logger.debug "Post Notification to: #{uid}"
+ note = reply.default_notification.merge(user_id: uid)
+ worker.add(note)
+ end
+ end
+
+ # Touch realtime_push_to_client
+ reply.notification_receiver_ids.each do |uid|
+ n = Notification.where(user_id: uid).last
+ n.realtime_push_to_client if n.present?
+ end
+ Reply.broadcast_to_client(reply)
+
+ true
+ end
+
+ def self.broadcast_to_client(reply)
+ ActionCable.server.broadcast("topics/#{reply.topic_id}/replies", id: reply.id, user_id: reply.user_id, action: :create)
+ end
+
+ def default_notification
+ @default_notification ||= {
+ notify_type: "topic_reply",
+ target_type: "Reply", target_id: self.id,
+ second_target_type: "Topic", second_target_id: self.topic_id,
+ actor_id: self.user_id
+ }
+ end
+
+ def notification_receiver_ids
+ return @notification_receiver_ids if defined? @notification_receiver_ids
+ # 加入帖子关注着
+ follower_ids = self.topic.try(:follow_by_user_ids) || []
+ # 加入回帖人的关注者
+ follower_ids += self.user.try(:follow_by_user_ids) || []
+ # 加入发帖人
+ follower_ids << self.topic.try(:user_id)
+ # 去重复
+ follower_ids.uniq!
+ # 排除回帖人
+ follower_ids.delete(self.user_id)
+ # 排除同一个回复过程中已经提醒过的人
+ follower_ids -= self.mentioned_user_ids
+ @notification_receiver_ids = follower_ids
+ end
+
+ # 是否热门
+ def popular?
+ likes_count >= 5
+ end
+
+ def upvote?
+ (body || "").strip.start_with?(*UPVOTES)
+ end
+
+ def destroy
+ super
+ Notification.where(notify_type: "topic_reply", target: self).delete_all
+ delete_notification_mentions
+ end
+
+ # 是否是系统事件
+ def system_event?
+ action.present?
+ end
+
+ def self.create_system_event(opts = {})
+ opts[:body] ||= ""
+ opts[:user] ||= User.current
+ return false if opts[:action].blank?
+ return false if opts[:user].blank?
+ self.create(opts)
end
end
diff --git a/app/models/section.rb b/app/models/section.rb
index b1bfd4683..fc83952e8 100644
--- a/app/models/section.rb
+++ b/app/models/section.rb
@@ -1,7 +1,26 @@
-# coding: utf-8
-class Section < ActiveRecord::Base
- validates_presence_of :name
- validates_uniqueness_of :name
- has_many :nodes
- default_scope :order => "sort desc"
+class Section < ApplicationRecord
+
+ second_level_cache expires_in: 2.weeks
+
+ has_many :nodes, dependent: :destroy
+
+ validates :name, presence: true, uniqueness: true
+
+ default_scope -> { order(sort: :desc) }
+
+ after_save :update_cache_version
+ after_destroy :update_cache_version
+
+ def update_cache_version
+ # 记录节点变更时间,用于清除缓存
+ CacheVersion.section_node_updated_at = Time.now.to_i
+ end
+
+ def sorted_nodes
+ nodes.where.not(id: Node.no_point.id).sorted
+ end
+
+ def self.default
+ @default ||= Section.first || Section.create(name: "分享")
+ end
end
diff --git a/app/models/setting.rb b/app/models/setting.rb
new file mode 100644
index 000000000..a7e06732e
--- /dev/null
+++ b/app/models/setting.rb
@@ -0,0 +1,74 @@
+# RailsSettings Model
+class Setting < RailsSettings::Base
+ source Rails.root.join("config/config.yml")
+
+ # List setting value separator chars
+ SEPARATOR_REGEXP = /[\s,]/
+
+ # keys that allow update in admin
+ KEYS_IN_ADMIN = %w(
+ custom_head_html
+ navbar_html
+ navbar_brand_html
+ footer_html
+ index_html
+ wiki_index_html
+ wiki_sidebar_html
+ site_index_html
+ topic_index_sidebar_html
+ after_topic_html
+ before_topic_html
+ node_ids_hide_in_topics_index
+ reject_newbie_reply_in_the_evening
+ newbie_limit_time
+ ban_words_on_reply
+ newbie_notices
+ tips
+ apns_pem
+ blacklist_ips
+ admin_emails
+ ban_reasons
+ )
+
+ class << self
+ def protocol
+ self.https == true ? "https" : "http"
+ end
+
+ def base_url
+ [self.protocol, self.domain].join("://")
+ end
+
+ def has_admin?(email)
+ return false if self.admin_emails.blank?
+ self.admin_emails.split(SEPARATOR_REGEXP).include?(email)
+ end
+
+ def has_module?(name)
+ return true if self.modules.blank? || self.modules == "all"
+ self.module_list.include?(name.to_s)
+ end
+
+ def module_list
+ self.modules.split(SEPARATOR_REGEXP)
+ end
+
+ def ban_reason_list
+ (self.ban_reasons || "").split("\n")
+ end
+
+ def has_profile_field?(name)
+ return true if self.profile_fields.blank? || self.profile_fields == "all"
+ self.profile_fields.split(SEPARATOR_REGEXP).include?(name.to_s)
+ end
+
+ def sso_enabled?
+ return false if self.sso_provider_enabled?
+ self.sso["enable"] == true
+ end
+
+ def sso_provider_enabled?
+ self.sso["enable_provider"] == true
+ end
+ end
+end
diff --git a/app/models/site_node.rb b/app/models/site_node.rb
new file mode 100644
index 000000000..f0647e30a
--- /dev/null
+++ b/app/models/site_node.rb
@@ -0,0 +1,15 @@
+class SiteNode < ApplicationRecord
+ second_level_cache version: 1, expires_in: 1.week
+
+ has_many :sites
+
+ validates :name, presence: true, uniqueness: true
+
+ after_save :update_cache_version
+ after_destroy :update_cache_version
+
+ def update_cache_version
+ # 记录节点变更时间,用于清除缓存
+ CacheVersion.sites_updated_at = Time.now.to_i
+ end
+end
diff --git a/app/models/team.rb b/app/models/team.rb
new file mode 100644
index 000000000..12be18398
--- /dev/null
+++ b/app/models/team.rb
@@ -0,0 +1,27 @@
+class Team < User
+ has_many :team_users
+ has_many :users, through: :team_users
+
+ has_many :topics
+
+ attr_accessor :owner_id
+ after_create do
+ self.team_users.create(user_id: owner_id, role: :owner, status: :accepted) if self.owner_id.present?
+ end
+
+ def user_ids
+ @user_ids ||= self.users.pluck(:id)
+ end
+
+ def password_required?
+ false
+ end
+
+ def owner?(user)
+ self.team_users.accepted.exists?(role: :owner, user_id: user.id)
+ end
+
+ def member?(user)
+ self.team_users.accepted.exists?(user_id: user.id)
+ end
+end
diff --git a/app/models/team_user.rb b/app/models/team_user.rb
new file mode 100644
index 000000000..9717b8a5c
--- /dev/null
+++ b/app/models/team_user.rb
@@ -0,0 +1,38 @@
+class TeamUser < ApplicationRecord
+ enum role: %i(owner member)
+ enum status: %i(pendding accepted)
+
+ belongs_to :team, touch: true, counter_cache: true
+ belongs_to :user
+
+ validates :login, :team_id, :role, presence: true, on: :invite
+ validates :user_id, uniqueness: { scope: :team_id, message: I18n.t("teams.user_existed") }
+
+ attr_accessor :login, :actor_id
+
+ before_validation do
+ if login.present?
+ u = User.find_by_login(login)
+ self.errors.add(:login, :notfound) if u.blank?
+ self.user_id = u&.id
+ end
+ end
+ after_create_commit :notify_user_to_accept
+
+ def status_name
+ I18n.t("team_user_status.#{self.status}")
+ end
+
+ def role_name
+ I18n.t("team_user_role.#{self.role}")
+ end
+
+ def notify_user_to_accept
+ return unless self.pendding?
+ Notification.create notify_type: "team_invite",
+ actor_id: self.actor_id,
+ user_id: self.user_id,
+ target: self,
+ second_target: team
+ end
+end
diff --git a/app/models/topic.rb b/app/models/topic.rb
index 4ea7321e6..d73dee6dc 100644
--- a/app/models/topic.rb
+++ b/app/models/topic.rb
@@ -1,73 +1,261 @@
-# coding: utf-8
-class Topic < ActiveRecord::Base
- attr_protected :user_id
- validates_presence_of :user_id, :title, :body, :node_id
- belongs_to :node, :counter_cache => true
- belongs_to :user
- belongs_to :last_reply_user, :class_name => "User"
- has_many :replies, :dependent => :destroy, :include => [:user]
+require "auto-space"
+
+CORRECT_CHARS = [
+ ["[", "["],
+ ["]", "]"],
+ ["【", "["],
+ ["】", "]"],
+ ["(", "("],
+ [")", ")"]
+]
+
+class Topic < ApplicationRecord
+ include MarkdownBody
+ include SoftDelete
+ include Mentionable
+ include Closeable
+ include Searchable
+ include MentionTopic
+ include UserAvatarDelegate
+
+ # 临时存储检测用户是否读过的结果
+ attr_accessor :read_state, :admin_editing
+
+ belongs_to :user, inverse_of: :topics, counter_cache: true
+ belongs_to :team, counter_cache: true
+ belongs_to :node, counter_cache: true
+ belongs_to :last_reply_user, class_name: "User"
+ belongs_to :last_reply, class_name: "Reply"
+ has_many :replies, dependent: :destroy
+
+ validates :user_id, :title, :body, :node_id, presence: true
+
+ counter :hits, default: 0
+
+ delegate :login, to: :user, prefix: true, allow_nil: true
+ delegate :body, to: :last_reply, prefix: true, allow_nil: true
# scopes
- scope :last_actived, :order => "replied_at desc",
- :include => [:user,:last_reply_user,:node]
- scope :recents, :order => "id desc", :include => [:user,:last_reply_user,:node]
- before_save :set_replied_at
- def set_replied_at
- self.replied_at = Time.now
- end
-
- # 检查用户是否看过
- # result:
- # 0 读过
- # 1 未读
- # 2 最后是用户的回复
- def user_readed?(user_id)
- uids = Rails.cache.read("Topic:user_read:#{self.id}")
- if uids.blank?
- if self.last_reply_user_id == user_id || self.user_id == user_id
- return 2
- else
- return 1
- end
+ scope :last_actived, -> { order(last_active_mark: :desc) }
+ scope :suggest, -> { where("suggested_at IS NOT NULL").order(suggested_at: :desc) }
+ scope :without_suggest, -> { where(suggested_at: nil) }
+ scope :high_likes, -> { order(likes_count: :desc).order(id: :desc) }
+ scope :high_replies, -> { order(replies_count: :desc).order(id: :desc) }
+ scope :no_reply, -> { where(replies_count: 0) }
+ scope :popular, -> { where("likes_count > 5") }
+ scope :excellent, -> { where("excellent >= 1") }
+ scope :without_hide_nodes, -> { exclude_column_ids("node_id", Topic.topic_index_hide_node_ids) }
+
+ scope :without_node_ids, ->(ids) { exclude_column_ids("node_id", ids) }
+ scope :without_users, ->(ids) { exclude_column_ids("user_id", ids) }
+ scope :exclude_column_ids, ->(column, ids) { ids.empty? ? all : where.not(column => ids) }
+
+ scope :without_nodes, lambda { |node_ids|
+ ids = node_ids + Topic.topic_index_hide_node_ids
+ ids.uniq!
+ exclude_column_ids("node_id", ids)
+ }
+
+ mapping do
+ indexes :title, term_vector: :yes
+ indexes :body, term_vector: :yes
+ end
+
+ def as_indexed_json(_options = {})
+ {
+ title: self.title,
+ body: self.full_body
+ }
+ end
+
+ def indexed_changed?
+ saved_change_to_title? || saved_change_to_body?
+ end
+
+ def related_topics(size = 5)
+ opts = {
+ query: {
+ more_like_this: {
+ fields: [:title, :body],
+ like: [
+ {
+ _index: self.class.index_name,
+ _type: self.class.document_type,
+ _id: id
+ }
+ ],
+ min_term_freq: 2,
+ min_doc_freq: 5
+ }
+ },
+ size: size
+ }
+ self.class.__elasticsearch__.search(opts).records.to_a
+ end
+
+ def self.fields_for_list
+ columns = %w(body who_deleted)
+ select(column_names - columns.map(&:to_s))
+ end
+
+ def full_body
+ ([self.body] + self.replies.pluck(:body)).join('\n\n')
+ end
+
+ def self.topic_index_hide_node_ids
+ Setting.node_ids_hide_in_topics_index.to_s.split(",").collect(&:to_i)
+ end
+
+ before_save :store_cache_fields
+ def store_cache_fields
+ self.node_name = node.try(:name) || ""
+ end
+
+ before_save :auto_correct_title
+ def auto_correct_title
+ CORRECT_CHARS.each do |chars|
+ title.gsub!(chars[0], chars[1])
+ end
+ title.auto_space!
+ end
+ before_save do
+ if admin_editing == true && self.node_id_changed?
+ Topic.notify_topic_node_changed(id, node_id)
+ end
+ end
+
+ before_create :init_last_active_mark_on_create
+ def init_last_active_mark_on_create
+ self.last_active_mark = Time.now.to_i
+ end
+
+ after_commit :async_create_reply_notify, on: :create
+ def async_create_reply_notify
+ NotifyTopicJob.perform_later(id)
+ end
+
+ def update_last_reply(reply, opts = {})
+ # replied_at 用于最新回复的排序,如果帖着创建时间在一个月以前,就不再往前面顶了
+ return false if reply.blank? && !opts[:force]
+
+ self.last_active_mark = Time.now.to_i if created_at > 1.month.ago
+ self.replied_at = reply.try(:created_at)
+ self.replies_count = replies.without_system.count
+ self.last_reply_id = reply.try(:id)
+ self.last_reply_user_id = reply.try(:user_id)
+ self.last_reply_user_login = reply.try(:user_login)
+ # Reindex Search document
+ SearchIndexer.perform_later("update", "topic", self.id)
+ save
+ end
+
+ # 更新最后更新人,当最后个回帖删除的时候
+ def update_deleted_last_reply(deleted_reply)
+ return false if deleted_reply.blank?
+ return false if last_reply_user_id != deleted_reply.user_id
+
+ previous_reply = replies.without_system.where.not(id: deleted_reply.id).recent.first
+ update_last_reply(previous_reply, force: true)
+ end
+
+ # 删除并记录删除人
+ def destroy_by(user)
+ return false if user.blank?
+ update_attribute(:who_deleted, user.login)
+ destroy
+ end
+
+ def destroy
+ super
+ delete_notification_mentions
+ end
+
+ # 所有的回复编号
+ def reply_ids
+ Rails.cache.fetch([self, "reply_ids"]) do
+ self.replies.order("id asc").pluck(:id)
end
+ end
+
+ def excellent?
+ excellent >= 1
+ end
- if uids.index(user_id)
- return 0
- else
- if self.last_reply_user_id == user_id || self.user_id == user_id
- return 2
- else
- return 1
+ def ban!(opts = {})
+ transaction do
+ update(lock_node: true, node_id: Node.no_point.id, admin_editing: true)
+ if opts[:reason]
+ Reply.create_system_event(action: "ban", topic_id: self.id, body: opts[:reason])
end
end
end
- # 记录用户读过
- def user_readed(user_id)
- uids = Rails.cache.read("Topic:user_read:#{self.id}")
- if uids.blank?
- uids = [user_id]
- else
- uids = uids.dup
+ def excellent!
+ transaction do
+ Reply.create_system_event(action: "excellent", topic_id: self.id)
+ update!(excellent: 1)
+ end
+ end
+
+ def unexcellent!
+ transaction do
+ Reply.create_system_event(action: "unexcellent", topic_id: self.id)
+ update!(excellent: 0)
end
+ end
- uids << user_id
- Rails.cache.write("Topic:user_read:#{self.id}",uids)
+ def floor_of_reply(reply)
+ reply_index = reply_ids.index(reply.id)
+ reply_index + 1
end
- # 清除用户读过的记录
- # 用户回复的时候清除状态
- def clear_user_readed
- Rails.cache.write("Topic:user_read:#{self.id}",nil)
+ def self.notify_topic_created(topic_id)
+ topic = Topic.find_by_id(topic_id)
+ return unless topic && topic.user
+
+ follower_ids = topic.user.follow_by_user_ids
+ return if follower_ids.empty?
+
+ notified_user_ids = topic.mentioned_user_ids
+
+ # 给关注者发通知
+ default_note = { notify_type: "topic", target_type: "Topic", target_id: topic.id, actor_id: topic.user_id }
+ Notification.bulk_insert(set_size: 100) do |worker|
+ follower_ids.each do |uid|
+ # 排除同一个回复过程中已经提醒过的人
+ next if notified_user_ids.include?(uid)
+ # 排除回帖人
+ next if uid == topic.user_id
+ note = default_note.merge(user_id: uid)
+ worker.add(note)
+ end
+ end
+
+ true
end
-
- def self.search(key,options = {})
- paginate :conditions => "title like '%#{key}%'",:page => 1
+
+ def self.notify_topic_node_changed(topic_id, node_id)
+ topic = Topic.find_by_id(topic_id)
+ return if topic.blank?
+ node = Node.find_by_id(node_id)
+ return if node.blank?
+
+ Notification.create notify_type: "node_changed",
+ user_id: topic.user_id,
+ target: topic,
+ second_target: node
+ true
end
-
- def self.cached_count
- return Rails.cache.fetch("topics/count",:expires_in => 1.hours) do
- self.count
+
+ def self.total_pages
+ return @total_pages if defined? @total_pages
+
+ total_count = Rails.cache.fetch("topics/total_count", expires_in: 1.week) do
+ self.unscoped.count
+ end
+ if total_count >= 1500
+ @total_pages = 60
end
+ @total_pages
end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 415dafee7..5de617b2c 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,78 +1,284 @@
-# coding: utf-8
-class User < ActiveRecord::Base
- attr_protected :email, :name, :state
- attr_accessor :password_confirmation
- acts_as_authentic do |c|
- c.ignore_blank_passwords = true #ignoring passwords
- c.validate_password_field = false #ignoring validations for password fields
- end
-
- #here we add required validations for a new record and pre-existing record
- validate do |user|
- if user.new_record? #adds validation if it is a new record
- user.errors.add(:password, "is required") if user.password.blank?
- user.errors.add(:password_confirmation, "is required") if user.password_confirmation.blank?
- user.errors.add(:password, "Password and confirmation must match") if user.password != user.password_confirmation
- elsif !(!user.new_record? && user.password.blank? && user.password_confirmation.blank?) #adds validation only if password or password_confirmation are modified
- user.errors.add(:password, "is required") if user.password.blank?
- user.errors.add(:password_confirmation, "is required") if user.password_confirmation.blank?
- user.errors.add(:password, " and confirmation must match.") if user.password != user.password_confirmation
- user.errors.add(:password, " and confirmation should be atleast 4 characters long.") if user.password.length < 4 || user.password_confirmation.length < 4
- end
- end
-
- validates_presence_of :name
- validates_presence_of :password, :on => :create
- validates_uniqueness_of :name
- has_many :topics, :dependent => :destroy
- has_many :replies, :dependent => :destroy
- has_many :notes, :dependent => :destroy
- has_many :authorizations, :dependent => :destroy
-
- before_create :default_value_for_create
- def default_value_for_create
- self.state = STATE[:normal]
- end
-
- # 注册邮件提醒
- after_create :send_welcome_mail
- def send_welcome_mail
- m = UserMailer.create_welcome(self)
- Thread.new { m.deliver }
- rescue => e
- logger.error { e }
- end
-
- # 封面图
- has_attached_file :avatar,
- :default_style => :normal,
- :styles => {
- :small => "16x16#",
- :normal => "48x48#",
- :large => "80x80#",
- },
- :url => "#{APP_CONFIG['upload_url']}/:class/:attachment/:hashed_path/:id_:style.jpg",
- :path => "#{APP_CONFIG['upload_root']}/:class/:attachment/:hashed_path/:id_:style.jpg",
- :default_url => "avatar/:style.jpg"
-
- STATE = {
- :normal => 1,
- # 屏蔽
- :blocked => 2
+require "digest/md5"
+
+class User < ApplicationRecord
+ include Searchable
+ include OmniauthCallbacks
+ include Blockable
+ include Likeable
+ include Followable
+ include TopicRead
+ include TopicFavorite
+ include GithubRepository
+ include UserCallbacks
+ include ProfileFields
+ include RewardFields
+
+ second_level_cache expires_in: 2.weeks
+
+ LOGIN_FORMAT = 'A-Za-z0-9\-\_\.'
+ ALLOW_LOGIN_FORMAT_REGEXP = /\A[#{LOGIN_FORMAT}]+\z/
+
+ devise :database_authenticatable, :registerable, :recoverable, :lockable,
+ :rememberable, :trackable, :validatable, :omniauthable
+
+ mount_uploader :avatar, AvatarUploader
+
+ has_many :topics, dependent: :destroy
+ has_many :replies, dependent: :destroy
+ has_many :authorizations, dependent: :destroy
+ has_many :notifications, dependent: :destroy
+ has_many :photos
+ has_many :oauth_applications, class_name: "Doorkeeper::Application", as: :owner
+ has_many :devices
+ has_many :team_users
+ has_many :teams, through: :team_users
+ has_one :sso, class_name: "UserSSO", dependent: :destroy
+
+ attr_accessor :password_confirmation
+
+ ACCESSABLE_ATTRS = [:name, :email_public, :location, :company, :bio, :website, :github, :twitter,
+ :tagline, :avatar, :by, :current_password, :password, :password_confirmation,
+ :_rucaptcha]
+
+ enum state: { deleted: -1, normal: 1, blocked: 2 }
+
+ validates :login, format: { with: ALLOW_LOGIN_FORMAT_REGEXP, message: "只允许数字、大小写字母、中横线、下划线" },
+ length: { in: 2..20 },
+ presence: true,
+ uniqueness: { case_sensitive: false }
+
+ validates :name, length: { maximum: 20 }
+
+ scope :hot, -> { order(replies_count: :desc).order(topics_count: :desc) }
+ scope :without_team, -> { where(type: nil) }
+ scope :fields_for_list, lambda {
+ select(:type, :id, :name, :login, :email, :email_md5, :email_public,
+ :avatar, :verified, :state, :tagline, :github, :website, :location,
+ :location_id, :twitter, :team_users_count, :created_at, :updated_at)
}
- def self.cached_count
- return Rails.cache.fetch("users/count",:expires_in => 1.hours) do
- self.count
+ def self.find_by_email(email)
+ fetch_by_uniq_keys(email: email)
+ end
+
+ def self.find_by_login!(slug)
+ find_by_login(slug) || raise(ActiveRecord::RecordNotFound.new(slug: slug))
+ end
+
+ def self.find_by_login(slug)
+ return nil unless slug.match? ALLOW_LOGIN_FORMAT_REGEXP
+ fetch_by_uniq_keys(login: slug) || where("lower(login) = ?", slug.downcase).take
+ end
+
+ def self.find_by_login_or_email(login_or_email)
+ login_or_email = login_or_email.downcase
+ find_by_login(login_or_email) || find_by_email(login_or_email)
+ end
+
+ def self.find_for_database_authentication(warden_conditions)
+ conditions = warden_conditions.dup
+ login = conditions.delete(:login)
+ login.downcase!
+ where(conditions.to_h).where(["(lower(login) = :value OR lower(email) = :value) and state != -1", { value: login }]).first
+ end
+
+ def self.current
+ Thread.current[:current_user]
+ end
+
+ def self.current=(user)
+ Thread.current[:current_user] = user
+ end
+
+ def self.search(term, options = {})
+ limit = (options[:limit] || 30).to_i
+ user = options[:user]
+ following = []
+ term = term.to_s + "%"
+ users = User.where("login ilike ? or name ilike ?", term, term).order("replies_count desc").limit(limit).to_a
+ if user
+ following = user.follow_users.where("login ilike ? or name ilike ?", term, term).to_a
end
+ users.unshift(*Array(following))
+ users.uniq!
+ users.compact!
+
+ users.first(limit)
+ end
+
+ def to_param
+ login
+ end
+
+ def user_type
+ (self[:type] || "User").underscore.to_sym
+ end
+
+ def organization?
+ self.user_type == :team
+ end
+
+ def email=(val)
+ self.email_md5 = Digest::MD5.hexdigest(val || "")
+ self[:email] = val
+ end
+
+ def password_required?
+ (authorizations.empty? || !password.blank?) && super
+ end
+
+ def profile_url
+ "/#{login}"
+ end
+
+ def github_url
+ return "" if github.blank?
+ "https://github.com/#{github.split('/').last}"
+ end
+
+ def website_url
+ return "" if website.blank?
+ website[%r{^https?://}] ? website : "http://#{website}"
+ end
+
+ def twitter_url
+ return "" if twitter.blank?
+ "https://twitter.com/#{twitter}"
+ end
+
+ def fullname
+ return login if name.blank?
+ "#{login} (#{name})"
end
- def self.create_from_hash(auth)
- user = User.new
- user.name = auth["user_info"]["name"]
- user.email = auth['user_info']['email']
- user.save(false)
- user.reset_persistence_token! #set persistence_token else sessions will not be created
- user
- end
+ # 是否是管理员
+ def admin?
+ Setting.has_admin?(email)
+ end
+
+ # 是否有 Wiki 维护权限
+ def wiki_editor?
+ self.admin? || verified == true
+ end
+
+ # 回帖大于 150 的才有酷站的发布权限
+ def site_editor?
+ self.admin? || replies_count >= 100
+ end
+
+ # 是否能发帖
+ def newbie?
+ return false if verified?
+ t = Setting.newbie_limit_time.to_i
+ return false if t == 0
+ created_at > t.seconds.ago
+ end
+
+ def roles?(role)
+ case role
+ when :admin then admin?
+ when :wiki_editor then wiki_editor?
+ when :site_editor then site_editor?
+ when :member then self.normal?
+ else false
+ end
+ end
+
+ # 用户的账号类型
+ def level
+ if admin?
+ "admin"
+ elsif verified?
+ "vip"
+ elsif blocked?
+ "blocked"
+ elsif newbie?
+ "newbie"
+ else
+ "normal"
+ end
+ end
+
+ def level_name
+ I18n.t("common.#{level}_user")
+ end
+
+ # Override Devise to send mails with async
+ def send_devise_notification(notification, *args)
+ devise_mailer.send(notification, self, *args).deliver_later
+ end
+
+ def bind?(provider)
+ authorizations.collect(&:provider).include?(provider)
+ end
+
+ def bind_service(response)
+ provider = response["provider"]
+ uid = response["uid"].to_s
+ authorizations.create(provider: provider, uid: uid)
+ end
+
+ # 软删除
+ def soft_delete
+ self.state = "deleted"
+ save(validate: false)
+ end
+
+ def letter_avatar_url(size)
+ path = LetterAvatar.generate(self.login, size).sub("public/", "/")
+
+ "#{Setting.base_url}#{path}"
+ end
+
+ def large_avatar_url
+ if self[:avatar].present?
+ self.avatar.url(:lg)
+ else
+ self.letter_avatar_url(192)
+ end
+ end
+
+ def avatar?
+ self[:avatar].present?
+ end
+
+ # @example.com 的可以修改邮件地址
+ def email_locked?
+ self.email.exclude?("@example.com")
+ end
+
+ def calendar_data
+ Rails.cache.fetch(["user", self.id, "calendar_data", Date.today, "by-months"]) do
+ calendar_data_without_cache
+ end
+ end
+
+ def calendar_data_without_cache
+ date_from = 12.months.ago.beginning_of_month.to_date
+ replies = self.replies.where("created_at > ?", date_from)
+ .group("date(created_at AT TIME ZONE 'CST')")
+ .select("date(created_at AT TIME ZONE 'CST') AS date, count(id) AS total_amount").all
+
+ replies.each_with_object({}) do |reply, timestamps|
+ timestamps[reply["date"].to_time.to_i.to_s] = reply["total_amount"]
+ end
+ end
+
+ def team_collection
+ return @team_collection if defined? @team_collection
+ teams = self.admin? ? Team.all : self.teams
+ @team_collection = teams.collect { |t| [t.name, t.id] }
+ end
+
+ # for Searchable
+ def as_indexed_json(_options = {})
+ as_json(only: [:login, :name, :tagline, :bio, :email, :location])
+ end
+
+ def indexed_changed?
+ %i(login name tagline bio email location).each do |key|
+ return true if saved_change_to_attribute?(key)
+ end
+ false
+ end
end
diff --git a/app/models/user/blockable.rb b/app/models/user/blockable.rb
new file mode 100644
index 000000000..0bcf4ac3f
--- /dev/null
+++ b/app/models/user/blockable.rb
@@ -0,0 +1,14 @@
+class User
+ module Blockable
+ extend ActiveSupport::Concern
+
+ included do
+ action_store :block, :user
+ action_store :block, :node
+ end
+
+ def block_users?
+ block_user_actions.first.present?
+ end
+ end
+end
diff --git a/app/models/user/followable.rb b/app/models/user/followable.rb
new file mode 100644
index 000000000..fe4c549da
--- /dev/null
+++ b/app/models/user/followable.rb
@@ -0,0 +1,17 @@
+class User
+ module Followable
+ extend ActiveSupport::Concern
+
+ included do
+ action_store :follow, :topic
+ action_store :follow, :user, counter_cache: "followers_count",
+ user_counter_cache: "following_count"
+ end
+
+ def follow_user(user)
+ return unless user
+ self.create_action(:follow, target: user)
+ Notification.notify_follow(user.id, self.id)
+ end
+ end
+end
diff --git a/app/models/user/github_repository.rb b/app/models/user/github_repository.rb
new file mode 100644
index 000000000..73d57ec30
--- /dev/null
+++ b/app/models/user/github_repository.rb
@@ -0,0 +1,59 @@
+class User
+ module GithubRepository
+ extend ActiveSupport::Concern
+
+ included do
+ end
+
+ # GitHub 项目
+ def github_repositories
+ cache_key = github_repositories_cache_key
+ items = Homeland.file_store.read(cache_key)
+ if items.nil?
+ GithubRepoFetcherJob.perform_later(id)
+ items = []
+ end
+ items.take(10)
+ end
+
+ def github_repositories_cache_key
+ "github-repos:#{github}:1"
+ end
+
+ def github_repo_api_url
+ github_login = self.github || self.login
+ resource_name = organization? ? "orgs" : "users"
+ "https://api.github.com/#{resource_name}/#{github_login}/repos?type=owner&sort=pushed&client_id=#{Setting.github_token}&client_secret=#{Setting.github_secret}"
+ end
+
+ module ClassMethods
+ def fetch_github_repositories(user_id)
+ user = User.find_by(id: user_id)
+ return unless user
+
+ url = user.github_repo_api_url
+ begin
+ json = Timeout.timeout(10) { open(url).read }
+ rescue => e
+ Rails.logger.error("GitHub Repositiory fetch Error: #{e}")
+ Homeland.file_store.write(user.github_repositories_cache_key, [], expires_in: 1.minutes)
+ return
+ end
+
+ items = JSON.parse(json)
+ items = items.collect do |a1|
+ {
+ name: a1["name"],
+ url: a1["html_url"],
+ watchers: a1["watchers"],
+ language: a1["language"],
+ description: a1["description"]
+ }
+ end
+ items.sort! { |a, b| b[:watchers] <=> a[:watchers] }.take(10)
+ Homeland.file_store.write(user.github_repositories_cache_key, items, expires_in: 15.days)
+ items
+ end
+ end
+ end
+end
diff --git a/app/models/user/likeable.rb b/app/models/user/likeable.rb
new file mode 100644
index 000000000..46ea40a26
--- /dev/null
+++ b/app/models/user/likeable.rb
@@ -0,0 +1,38 @@
+class User
+ module Likeable
+ extend ActiveSupport::Concern
+
+ included do
+ # Action for Topic
+ action_store :like, :topic, counter_cache: true
+ # Action for Reply
+ action_store :like, :reply, counter_cache: true
+ end
+
+ # 赞
+ def like(likeable)
+ return false if likeable.blank?
+ return false if likeable.user_id == self.id
+ self.create_action(:like, target: likeable)
+ end
+
+ # 取消赞
+ def unlike(likeable)
+ return false if likeable.blank?
+ self.destroy_action(:like, target: likeable)
+ end
+
+ # 是否喜欢过
+ def liked?(likeable)
+ self.find_action(:like, target: likeable).present?
+ end
+
+ # 基于一组 Reply 获取用户已经喜欢过的内容
+ def like_reply_ids_by_replies(replies)
+ return [] if replies.blank?
+ return [] if self.like_reply_ids.blank?
+ # Intersection between reply ids and user like_reply_ids
+ self.like_reply_actions.where(target_id: replies.collect(&:id)).pluck(:target_id)
+ end
+ end
+end
diff --git a/app/models/user/omniauth_callbacks.rb b/app/models/user/omniauth_callbacks.rb
new file mode 100644
index 000000000..36cd9aad8
--- /dev/null
+++ b/app/models/user/omniauth_callbacks.rb
@@ -0,0 +1,57 @@
+class User
+ module OmniauthCallbacks
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ %w(github).each do |provider|
+ define_method "find_or_create_for_#{provider}" do |response|
+ uid = response["uid"].to_s
+ data = response["info"]
+
+ if (user = Authorization.find_by(provider: provider, uid: uid).try(:user))
+ user
+ else
+ user = User.new_from_provider_data(provider, uid, data)
+
+ if user.save(validate: false)
+ Authorization.find_or_create_by(provider: provider, uid: uid, user_id: user.id)
+ return user
+ else
+ Rails.logger.warn("User.create_from_hash 失败,#{user.errors.inspect}")
+ return nil
+ end
+ end
+ end
+ end
+
+ def new_from_provider_data(provider, uid, data)
+ User.new do |user|
+ user.email =
+ if data["email"].present? && !User.where(email: data["email"]).exists?
+ data["email"]
+ else
+ "#{provider}+#{uid}@example.com"
+ end
+
+ user.name = data["name"]
+ user.login = Homeland::Username.sanitize(data["nickname"])
+ if provider == "github"
+ user.github = data["nickname"]
+ end
+
+ if user.login.blank?
+ user.login = "u#{Time.now.to_i}"
+ end
+
+ if User.where(login: user.login).exists?
+ user.login = "#{user.github}-github" # TODO: possibly duplicated user login here. What should we do?
+ end
+
+ user.password = Devise.friendly_token[0, 20]
+ user.location = data["location"]
+ user.tagline = data["description"]
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/user/profile_fields.rb b/app/models/user/profile_fields.rb
new file mode 100644
index 000000000..03f5f8bb8
--- /dev/null
+++ b/app/models/user/profile_fields.rb
@@ -0,0 +1,60 @@
+class User
+ module ProfileFields
+ extend ActiveSupport::Concern
+
+ included do
+ include RailsSettings::Extend
+
+ PROFILE_FIELDS = %i(alipay paypal qq weibo wechat douban dingding aliwangwang
+ facebook instagram dribbble battle_tag psn_id steam_id)
+
+ PROFILE_FIELD_PREFIXS = {
+ douban: "https://www.douban.com/people/",
+ weibo: "https://weibo.com/",
+ facebook: "https://facebook.com/",
+ instagram: "https://instagram.com/",
+ dribbble: "https://dribbble.com/",
+ battle_tag: "#"
+ }
+ end
+
+ def profile_fields
+ return @profile_fields if defined? @profile_fields
+ @profile_fields = self.settings.profile_fields || {}
+ unless @profile_fields.is_a?(Hash)
+ @profile_fields = {}
+ end
+ @profile_fields
+ end
+
+ def profile_field(field)
+ return nil unless PROFILE_FIELDS.include?(field.to_sym)
+ profile_fields[field.to_sym]
+ end
+
+ def full_profile_field(field)
+ v = profile_field(field)
+ prefix = User.profile_field_prefix(field)
+ return v if prefix.blank?
+ [prefix, v].join("")
+ end
+
+ def update_profile_fields(field_values)
+ field_values.each do |key, value|
+ next unless PROFILE_FIELDS.include?(key.to_sym)
+ profile_fields[key.to_sym] = value
+ end
+ self.settings.profile_fields = profile_fields
+ end
+
+ module ClassMethods
+ def profile_field_prefix(field)
+ PROFILE_FIELD_PREFIXS[field.to_sym]
+ end
+
+ def profile_field_label(field)
+ I18n.t("activerecord.attributes.user.profile_fields.#{field}")
+ end
+ end
+ end
+end
diff --git a/app/models/user/reward_fields.rb b/app/models/user/reward_fields.rb
new file mode 100644
index 000000000..fa19a1a12
--- /dev/null
+++ b/app/models/user/reward_fields.rb
@@ -0,0 +1,46 @@
+class User
+ module RewardFields
+ extend ActiveSupport::Concern
+
+ included do
+ include RailsSettings::Extend
+
+ REWARD_FIELDS = %i(alipay wechat)
+ end
+
+ def reward_enabled?
+ REWARD_FIELDS.each do |key|
+ return true if reward_field(key).present?
+ end
+ false
+ end
+
+ def reward_field(field)
+ return nil unless REWARD_FIELDS.include?(field.to_sym)
+ reward_fields[field.to_sym]
+ end
+
+ def reward_fields
+ return @reward_fields if defined? @reward_fields
+ @reward_fields = self.settings.reward_fields || {}
+ unless @reward_fields.is_a?(Hash)
+ @reward_fields = {}
+ end
+ @reward_fields
+ end
+
+ def update_reward_fields(field_values)
+ field_values.each do |key, value|
+ next unless REWARD_FIELDS.include?(key.to_sym)
+ reward_fields[key.to_sym] = value
+ end
+ self.settings.reward_fields = reward_fields
+ end
+
+ module ClassMethods
+ def reward_field_label(field)
+ I18n.t("activerecord.attributes.user.profile_fields.#{field}")
+ end
+ end
+ end
+end
diff --git a/app/models/user/topic_favorite.rb b/app/models/user/topic_favorite.rb
new file mode 100644
index 000000000..f03833755
--- /dev/null
+++ b/app/models/user/topic_favorite.rb
@@ -0,0 +1,13 @@
+class User
+ module TopicFavorite
+ extend ActiveSupport::Concern
+
+ included do
+ action_store :favorite, :topic
+ end
+
+ def favorites_count
+ favorite_topic_actions.count
+ end
+ end
+end
diff --git a/app/models/user/topic_read.rb b/app/models/user/topic_read.rb
new file mode 100644
index 000000000..37efc450a
--- /dev/null
+++ b/app/models/user/topic_read.rb
@@ -0,0 +1,50 @@
+class User
+ module TopicRead
+ extend ActiveSupport::Concern
+
+ # 是否读过 topic 的最近更新
+ def topic_read?(topic)
+ # 用 last_reply_id 作为 cache key ,以便不热门的数据自动被 Memcached 挤掉
+ last_reply_id = topic.last_reply_id || -1
+ Rails.cache.read("user:#{id}:topic_read:#{topic.id}") == last_reply_id
+ end
+
+ def filter_readed_topics(topics)
+ t1 = Time.now
+ return [] if topics.blank?
+ cache_keys = topics.map { |t| "user:#{id}:topic_read:#{t.id}" }
+ results = Rails.cache.read_multi(*cache_keys)
+ ids = []
+ topics.each do |topic|
+ val = results["user:#{id}:topic_read:#{topic.id}"]
+ if val == (topic.last_reply_id || -1)
+ ids << topic.id
+ end
+ end
+ t2 = Time.now
+ logger.info "User filter_readed_topics (#{(t2 - t1) * 1000}ms)"
+ ids
+ end
+
+ # 将 topic 的最后回复设置为已读
+ def read_topic(topic, opts = {})
+ return if topic.blank?
+ return if self.topic_read?(topic)
+
+ opts[:replies_ids] ||= topic.replies.pluck(:id)
+
+ any_sql = "
+ (target_type = 'Topic' AND target_id = ?) or
+ (target_type = 'Reply' AND target_id in (?))
+ "
+ notifications.unread
+ .where(any_sql, topic.id, opts[:replies_ids])
+ .update_all(read_at: Time.now)
+ Notification.realtime_push_to_client(self)
+
+ # 处理 last_reply_id 是空的情况
+ last_reply_id = topic.last_reply_id || -1
+ Rails.cache.write("user:#{id}:topic_read:#{topic.id}", last_reply_id)
+ end
+ end
+end
diff --git a/app/models/user/user_callbacks.rb b/app/models/user/user_callbacks.rb
new file mode 100644
index 000000000..5197571f1
--- /dev/null
+++ b/app/models/user/user_callbacks.rb
@@ -0,0 +1,32 @@
+class User
+ module UserCallbacks
+ extend ActiveSupport::Concern
+
+ included do
+ after_commit :send_welcome_mail, on: :create
+ before_save :store_location
+ end
+
+ def send_welcome_mail
+ UserMailer.welcome(id).deliver_later
+ end
+
+ # Store user location
+ def store_location
+ if self.location_changed?
+ if location.blank?
+ self.location_id = nil
+ else
+ old_location = Location.location_find_by_name(self.location_was)
+ old_location&.decrement!(:users_count)
+
+ location = Location.location_find_or_create_by_name(self.location)
+ unless location.new_record?
+ location.increment!(:users_count)
+ self.location_id = location.id
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/user_session.rb b/app/models/user_session.rb
deleted file mode 100644
index a6174627b..000000000
--- a/app/models/user_session.rb
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding: utf-8
-class UserSession < Authlogic::Session::Base
-end
diff --git a/app/models/user_sso.rb b/app/models/user_sso.rb
new file mode 100644
index 000000000..be3b2d419
--- /dev/null
+++ b/app/models/user_sso.rb
@@ -0,0 +1,3 @@
+class UserSSO < ActiveRecord::Base
+ belongs_to :user
+end
diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb
new file mode 100644
index 000000000..7fe456d43
--- /dev/null
+++ b/app/uploaders/avatar_uploader.rb
@@ -0,0 +1,8 @@
+class AvatarUploader < BaseUploader
+ def filename
+ if super.present?
+ @name ||= SecureRandom.hex(3)
+ "avatar/#{model.id}/#{@name}.#{file.extension.downcase}"
+ end
+ end
+end
diff --git a/app/uploaders/base_uploader.rb b/app/uploaders/base_uploader.rb
new file mode 100644
index 000000000..4e91591c9
--- /dev/null
+++ b/app/uploaders/base_uploader.rb
@@ -0,0 +1,58 @@
+class BaseUploader < CarrierWave::Uploader::Base
+ # 在 UpYun 或其他平台配置图片缩略图
+ # http://docs.upyun.com/guide/#_12
+ # Avatar
+ # 固定宽度和高度
+ # xs - 32x32
+ # sm - 48x48
+ # md - 96x96
+ # lg - 192x192
+ #
+ # Photo
+ # large - 1920x? - 限定宽度,高度自适应
+ ALLOW_VERSIONS = %w(xs sm md lg large)
+
+ def store_dir
+ dir = model.class.to_s.underscore
+ if Setting.upload_provider == "file"
+ dir = "uploads/#{dir}"
+ end
+ dir
+ end
+
+ def extension_white_list
+ %w(jpg jpeg gif png svg)
+ end
+
+ def url(version_name = nil)
+ @url ||= super({})
+ return @url if version_name.blank?
+ version_name = version_name.to_s
+ unless version_name.in?(ALLOW_VERSIONS)
+ raise "ImageUploader version_name:#{version_name} not allow."
+ end
+
+ case Setting.upload_provider
+ when "aliyun"
+ super(thumb: "?x-oss-process=image/#{aliyun_thumb_key(version_name)}")
+ when "upyun"
+ [@url, version_name].join("!")
+ else
+ [@url, version_name].join("!")
+ end
+ end
+
+ private
+
+ def aliyun_thumb_key(version_name)
+ case version_name
+ when "large" then "resize,w_1920"
+ when "lg" then "resize,w_192,h_192,m_fill"
+ when "md" then "resize,w_96,h_96,m_fill"
+ when "sm" then "resize,w_48,h_48,m_fill"
+ when "xs" then "resize,w_32,h_32,m_fill"
+ else
+ "resize,w_32,h_32,m_fill"
+ end
+ end
+end
diff --git a/app/uploaders/photo_uploader.rb b/app/uploaders/photo_uploader.rb
new file mode 100644
index 000000000..5ba5e0e46
--- /dev/null
+++ b/app/uploaders/photo_uploader.rb
@@ -0,0 +1,9 @@
+class PhotoUploader < BaseUploader
+ # Override the filename of the uploaded files:
+ def filename
+ if super.present?
+ @name ||= SecureRandom.uuid
+ "#{Time.now.year}/#{@name}.#{file.extension.downcase}"
+ end
+ end
+end
diff --git a/app/views/account/create.js.erb b/app/views/account/create.js.erb
new file mode 100644
index 000000000..bbaf53e2d
--- /dev/null
+++ b/app/views/account/create.js.erb
@@ -0,0 +1,8 @@
+<% if resource.errors.empty? %>
+ <% flash[:notice] = t('devise.sessions.signed_in') %>
+ Turbolinks.visit('<%= topics_url %>');
+<% else %>
+ $('#new_user .alert').remove();
+ $('#new_user').prepend('<%= j render("shared/error_messages", target: resource) %>');
+ $('.rucaptcha-image-box').html('<%= j rucaptcha_image_tag %>');
+<% end %>
diff --git a/app/views/account/new.html.erb b/app/views/account/new.html.erb
new file mode 100644
index 000000000..77193a29d
--- /dev/null
+++ b/app/views/account/new.html.erb
@@ -0,0 +1,44 @@
+<% title_tag t("users.register_user") %>
+
+
+
+
+
<%= t("users.register_user")%>
+
+ <%= simple_form_for(resource, as: resource_name, url: registration_path(resource_name), remote: true, html: { class: ""}) do |f| %>
+
+ <%= f.text_field :login, type: :email, class: "form-control input-lg", placeholder: t("users.login"), hint: t("users.suggest_using_twitter_id") %>
+
+
+ <%= f.text_field :name, class: "form-control input-lg", placeholder: t("activerecord.attributes.user.name") %>
+
+
+ <%= f.text_field :email, type: :email, class: "form-control input-lg", placeholder: t("activerecord.attributes.user.email") %>
+
+
+
+ <%= f.password_field :password, class: "form-control input-lg", placeholder: t("activerecord.attributes.user.password") %>
+
+
+ <%= f.password_field :password_confirmation, class: "form-control input-lg", placeholder: t("activerecord.attributes.user.password_confirmation") %>
+
+
+
+
+ <%= f.submit t('users.submit_new_user'), class: "btn btn-lg btn-primary", 'data-disable-with' => t("common.submitting") %>
+ <%= link_to t("common.login"), new_session_path(resource_name), class: "btn btn-lg btn-default hide-ios" %>
+
+ <% end %>
+
+
+
+
diff --git a/app/views/admin/applications/edit.html.erb b/app/views/admin/applications/edit.html.erb
new file mode 100644
index 000000000..78d654624
--- /dev/null
+++ b/app/views/admin/applications/edit.html.erb
@@ -0,0 +1,18 @@
+<% content_for :sitemap do %>
+ OAuth 应用 编辑
+<% end %>
+
+<%= simple_form_for([:admin, @application], url: admin_application_path(@application)) do |f| %>
+ <%= render "/shared/error_messages", target: @application %>
+ <%= f.input :name %>
+ <%= f.input :redirect_uri %>
+ <%= f.input :level %>
+
+
+ <%= f.submit "保存", class: "btn btn-primary", 'data-disable-with' => "Saving..." %>
+ <%= link_to '取消', admin_applications_path, class: "btn" %>
+
+ <%= link_to "删除", admin_application_path(@application.id), class: 'btn btn-danger', method: :delete, 'data-confirm' => '确定要删除吗?' %>
+
+
+<% end %>
diff --git a/app/views/admin/applications/index.html.erb b/app/views/admin/applications/index.html.erb
new file mode 100644
index 000000000..553a7fd24
--- /dev/null
+++ b/app/views/admin/applications/index.html.erb
@@ -0,0 +1,46 @@
+<% content_for :sitemap do %>
+ OAuth 应用
+<% end %>
+
+
+
+
+
+
+ 编号
+ 名称
+ uid
+ Owner
+ Tokens
+ Level
+ 创建时间
+
+
+ <% @applications.each do |item| %>
+ ">
+ <%= item.id %>
+ <%= link_to item.name, admin_application_path(item.id) %>
+ <%= item.uid %>
+ <%= user_name_tag(item.owner) %>
+ <%= item.access_tokens.count %>
+ <%= item.level %>
+ <%= l(item.created_at, format: :long) %>
+
+ <%= link_to '修改', edit_admin_application_path(item) %>
+
+
+ <% end %>
+
+
+ <%= paginate @applications %>
+
diff --git a/app/views/admin/applications/show.html.erb b/app/views/admin/applications/show.html.erb
new file mode 100644
index 000000000..d80cecc99
--- /dev/null
+++ b/app/views/admin/applications/show.html.erb
@@ -0,0 +1,23 @@
+<% content_for :sitemap do %>
+ OAuth 应用 详情
+<% end %>
+
+
+
+ 名称: <%= @application.name %>
+
+ Uid: <%= @application.uid %>
+
+ Secret: <%= @application.secret %>
+
+ 创建人: <%= user_name_tag(@application.owner) %>
+
+ 用户量: <%= @application.access_tokens.count %>
+
+ Level: <%= @application.level %>
+
+
+
+ <%= link_to '修改', edit_admin_application_path(@application), class: "btn btn-sm btn-success" %>
+ <%= link_to '返回', admin_applications_path, class: "btn btn-sm btn-default" %>
+
\ No newline at end of file
diff --git a/app/views/admin/comments/_form.html.erb b/app/views/admin/comments/_form.html.erb
new file mode 100644
index 000000000..8ba0cfedf
--- /dev/null
+++ b/app/views/admin/comments/_form.html.erb
@@ -0,0 +1,10 @@
+<%= simple_form_for([:admin,@comment]) do |f| %>
+ <%= render "/shared/error_messages", target: @comment %>
+
+ <%= f.input :body, as: :text, input_html: { class: "form-control" } %>
+
+
+ <%= f.submit "Save", class: "btn btn-primary", 'data-disable-with' => "Saving..." %> or
+ <%= link_to 'Cancel', admin_comments_path, class: "btn" %>
+
+<% end %>
diff --git a/app/views/admin/comments/destroy.js.erb b/app/views/admin/comments/destroy.js.erb
new file mode 100644
index 000000000..7cafdb91f
--- /dev/null
+++ b/app/views/admin/comments/destroy.js.erb
@@ -0,0 +1,2 @@
+$("#comment<%= params[:id] %>").remove();
+App.notice("删除成功。")
\ No newline at end of file
diff --git a/app/views/admin/comments/edit.html.erb b/app/views/admin/comments/edit.html.erb
new file mode 100644
index 000000000..d4c42760c
--- /dev/null
+++ b/app/views/admin/comments/edit.html.erb
@@ -0,0 +1,5 @@
+<% content_for :sitemap do %>
+ <%= t("admin.menu.comments")%> <%= t("common.edit")%>
+<% end %>
+
+<%= render 'form' %>
diff --git a/app/views/admin/comments/index.html.erb b/app/views/admin/comments/index.html.erb
new file mode 100644
index 000000000..527f6599f
--- /dev/null
+++ b/app/views/admin/comments/index.html.erb
@@ -0,0 +1,25 @@
+<% content_for :sitemap do %>
+ <%= t('admin.menu.comments') %>
+<% end %>
+
+
+
+ 被评论对象
+ 评论人
+ 内容
+ At
+
+
+
+<% @comments.each do |item| %>
+
+<% end %>
+
+<%= paginate @comments %>
diff --git a/app/views/admin/home/index.html.erb b/app/views/admin/home/index.html.erb
new file mode 100644
index 000000000..6d304d6f9
--- /dev/null
+++ b/app/views/admin/home/index.html.erb
@@ -0,0 +1,104 @@
+<% content_for :sitemap do %>
+ <%= t("admin.overview") %>
+<% end %>
+
+
+
+
+
+
+
+
+
0
+
+ 用户总数
+ +0
+ +0
+
+
+
+
+
+
+
0
+
+ 话题总数
+ +0
+ +0
+
+
+
+
+
+
+
0
+
+ 回帖总数
+ +0
+ +0
+
+
+
+
+
+
+
+
0
+
+ 通知总数
+ +0
+ +0
+
+
+
+
+
+
+
+
+
+
0
+
+ 照片总数
+ +0
+ +0
+
+
+
+
+
+
+
+
diff --git a/app/views/admin/locations/_base.html.erb b/app/views/admin/locations/_base.html.erb
new file mode 100644
index 000000000..89ff4509d
--- /dev/null
+++ b/app/views/admin/locations/_base.html.erb
@@ -0,0 +1,3 @@
+<% content_for :sitemap do %>
+ <%= t('admin.menu.locations') %>
+<% end %>
diff --git a/app/views/admin/locations/_form.html.erb b/app/views/admin/locations/_form.html.erb
new file mode 100644
index 000000000..cce34ffc9
--- /dev/null
+++ b/app/views/admin/locations/_form.html.erb
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/app/views/admin/locations/edit.html.erb b/app/views/admin/locations/edit.html.erb
new file mode 100644
index 000000000..b268fd449
--- /dev/null
+++ b/app/views/admin/locations/edit.html.erb
@@ -0,0 +1,3 @@
+<%= render 'base' %>
+<%= render 'form' %>
+
diff --git a/app/views/admin/locations/index.html.erb b/app/views/admin/locations/index.html.erb
new file mode 100644
index 000000000..da115e6e5
--- /dev/null
+++ b/app/views/admin/locations/index.html.erb
@@ -0,0 +1,23 @@
+<%= render 'base' %>
+
+
+
+
+ #
+ 地名
+ 会员数
+
+
+ <% @locations.each do |item| %>
+ ">
+ <%= item.id %>
+ <%= item.name %>
+ <%= item.users_count %>
+
+ <%= link_to "", edit_admin_location_path(item.id), class: "fa fa-pencil" %>
+
+
+ <% end %>
+
+ <%= paginate @locations %>
+
diff --git a/app/views/admin/nodes/_form.html.erb b/app/views/admin/nodes/_form.html.erb
new file mode 100644
index 000000000..ac3d665bd
--- /dev/null
+++ b/app/views/admin/nodes/_form.html.erb
@@ -0,0 +1,14 @@
+<%= simple_form_for([:admin,@node]) do |f| %>
+ <%= render 'shared/error_messages', target: @node %>
+
+
+ <%= f.input :name %>
+ <%= f.input :section_id, collection: Section.all.collect { |s| [s.name,s.id] } %>
+ <%= f.input :sort %>
+ <%= f.input :summary, as: :text, input_html: { style: "width:500px; height:200px;"} %>
+
+ <%= f.submit t("common.save"), class: "btn btn-primary", 'data-disable-with' => t("common.saving") %>
+ <%= link_to 'Back', admin_nodes_path %>
+
+
+<% end %>
diff --git a/app/views/admin/nodes/edit.html.erb b/app/views/admin/nodes/edit.html.erb
new file mode 100644
index 000000000..84427bc4b
--- /dev/null
+++ b/app/views/admin/nodes/edit.html.erb
@@ -0,0 +1,8 @@
+<% content_for :sitemap do %>
+ <%= t("admin.menu.nodes")%> <%= t("common.edit")%>
+<% end %>
+修改节点
+
+<%= render 'form' %>
+
+
diff --git a/app/views/admin/nodes/index.html.erb b/app/views/admin/nodes/index.html.erb
new file mode 100644
index 000000000..d89924ca4
--- /dev/null
+++ b/app/views/admin/nodes/index.html.erb
@@ -0,0 +1,30 @@
+<% content_for :sitemap do %>
+ <%= t("admin.menu.nodes")%>
+<% end %>
+
+
+ <%= link_to '新建节点', new_admin_node_path, class: "btn btn-default" %>
+
+
+
+
+ #
+ Name
+ 分类
+ 排序
+ 帖子数量
+
+
+
+<% @nodes.each do |node| %>
+
+ <%= node.id %>
+ <%= link_to node.name, node_topics_path(node.id) %>
+ <%= node.section.name %>
+ <%= node.sort %>
+ <%= node.topics_count %>
+ <%= link_to '', edit_admin_node_path(node), class: "fa fa-pencil" %>
+ <%= link_to '', admin_node_path(node), 'data-confirm' => 'Are you sure?' , method: :delete, class: "fa fa-trash" %>
+
+<% end %>
+
diff --git a/app/views/admin/nodes/new.html.erb b/app/views/admin/nodes/new.html.erb
new file mode 100644
index 000000000..9a4352cc1
--- /dev/null
+++ b/app/views/admin/nodes/new.html.erb
@@ -0,0 +1,6 @@
+<% content_for :sitemap do %>
+ <%= t("admin.menu.nodes")%> <%= t("common.create")%>
+<% end %>
+新建节点
+
+<%= render 'form' %>
diff --git a/app/views/admin/photos/index.html.erb b/app/views/admin/photos/index.html.erb
new file mode 100644
index 000000000..320cd2f32
--- /dev/null
+++ b/app/views/admin/photos/index.html.erb
@@ -0,0 +1,25 @@
+<% content_for :sitemap do %>
+ <%= t('admin.menu.photos') %>
+<% end %>
+
+
+
+ #
+ 预览
+ 上传者
+
+
+
+<% @photos.each do |photo| %>
+
+ <%= photo.id %>
+ <%= link_to image_tag(photo.image.url(:md), style: "width: 150px"), photo.image.url, target: "_blank" if !photo[:image].blank? %>
+ <%= photo.user.login if !photo.user.blank? %>
+ <%= link_to '', admin_photo_path(photo), 'data-confirm' => 'Are you sure?', method: :delete, class: "fa fa-trash" %>
+
+<% end %>
+
+<%= paginate @photos %>
+
+
+<%= link_to 'New Photo', new_admin_photo_path %>
diff --git a/app/views/admin/photos/show.html.erb b/app/views/admin/photos/show.html.erb
new file mode 100644
index 000000000..d03c8bf01
--- /dev/null
+++ b/app/views/admin/photos/show.html.erb
@@ -0,0 +1,16 @@
+<% content_for :sitemap do %>
+ <%= t("admin.menu.photos")%> <%= t("admin.check_photo")%>
+<% end %>
+<%= notice %>
+
+
+ User:
+ <%= @photo.user.login %>
+
+
+ <%= image_tag(@photo.image.url) %>
+
+
+
+<%= link_to 'Edit', edit_admin_photo_path(@photo) %> |
+<%= link_to 'Back', admin_photos_path %>
diff --git a/app/views/admin/replies/_form.html.erb b/app/views/admin/replies/_form.html.erb
new file mode 100644
index 000000000..857e382d8
--- /dev/null
+++ b/app/views/admin/replies/_form.html.erb
@@ -0,0 +1,13 @@
+<%= simple_form_for([:admin,@reply]) do |f| %>
+ <%= render 'shared/error_messages', target: @reply %>
+
+
+ <%= f.input :body, as: :text, input_html: { class: "span8", rows: "10"} %>
+ <%= f.input :user_id %>
+
+ <%= f.submit t("common.save"), class: "btn btn-primary", 'data-disable-with' => t("common.saving") %>
+ or
+ <%= link_to 'Back', admin_replies_path %>
+
+
+<% end %>
diff --git a/app/views/admin/replies/destroy.js.erb b/app/views/admin/replies/destroy.js.erb
new file mode 100644
index 000000000..cef58be80
--- /dev/null
+++ b/app/views/admin/replies/destroy.js.erb
@@ -0,0 +1,2 @@
+$("#reply-<%= params[:id] %>").attr("class","deleted");
+App.notice("删除成功。")
\ No newline at end of file
diff --git a/app/views/admin/replies/edit.html.erb b/app/views/admin/replies/edit.html.erb
new file mode 100644
index 000000000..f02c0905b
--- /dev/null
+++ b/app/views/admin/replies/edit.html.erb
@@ -0,0 +1,5 @@
+<% content_for :sitemap do %>
+ <%= t('admin.menu.replies') %> <%= t("common.edit") %>
+<% end %>
+<%= t("common.edit") %><%= t('admin.menu.replies') %>
+<%= render 'form' %>
\ No newline at end of file
diff --git a/app/views/admin/replies/index.html.erb b/app/views/admin/replies/index.html.erb
new file mode 100644
index 000000000..afa4f6d95
--- /dev/null
+++ b/app/views/admin/replies/index.html.erb
@@ -0,0 +1,47 @@
+<% content_for :sitemap do %>
+ <%= t('admin.menu.replies') %>
+<% end %>
+
+
+
+
+
+
+ #
+ 回帖内容
+ 话题
+ 回复人
+ 时间
+
+
+
+<% @replies.each do |reply| %>
+
+ <%= reply.id %>
+ <%= truncate(reply.body, length: 50) %>
+ <% if reply.topic %>
+
+ <%= link_to(reply.topic_id, topic_path(reply.topic_id), title: reply.topic.title)%>
+
+ <% else %>
+ <%= reply.topic_id %>
+ <% end %>
+ <%= link_to(reply.user.login, edit_admin_user_path(reply.user_id), target: "_blank") if reply.user %>
+ <%= l reply.created_at, format: :short %>
+ <%= link_to '', edit_admin_reply_path(reply), class: "fa fa-pencil" %>
+ <%= link_to '', admin_reply_path(reply), 'data-confirm' => 'Are you sure?', method: :delete, remote: true, class: "fa fa-trash" %>
+
+<% end %>
+
+<%= paginate @replies %>
+
diff --git a/app/views/admin/sections/_form.html.erb b/app/views/admin/sections/_form.html.erb
new file mode 100644
index 000000000..f41959e24
--- /dev/null
+++ b/app/views/admin/sections/_form.html.erb
@@ -0,0 +1,8 @@
+<%= simple_form_for([:admin, @section]) do |f| %>
+ <%= render 'shared/error_messages', target: @section %>
+ <%= f.input :name %>
+ <%= f.input :sort %>
+
+ <%= f.submit t("common.save"), class: "btn btn-primary", 'data-disable-with' => t("common.saving") %>
+
+<% end %>
diff --git a/app/views/admin/sections/edit.html.erb b/app/views/admin/sections/edit.html.erb
new file mode 100644
index 000000000..3488989ec
--- /dev/null
+++ b/app/views/admin/sections/edit.html.erb
@@ -0,0 +1,8 @@
+<% content_for :sitemap do %>
+ <%= t("admin.menu.sections") %> <%= t("common.edit") %>
+<% end %>
+Editing section
+
+<%= render 'form' %>
+
+
diff --git a/app/views/admin/sections/index.html.erb b/app/views/admin/sections/index.html.erb
new file mode 100644
index 000000000..ce66fae06
--- /dev/null
+++ b/app/views/admin/sections/index.html.erb
@@ -0,0 +1,28 @@
+<% content_for :sitemap do %>
+ <%= t("admin.menu.sections") %>
+<% end %>
+
+
+ <%= link_to '新建分类', new_admin_section_path, class: "btn btn-default" %>
+
+
+
+
+ #
+ 名称
+ 排序
+
+
+
+<% @sections.each do |section| %>
+
+
+ <%= section.id %>
+ <%= section.name %>
+ <%= section.sort %>
+ <%= link_to '', edit_admin_section_path(section), class: "fa fa-pencil" %>
+ <%= link_to '', admin_section_path(section), 'data-confirm' => '确定要删除吗?', method: :delete, class: "fa fa-trash" %>
+
+<% end %>
+
+
diff --git a/app/views/admin/sections/new.html.erb b/app/views/admin/sections/new.html.erb
new file mode 100644
index 000000000..35d221265
--- /dev/null
+++ b/app/views/admin/sections/new.html.erb
@@ -0,0 +1,8 @@
+<% content_for :sitemap do %>
+ <%= t("admin.menu.sections") %> <%= t("common.create") %>
+<% end %>
+
+新建分类
+
+<%= render 'form' %>
+
diff --git a/app/views/admin/site_configs/_form.html.erb b/app/views/admin/site_configs/_form.html.erb
new file mode 100644
index 000000000..80fc7eb0c
--- /dev/null
+++ b/app/views/admin/site_configs/_form.html.erb
@@ -0,0 +1,11 @@
+<%= simple_form_for(@site_config, url: admin_site_config_path(@site_config.var), method: 'patch') do |f| %>
+
+
+<% end %>
diff --git a/app/views/admin/site_configs/edit.html.erb b/app/views/admin/site_configs/edit.html.erb
new file mode 100644
index 000000000..1db66a657
--- /dev/null
+++ b/app/views/admin/site_configs/edit.html.erb
@@ -0,0 +1,7 @@
+<% content_for :sitemap do %>
+ <%= t("admin.menu.site_configs")%>
+ <%= @site_config.var %>
+<% end %>
+
+<%= render 'form' %>
+
diff --git a/app/views/admin/site_configs/index.html.erb b/app/views/admin/site_configs/index.html.erb
new file mode 100644
index 000000000..e338997d6
--- /dev/null
+++ b/app/views/admin/site_configs/index.html.erb
@@ -0,0 +1,21 @@
+<% content_for :sitemap do %>
+ <%= t("admin.site_configs.settings") %>
+<% end %>
+
+<%= t("admin.site_configs.settings") %>
+
+
+
+ Key
+ 说明
+
+
+
+<% Setting::KEYS_IN_ADMIN.each do |key| %>
+
+ <%= key %>
+ <%= t("setting.#{key}") %>
+ <%= link_to icon_tag("pencil"), edit_admin_site_config_path(key) %>
+
+<% end %>
+
diff --git a/app/views/admin/topics/_form.html.erb b/app/views/admin/topics/_form.html.erb
new file mode 100644
index 000000000..01a3e425c
--- /dev/null
+++ b/app/views/admin/topics/_form.html.erb
@@ -0,0 +1,16 @@
+<%= simple_form_for([:admin,@topic]) do |f| %>
+ <%= render 'shared/error_messages', target: @topic %>
+
+
+
+ <%= f.input :title, input_html: { class: "span10" } %>
+ <%= f.input :node_id %>
+ <%= f.input :user_id %>
+ <%= f.input :body, as: :text, input_html: { class: "span8", rows: "10"} %>
+
+ <%= f.submit t("common.save"), class: "btn btn-primary", 'data-disable-with' => t("common.saving") %>
+ or
+ <%= link_to 'Back', admin_topics_path %>
+
+
+<% end %>
diff --git a/app/views/admin/topics/edit.html.erb b/app/views/admin/topics/edit.html.erb
new file mode 100644
index 000000000..bb2460f68
--- /dev/null
+++ b/app/views/admin/topics/edit.html.erb
@@ -0,0 +1,8 @@
+<% content_for :sitemap do %>
+ <%= t("admin.menu.topics")%> <%= t("common.edit")%>
+<% end %>
+<%= t("common.edit")%><%= t("admin.menu.topics") %>
+
+<%= render 'form' %>
+
+
diff --git a/app/views/admin/topics/index.html.erb b/app/views/admin/topics/index.html.erb
new file mode 100644
index 000000000..c88f5814e
--- /dev/null
+++ b/app/views/admin/topics/index.html.erb
@@ -0,0 +1,62 @@
+<% content_for :sitemap do %>
+ <%= t("admin.menu.topics")%>
+<% end %>
+
+
+
+
+
+ #
+ 标题
+ 节点
+ 发帖人
+ 回帖
+ 时间
+
+
+
+<% @topics.each do |topic| %>
+
+ <%= topic.id %>
+
+ <%= link_to truncate(topic.title, length: 30), topic, target: "_blank" %>
+
+ <%= topic.node_name %>
+ <%= user_name_tag(topic.user) %>
+ <%= topic.replies_count %>
+
+ <% if topic.deleted_at.blank? %>
+ <%= l topic.created_at, format: :short %>
+ <% else %>
+ <%= link_to topic.who_deleted, user_path(topic.who_deleted) if not topic.who_deleted.blank? %> 删除于
+ <%= l topic.deleted_at, format: :short %>
+ <% end %>
+
+
+ <% if !topic.suggested_at.blank? %>
+ <%= link_to t("common.un_top"), unsuggest_admin_topic_path(topic), 'data-confirm' => 'Are you sure?', method: :post %>
+ <% else %>
+ <%= link_to t("common.place_top"), suggest_admin_topic_path(topic), 'data-confirm' => 'Are you sure?', method: :post %>
+ <% end %>
+ <%= link_to "", edit_admin_topic_path(topic), class: "fa fa-pencil" %>
+ <% if topic.deleted_at.blank? %>
+ <%= link_to "", admin_topic_path(topic), 'data-confirm' => 'Are you sure?', method: :delete, class: "fa fa-trash" %>
+ <% else %>
+ <%= link_to "", undestroy_admin_topic_path(topic), title: t("common.undelete"), 'data-confirm' => 'Are you sure?', method: :post, class: "fa fa-undo" %>
+ <% end %>
+
+
+
+<% end %>
+
+<%= paginate @topics %>
diff --git a/app/views/admin/topics/new.html.erb b/app/views/admin/topics/new.html.erb
new file mode 100644
index 000000000..553587c5c
--- /dev/null
+++ b/app/views/admin/topics/new.html.erb
@@ -0,0 +1,6 @@
+<% content_for :sitemap do %>
+ <%= t("admin.menu.topics")%> <%= t("common.create")%>
+<% end %>
+New topic
+
+<%= render 'form' %>
diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb
new file mode 100644
index 000000000..b82501a85
--- /dev/null
+++ b/app/views/admin/users/_form.html.erb
@@ -0,0 +1,63 @@
+<%= simple_form_for([:admin, @user], url: admin_user_path(@user)) do |f| %>
+ <%= render 'shared/error_messages', target: @user %>
+ <%= f.input :login %>
+ <%= f.input :name %>
+ <%= f.input :email, as: :email, input_html: { class: "xxlarge" } %>
+ <% if Setting.has_profile_field? :location %>
+ <%= f.input :location %>
+ <% end %>
+ <% if Setting.has_module? :github %>
+ <%= f.input :github %>
+ <% end %>
+ <% if Setting.has_profile_field? :twitter %>
+ <%= f.input :twitter %>
+ <% end %>
+ <%= f.input :email_public, as: :boolean %>
+ <% if Setting.has_profile_field? :company %>
+ <%= f.input :company %>
+ <% end %>
+ <% if Setting.has_profile_field? :tagline %>
+ <%= f.input :tagline, input_html: { class: "span8" } %>
+ <% end %>
+ <%= f.input :bio, as: :text, input_html: { class: "span8", style: "height:60px;" } %>
+ <% if Setting.has_profile_field? :website %>
+ <%= f.input :website, as: :url, input_html: { class: "xxlarge" } %>
+ <% end %>
+ <%= f.input :avatar %>
+
+ <%= user_avatar_tag(@user, :lg) %>
+
+ <% if @user.user_type == :user %>
+ <%= f.input :verified, as: :boolean, hint: t("admin.users.trust_user_can_modify_wiki") %>
+ <%= f.input :state, as: :select, collection: User.states.collect { |s| [s[0], s[0]] } %>
+ <%= f.input :sign_in_count, readonly: true %>
+ <%= f.input :last_sign_in_at, as: :string, readonly: true %>
+ <%= f.input :current_sign_in_at, as: :string, readonly: true %>
+ <%= f.input :last_sign_in_ip, readonly: true %>
+ <%= f.input :current_sign_in_ip, readonly: true %>
+ <% end %>
+ <% if not @user.authorizations.blank? %>
+
+ <% end %>
+
+
+ <%= f.submit t("common.save"), class: "btn btn-lg btn-block btn-primary", 'data-disable-with' => t("common.saving") %>
+
+
+ <% if !@user.deleted? %>
+
+ <%= link_to '删除此用户', admin_user_path(@user.id), 'data-confirm' => '警告!此动作无法撤销,确定要删除么?', method: :delete, class: "btn btn-danger" %>
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb
new file mode 100644
index 000000000..0b3d399cd
--- /dev/null
+++ b/app/views/admin/users/edit.html.erb
@@ -0,0 +1,15 @@
+<% content_for :sitemap do %>
+ <%= t("admin.menu.users")%> <%= t("common.edit")%>
+<% end %>
+
+<% if @user.user_type == :user %>
+
+
+ <%= link_to '删除他的最近 10 条回帖', clean_admin_user_path(@user, type: 'replies'),
+ method: 'delete', class: 'btn btn-warning',
+ data: { confirm: '注意!!!此动作是直接删除,无法恢复的,你确定要删除他的所有回帖么?' } %>
+
+
+<% end %>
+
+<%= render 'form' %>
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb
new file mode 100644
index 000000000..851e133b3
--- /dev/null
+++ b/app/views/admin/users/index.html.erb
@@ -0,0 +1,39 @@
+<% content_for :sitemap do %>
+ 用户
+<% end %>
+
+
+
+
+ #
+ 帐号
+ 姓名
+ Email
+ 注册时间
+ 操作
+
+
+<% @users.each do |user| %>
+
+ <%= user.id %>
+ <%= user_name_tag(user) %>
+ <%= user.name %>
+ <%= user.email %>
+ <%= user.created_at.to_date %>
+
+ <%= link_to '', edit_admin_user_path(user.id), class: "fa fa-pencil" %>
+
+
+<% end %>
+
+<%= paginate @users %>
diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb
new file mode 100644
index 000000000..4c013bd89
--- /dev/null
+++ b/app/views/admin/users/new.html.erb
@@ -0,0 +1,6 @@
+<% content_for :sitemap do %>
+ <%= t("admin.menu.users")%> <%= t("common.create")%>
+<% end %>
+New user
+
+<%= render 'form' %>
diff --git a/app/views/api/v3/application/_abilities.json.jbuilder b/app/views/api/v3/application/_abilities.json.jbuilder
new file mode 100644
index 000000000..2a940314f
--- /dev/null
+++ b/app/views/api/v3/application/_abilities.json.jbuilder
@@ -0,0 +1,19 @@
+# @class BaseSerializer
+# @!method abilities
+# 当前 accessToken 对应的用户对此数据的权限
+#
+# @example 表示可修改,不可删除
+#
+# { update: true, destroy: false }
+#
+# @return update [Boolean] 当前 accessToken 是否有修改权限
+# @return destroy [Boolean] 当前 accessToken 是否有删除权限
+json.abilities do
+ json.update can?(:update, object)
+ json.destroy can?(:destroy, object)
+ if object && object.is_a?(Topic)
+ %i(ban excellent unexcellent close open).each do |action|
+ json.set! action, can?(action, object)
+ end
+ end
+end
diff --git a/app/views/api/v3/application/_node.json.jbuilder b/app/views/api/v3/application/_node.json.jbuilder
new file mode 100644
index 000000000..bfeece6e5
--- /dev/null
+++ b/app/views/api/v3/application/_node.json.jbuilder
@@ -0,0 +1,17 @@
+# @class NodeSerializer
+# 节点
+#
+# == attributes
+# - *id* [Integer] 编号
+# - *name* [String] 节点名称
+# - *summary* [String] 简介, Markdown 格式
+# - *section_id* [Integer] 大类别编号
+# - *section_name* [String] 大类别名称
+# - *topics_count* [Integer] 话题数量
+# - *sort* {Integer} 排序优先级
+# - *updated_at* [DateTime] 更新时间
+if node
+ json.cache! ["v1", node] do
+ json.(node, :id, :name, :topics_count, :summary, :section_id, :sort, :section_name, :updated_at)
+ end
+end
diff --git a/app/views/api/v3/application/_notification.json.jbuilder b/app/views/api/v3/application/_notification.json.jbuilder
new file mode 100644
index 000000000..1436cb01a
--- /dev/null
+++ b/app/views/api/v3/application/_notification.json.jbuilder
@@ -0,0 +1,59 @@
+# 通知
+# @class NotificationSerializer
+#
+# == attributes
+# - *id* [Integer] 编号
+# - *type* [String] 通知类型
+# - *read* [Boolean] 是否已读
+# - *actor* {UserSerializer} 动作发起者
+# - *mention_type* [String] 提及的数据类型 Topic, Reply
+# - *created_at* [DateTime] 创建时间
+# - *updated_at* [DateTime] 更新时间
+json.cache! ["v2", notification] do
+ json.(notification, :id, :created_at, :updated_at)
+ json.type notification.notify_type.classify
+ json.read notification.read?
+ json.actor do
+ json.partial! "user", user: notification.actor
+ end
+
+ if notification.notify_type != "mention"
+ json.mention_type nil
+ json.mention nil
+ else
+ json.mention_type notification.target_type
+ json.mention do
+ if notification.target_type == "Topic"
+ json.partial! "topic", topic: notification.target
+ elsif notification.target_type == "Reply"
+ json.partial! "reply", reply: notification.target
+ end
+ end
+ end
+
+ json.topic do
+ if notification.notify_type == "topic" || notification.notify_type == "node_changed"
+ json.partial! "topic", topic: notification.try(:target)
+ elsif notification.notify_type == "topic_reply"
+ json.partial! "topic", topic: notification.try(:second_target)
+ elsif notification.notify_type == "mention"
+ if notification.target_type == "Topic"
+ json.partial! "topic", topic: notification.try(:target)
+ elsif notification.target_type == "Reply"
+ json.partial! "topic", topic: notification.try(:second_target)
+ end
+ end
+ end
+
+ if notification.notify_type == "topic_reply"
+ json.reply do
+ json.partial! "reply", reply: notification.try(:target)
+ end
+ end
+
+ if notification.notify_type == "node_changed" && notification.try(:second_target)
+ json.node do
+ json.partial! "node", node: notification.try(:second_target)
+ end
+ end
+end
diff --git a/app/views/api/v3/application/_reply.json.jbuilder b/app/views/api/v3/application/_reply.json.jbuilder
new file mode 100644
index 000000000..fd33554a9
--- /dev/null
+++ b/app/views/api/v3/application/_reply.json.jbuilder
@@ -0,0 +1,47 @@
+# 话题信息
+# @class ReplySerializer
+#
+# == attributes
+# - *id* [Integer] 编号
+# - *topic_id* [Integer] 话题编号
+# - *deleted* [Boolean] 是否已删除
+# - *likes_count* [Integer] 赞数量
+# - *user* {UserSerializer} 最后回复者用户对象
+# - *created_at* [DateTime] 创建时间
+# - *updated_at* [DateTime] 更新时间
+
+# 包含原始信息的回帖
+# @class ReplyDetailSerializer
+#
+# == attributes
+# {include:ReplySerializer}
+# - *topic_title* [String] 话题标题
+# - *body* [String] 回帖正文,原始 Markdown
+if reply
+ json.cache! ["v1.2", reply, defined?(detail)] do
+ json.(reply, :id, :body_html, :body, :topic_id, :created_at, :updated_at,
+ :likes_count, :action, :target_type)
+ json.deleted reply.deleted_at.present?
+ json.user do
+ json.partial! "user", user: reply.user
+ end
+
+ if defined?(detail)
+ json.(reply, :body)
+ json.topic_title reply.topic.try(:title)
+ end
+ end
+
+ # Mention Target
+ if reply.action == "mention"
+ json.mention_topic do
+ if reply.target_type == "Topic"
+ json.partial! "topic", topic: reply.target
+ else
+ json.partial! "topic", topic: reply&.target&.topic
+ end
+ end
+ end
+
+ json.partial! "abilities", object: reply
+end
diff --git a/app/views/api/v3/application/_topic.json.jbuilder b/app/views/api/v3/application/_topic.json.jbuilder
new file mode 100644
index 000000000..9bc86ed14
--- /dev/null
+++ b/app/views/api/v3/application/_topic.json.jbuilder
@@ -0,0 +1,44 @@
+# 话题信息
+# @class TopicSerializer
+#
+# == attributes
+# - *id* [Integer] 话题编号
+# - *title* [String] 标题
+# - *node_name* [String] 节点名称
+# - *node_id* [Integer] 节点 ID
+# - *excellent* [Boolean] 是否精华
+# - *deleted* [Boolean] 是否已删除
+# - *replies_count* [Integer] 回帖数量
+# - *likes_count* [Integer] 赞数量
+# - *last_reply_user_id* [Integer] 最后回复人用户编号
+# - *last_reply_user_login* [String] 最后回复者 login
+# - *user* {UserSerializer} 最后回复者用户对象
+# - *closed_at* [DateTime] 结帖时间,null 表示正常帖子
+# - *replied_at* [DateTime] 最后回帖时间
+# - *created_at* [DateTime] 创建时间
+# - *updated_at* [DateTime] 更新时间
+
+# @class TopicDetailSerializer
+# 完整话题详情
+# {include:TopicSerializer}
+# - *body* [String] 话题正文,原始 Markdown
+# - *body_html* [String] 以转换成 HTML 的正文
+# - *hits* [Integer] 阅读次数
+
+if topic
+ json.cache! ["v1.1", topic, defined?(detail)] do
+ json.(topic, :id, :title, :created_at, :updated_at, :replied_at, :replies_count,
+ :node_name, :node_id, :last_reply_user_id, :last_reply_user_login,
+ :excellent, :likes_count, :suggested_at, :closed_at)
+ json.deleted topic.deleted_at.present?
+ json.user do
+ json.partial! "user", user: topic.user
+ end
+
+ if defined?(detail)
+ json.(topic, :body, :body_html)
+ end
+ end
+ json.hits topic.hits.to_i
+ json.partial! "abilities", object: topic
+end
diff --git a/app/views/api/v3/application/_user.json.jbuilder b/app/views/api/v3/application/_user.json.jbuilder
new file mode 100644
index 000000000..725d46651
--- /dev/null
+++ b/app/views/api/v3/application/_user.json.jbuilder
@@ -0,0 +1,51 @@
+# 用户 API 返回数据结构
+# @class UserSerializer
+#
+# == attributes
+# - *id* [Integer] 用户编号
+# - *login* [String] 用户名
+# - *name* [String] 用户姓名
+# - *avatar_url* [String] 头像 URL
+
+# 用户详细信息 API 返回数据结构
+# @class UserDetailSerializer
+#
+# == attributes
+# {include:UserSerializer}
+# - *location* [String] 城市
+# - *company* [String] 公司名称
+# - *github* [String] GitHub ID
+# - *twitter* [String] Twitter ID
+# - *website* [String] 个人主页 URL
+# - *bio* [String] 个人介绍
+# - *tagline* [String] 一段话的简单个人介绍
+# - *email* [String] Email 地址
+# - *topics_count* [Integer] 用户创建的话题数量
+# - *replies_count* [Integer] 用户创建的回帖数量
+# - *following_count* [Integer] 关注了多少人
+# - *followers_count* [Integer] 有多少个关注者
+# - *favorites_count* [Integer] 收藏的话题数量
+# - *level* [String] 用户级别
+# - *level_name* [String] 用户级别(用于显示)
+# - *created_at* [DateTime] 注册时间 iso8601 格式
+if user
+ json.cache! ["v1.1", user, defined?(detail)] do
+ json.(user, :id, :login, :name)
+ json.avatar_url user.avatar? ? user.avatar.url(:large) : user.letter_avatar_url(240)
+
+ if defined?(detail)
+ json.(user, :location, :company, :twitter, :website,
+ :tagline, :github, :created_at,
+ :topics_count, :replies_count,
+ :following_count, :followers_count, :favorites_count,
+ :level, :level_name)
+ json.bio markdown(user.bio)
+ if owner?(user) || user.email_public
+ json.email user.email
+ else
+ json.email ""
+ end
+ end
+ json.partial! "abilities", object: user
+ end
+end
diff --git a/app/views/api/v3/nodes/index.json.jbuilder b/app/views/api/v3/nodes/index.json.jbuilder
new file mode 100644
index 000000000..7dbc2cc01
--- /dev/null
+++ b/app/views/api/v3/nodes/index.json.jbuilder
@@ -0,0 +1 @@
+json.nodes @nodes, partial: "node", as: :node
diff --git a/app/views/api/v3/nodes/show.json.jbuilder b/app/views/api/v3/nodes/show.json.jbuilder
new file mode 100644
index 000000000..7c7ed1653
--- /dev/null
+++ b/app/views/api/v3/nodes/show.json.jbuilder
@@ -0,0 +1,4 @@
+json.node do
+ json.partial! partial: "node", locals: { node: @node }
+end
+json.meta @meta
diff --git a/app/views/api/v3/notifications/index.json.jbuilder b/app/views/api/v3/notifications/index.json.jbuilder
new file mode 100644
index 000000000..507799912
--- /dev/null
+++ b/app/views/api/v3/notifications/index.json.jbuilder
@@ -0,0 +1 @@
+json.notifications @notifications, partial: "notification", as: :notification
diff --git a/app/views/api/v3/photos/create.json.jbuilder b/app/views/api/v3/photos/create.json.jbuilder
new file mode 100644
index 000000000..6fd1d8547
--- /dev/null
+++ b/app/views/api/v3/photos/create.json.jbuilder
@@ -0,0 +1 @@
+json.image_url @photo.image.url(:large)
diff --git a/app/views/api/v3/replies/show.json.jbuilder b/app/views/api/v3/replies/show.json.jbuilder
new file mode 100644
index 000000000..bcf14cb5f
--- /dev/null
+++ b/app/views/api/v3/replies/show.json.jbuilder
@@ -0,0 +1,4 @@
+json.reply do
+ json.partial! partial: "reply", locals: { reply: @reply, detail: true }
+end
+json.meta @meta
diff --git a/app/views/api/v3/topics/index.json.jbuilder b/app/views/api/v3/topics/index.json.jbuilder
new file mode 100644
index 000000000..95ba2fcb1
--- /dev/null
+++ b/app/views/api/v3/topics/index.json.jbuilder
@@ -0,0 +1 @@
+json.topics @topics, partial: "topic", as: :topic
diff --git a/app/views/api/v3/topics/replies.json.jbuilder b/app/views/api/v3/topics/replies.json.jbuilder
new file mode 100644
index 000000000..54585370f
--- /dev/null
+++ b/app/views/api/v3/topics/replies.json.jbuilder
@@ -0,0 +1,2 @@
+json.replies @replies, partial: "reply", as: :reply
+json.meta @meta
diff --git a/app/views/api/v3/topics/show.json.jbuilder b/app/views/api/v3/topics/show.json.jbuilder
new file mode 100644
index 000000000..4b895a769
--- /dev/null
+++ b/app/views/api/v3/topics/show.json.jbuilder
@@ -0,0 +1,4 @@
+json.topic do
+ json.partial! partial: "topic", locals: { topic: @topic, detail: true }
+end
+json.meta @meta
diff --git a/app/views/api/v3/users/blocked.json.jbuilder b/app/views/api/v3/users/blocked.json.jbuilder
new file mode 100644
index 000000000..0b08c75a1
--- /dev/null
+++ b/app/views/api/v3/users/blocked.json.jbuilder
@@ -0,0 +1 @@
+json.blocked @users, partial: "user", as: :user
diff --git a/app/views/api/v3/users/followers.json.jbuilder b/app/views/api/v3/users/followers.json.jbuilder
new file mode 100644
index 000000000..89595835d
--- /dev/null
+++ b/app/views/api/v3/users/followers.json.jbuilder
@@ -0,0 +1 @@
+json.followers @users, partial: "user", as: :user
diff --git a/app/views/api/v3/users/following.json.jbuilder b/app/views/api/v3/users/following.json.jbuilder
new file mode 100644
index 000000000..162996371
--- /dev/null
+++ b/app/views/api/v3/users/following.json.jbuilder
@@ -0,0 +1 @@
+json.following @users, partial: "user", as: :user
diff --git a/app/views/api/v3/users/index.json.jbuilder b/app/views/api/v3/users/index.json.jbuilder
new file mode 100644
index 000000000..51e5e151d
--- /dev/null
+++ b/app/views/api/v3/users/index.json.jbuilder
@@ -0,0 +1 @@
+json.users @users, partial: "user", as: :user
diff --git a/app/views/api/v3/users/replies.json.jbuilder b/app/views/api/v3/users/replies.json.jbuilder
new file mode 100644
index 000000000..3381a4037
--- /dev/null
+++ b/app/views/api/v3/users/replies.json.jbuilder
@@ -0,0 +1,3 @@
+json.replies @replies do |reply|
+ json.partial! partial: "reply", locals: { reply: reply, detail: true }
+end
diff --git a/app/views/api/v3/users/show.json.jbuilder b/app/views/api/v3/users/show.json.jbuilder
new file mode 100644
index 000000000..6df5f4b6c
--- /dev/null
+++ b/app/views/api/v3/users/show.json.jbuilder
@@ -0,0 +1,4 @@
+json.user do
+ json.partial! partial: "user", locals: { user: @user, detail: true }
+end
+json.meta @meta
diff --git a/app/views/api/v3/users/topics.json.jbuilder b/app/views/api/v3/users/topics.json.jbuilder
new file mode 100644
index 000000000..95ba2fcb1
--- /dev/null
+++ b/app/views/api/v3/users/topics.json.jbuilder
@@ -0,0 +1 @@
+json.topics @topics, partial: "topic", as: :topic
diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb
new file mode 100644
index 000000000..cbf2c5afe
--- /dev/null
+++ b/app/views/comments/_comment.html.erb
@@ -0,0 +1,11 @@
+
diff --git a/app/views/comments/create.js.erb b/app/views/comments/create.js.erb
new file mode 100644
index 000000000..95286458e
--- /dev/null
+++ b/app/views/comments/create.js.erb
@@ -0,0 +1,13 @@
+App.loading(false);
+comments_box = $("#<%= @comment.commentable_type %>_<%= @comment.commentable_id %>_cell_comments .panel-body");
+comments_form = $("#<%= @comment.commentable_type %>_<%= @comment.commentable_id %>_cell_new_comment");
+<% if @success %>
+comment_line = '<%= j(render("comment", item: @comment)) %>';
+comments_box.append(comment_line);
+$(comment_line).fadeOut("fast");
+comments_box.find('.no-result').remove();
+$("abbr.timeago").timeago();
+$("textarea",comments_form).val("").focus();
+<% else %>
+$("textarea",comments_form).focus();
+<% end %>
diff --git a/app/views/cpanel/home/index.html.erb b/app/views/cpanel/home/index.html.erb
deleted file mode 100644
index c7507e0e7..000000000
--- a/app/views/cpanel/home/index.html.erb
+++ /dev/null
@@ -1,25 +0,0 @@
-<% content_for :sitemap do %>
- 概览
-<% end %>
-
-<% if !@recent_topics.blank? %>
-
-<% end %>
-
-
-
统计信息
-
- 用户: <%= User.cached_count %> 人
- 贴子: <%= Topic.cached_count %> 个
- 回复: <%= Reply.cached_count %> 条
- 记事本: <%= Note.cached_count %> 条
-
-
-
diff --git a/app/views/cpanel/nodes/_form.html.erb b/app/views/cpanel/nodes/_form.html.erb
deleted file mode 100644
index ad3e84ca2..000000000
--- a/app/views/cpanel/nodes/_form.html.erb
+++ /dev/null
@@ -1,33 +0,0 @@
-<%= form_for(@node, :url => (@node.id.blank? ? cpanel_nodes_path : cpanel_node_path(@node.id)) ) do |f| %>
- <% if @node.errors.any? %>
-
-
<%= pluralize(@node.errors.count, "error") %> prohibited this node from being saved:
-
-
- <% @node.errors.full_messages.each do |msg| %>
- <%= msg %>
- <% end %>
-
-
- <% end %>
-
-
- <%= f.label :name %>
- <%= f.text_field :name %>
-
-
- <%= f.label :section_id %>
- <%= f.select :section_id, Section.all.collect { |s| [s.name,s.id] } %>
-
-
- <%= f.label :sort %>
- <%= f.text_field :sort %>
-
-
- <%= f.label :summary %>
- <%= f.text_area :summary, :class => "middle",:style => "height:80px;" %>
-
-
- 保存
-
-<% end %>
diff --git a/app/views/cpanel/nodes/edit.html.erb b/app/views/cpanel/nodes/edit.html.erb
deleted file mode 100644
index 8138d507d..000000000
--- a/app/views/cpanel/nodes/edit.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<% content_for :sitemap do %>
- 节点 > 修改
-<% end %>
-Editing node
-
-<%= render 'form' %>
-
-<%= link_to 'Back', cpanel_nodes_path %>
diff --git a/app/views/cpanel/nodes/index.html.erb b/app/views/cpanel/nodes/index.html.erb
deleted file mode 100644
index 733660878..000000000
--- a/app/views/cpanel/nodes/index.html.erb
+++ /dev/null
@@ -1,29 +0,0 @@
-<% content_for :sitemap do %>
- 节点
-<% end %>
-Listing nodes
-
-
-
- Name
- Section
- Sort
- Topics count
-
-
-
-<% @nodes.each do |node| %>
-
- <%= node.name %>
- <%= node.section.name %>
- <%= node.sort %>
- <%= node.topics_count %>
- <%= link_to 'Edit', edit_cpanel_node_path(node) %>
- <%= link_to 'Destroy', cpanel_node_path(node), :confirm => 'Are you sure?', :method => :delete %>
-
-<% end %>
-
-
-
-
-<%= link_to 'New Node', new_cpanel_node_path %>
diff --git a/app/views/cpanel/nodes/new.html.erb b/app/views/cpanel/nodes/new.html.erb
deleted file mode 100644
index 5e3be26f6..000000000
--- a/app/views/cpanel/nodes/new.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<% content_for :sitemap do %>
- 节点 > 新建
-<% end %>
-New node
-
-<%= render 'form' %>
-
-<%= link_to 'Back', cpanel_nodes_path %>
diff --git a/app/views/cpanel/photos/_form.html.erb b/app/views/cpanel/photos/_form.html.erb
deleted file mode 100644
index ffa131555..000000000
--- a/app/views/cpanel/photos/_form.html.erb
+++ /dev/null
@@ -1,25 +0,0 @@
-<%= form_for(@photo, :url => (@photo.id.blank? ? cpanel_photos_path : cpanel_photo_path(@photo.id)) , :html => { :enctype => "multipart/form-data" }) do |f| %>
- <% if @photo.errors.any? %>
-
-
<%= pluralize(@photo.errors.count, "error") %> prohibited this photo from being saved:
-
-
- <% @photo.errors.full_messages.each do |msg| %>
- <%= msg %>
- <% end %>
-
-
- <% end %>
-
-
- <%= f.label :title %>
- <%= f.text_field :title %>
-
-
- <%= f.label :image %>
- <%= f.file_field :image %>
-
-
- 上传
-
-<% end %>
diff --git a/app/views/cpanel/photos/edit.html.erb b/app/views/cpanel/photos/edit.html.erb
deleted file mode 100644
index acff2bc96..000000000
--- a/app/views/cpanel/photos/edit.html.erb
+++ /dev/null
@@ -1,6 +0,0 @@
-Editing photo
-
-<%= render 'form' %>
-
-<%= link_to 'Show', @photo %> |
-<%= link_to 'Back', photos_path %>
diff --git a/app/views/cpanel/photos/index.html.erb b/app/views/cpanel/photos/index.html.erb
deleted file mode 100644
index 6f24ef8b0..000000000
--- a/app/views/cpanel/photos/index.html.erb
+++ /dev/null
@@ -1,28 +0,0 @@
-Listing photos
-
-
-
- Title
- Image file name
- Image file size
- User
-
-
-
-
-
-<% @photos.each do |photo| %>
-
- <%= photo.title %>
- <%= image_tag(photo.image(:small)) %>
- <%= photo.image_file_size %>
- <%= photo.user.name %>
- <%= link_to 'Show', cpanel_photo_path(photo) %>
- <%= link_to 'Destroy', cpanel_photo_path(photo), :confirm => 'Are you sure?', :method => :delete %>
-
-<% end %>
-
-<%= will_paginate @photos %>
-
-
-<%= link_to 'New Photo', new_cpanel_photo_path %>
diff --git a/app/views/cpanel/photos/new.html.erb b/app/views/cpanel/photos/new.html.erb
deleted file mode 100644
index 003299314..000000000
--- a/app/views/cpanel/photos/new.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<% content_for :sitemap do %>
- 图片 > 上传
-<% end %>
-New photo
-
-<%= render 'form' %>
-
-<%= link_to 'Back', photos_path %>
diff --git a/app/views/cpanel/photos/show.html.erb b/app/views/cpanel/photos/show.html.erb
deleted file mode 100644
index 126dc9365..000000000
--- a/app/views/cpanel/photos/show.html.erb
+++ /dev/null
@@ -1,24 +0,0 @@
-<% content_for :sitemap do %>
- 图片 > 查看图片
-<% end %>
-<%= notice %>
-
-
- Title:
- <%= @photo.title %>
-
-
- User:
- <%= @photo.user.name %>
-
-
- Image file size:
- <%= @photo.image_file_size %>
-
-
- <%= image_tag(@photo.image(:normal)) %>
-
-
-
-<%= link_to 'Edit', edit_photo_path(@photo) %> |
-<%= link_to 'Back', photos_path %>
diff --git a/app/views/cpanel/replies/_form.html.erb b/app/views/cpanel/replies/_form.html.erb
deleted file mode 100644
index 4aafcb993..000000000
--- a/app/views/cpanel/replies/_form.html.erb
+++ /dev/null
@@ -1,36 +0,0 @@
-<%= form_for(@reply, :url => (@reply.id.blank? ? cpanel_replies_path : cpanel_reply_path(@reply.id))) do |f| %>
- <% if @reply.errors.any? %>
-
-
<%= pluralize(@reply.errors.count, "error") %> prohibited this reply from being saved:
-
-
- <% @reply.errors.full_messages.each do |msg| %>
- <%= msg %>
- <% end %>
-
-
- <% end %>
-
-
- <%= @topic.title %>
-
-
- <%= f.label :body %>
- <%= f.text_area :body, :class => "long", :style => "height:400px" %>
-
-
- <%= f.label :user_id %>
- <%= f.text_field :user_id %> <%= @topic.user.name %>
-
-
- <%= f.label :state %>
- <%= f.text_field :state, Reply::STATE %>
-
-
- <%= f.label :source %>
- <%= f.text_field :source %>
-
-
- 保存
-
-<% end %>
diff --git a/app/views/cpanel/replies/edit.html.erb b/app/views/cpanel/replies/edit.html.erb
deleted file mode 100644
index b2dc4b968..000000000
--- a/app/views/cpanel/replies/edit.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<% content_for :sitemap do %>
- 回复 > 修改
-<% end %>
-Editing reply
-
-<%= render 'form' %>
-
-<%= link_to 'Back', cpanel_replies_path %>
diff --git a/app/views/cpanel/replies/index.html.erb b/app/views/cpanel/replies/index.html.erb
deleted file mode 100644
index 30139edf6..000000000
--- a/app/views/cpanel/replies/index.html.erb
+++ /dev/null
@@ -1,28 +0,0 @@
-<% content_for :sitemap do %>
- 回复
-<% end %>
-Listing replies
-
-
-
- Body
- Topic
- User
- At
-
-
-
-
-<% @replies.each do |reply| %>
-
- <%= truncate(reply.body, :length => 50) %>
- <%= reply.topic_id %>
- <%= reply.user.name %>
- <%= l reply.created_at, :format => :short %>
- <%= link_to 'Edit', edit_cpanel_reply_path(reply) %>
- <%= link_to 'Destroy', cpanel_reply_path(reply), :confirm => 'Are you sure?', :method => :delete %>
-
-<% end %>
-
-<%= will_paginate @replies %>
-
diff --git a/app/views/cpanel/sections/_form.html.erb b/app/views/cpanel/sections/_form.html.erb
deleted file mode 100644
index 531bdbfaf..000000000
--- a/app/views/cpanel/sections/_form.html.erb
+++ /dev/null
@@ -1,25 +0,0 @@
-<%= form_for(@section, :url => (@section.id.blank? ? cpanel_sections_path : cpanel_section_path(@section.id)) ) do |f| %>
- <% if @section.errors.any? %>
-
-
<%= pluralize(@section.errors.count, "error") %> prohibited this section from being saved:
-
-
- <% @section.errors.full_messages.each do |msg| %>
- <%= msg %>
- <% end %>
-
-
- <% end %>
-
-
- <%= f.label :name %>
- <%= f.text_field :name %>
-
-
- <%= f.label :sort %>
- <%= f.text_field :sort %>
-
-
- 保存
-
-<% end %>
diff --git a/app/views/cpanel/sections/edit.html.erb b/app/views/cpanel/sections/edit.html.erb
deleted file mode 100644
index 857edecba..000000000
--- a/app/views/cpanel/sections/edit.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<% content_for :sitemap do %>
- 分类 > 修改
-<% end %>
-Editing section
-
-<%= render 'form' %>
-
-<%= link_to 'Back', cpanel_sections_path %>
diff --git a/app/views/cpanel/sections/index.html.erb b/app/views/cpanel/sections/index.html.erb
deleted file mode 100644
index d5275674c..000000000
--- a/app/views/cpanel/sections/index.html.erb
+++ /dev/null
@@ -1,25 +0,0 @@
-<% content_for :sitemap do %>
- 分类
-<% end %>
-Listing sections
-
-
-
- Name
- Sort
-
-
-
-<% @sections.each do |section| %>
-
- <%= section.name %>
- <%= section.sort %>
- <%= link_to 'Edit', edit_cpanel_section_path(section) %>
- <%= link_to 'Destroy', cpanel_section_path(section), :confirm => 'Are you sure?', :method => :delete %>
-
-<% end %>
-
-
-
-
-<%= link_to 'New Section', new_cpanel_section_path %>
diff --git a/app/views/cpanel/sections/new.html.erb b/app/views/cpanel/sections/new.html.erb
deleted file mode 100644
index 90f29e4e0..000000000
--- a/app/views/cpanel/sections/new.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<% content_for :sitemap do %>
- 分类 > 新建
-<% end %>
-New section
-
-<%= render 'form' %>
-
-<%= link_to 'Back', cpanel_sections_path %>
diff --git a/app/views/cpanel/topics/_form.html.erb b/app/views/cpanel/topics/_form.html.erb
deleted file mode 100644
index 555eece42..000000000
--- a/app/views/cpanel/topics/_form.html.erb
+++ /dev/null
@@ -1,40 +0,0 @@
-<%= form_for(@topic, :url => (@topic.id.blank? ? cpanel_topics_path : cpanel_topic_path(@topic.id))) do |f| %>
- <% if @topic.errors.any? %>
-
-
<%= pluralize(@topic.errors.count, "error") %> prohibited this topic from being saved:
-
- <% @topic.errors.full_messages.each do |msg| %>
- <%= msg %>
- <% end %>
-
-
- <% end %>
-
-
- <%= f.label :title %>
- <%= f.text_field :title, :class => "middle" %>
-
-
- <%= f.label :node_id %>
- <%= f.select :node_id, Node.all.collect { |n| [n.name,n.id] } %>
-
-
- <%= f.label :body %>
- <%= f.text_area :body, :class => "long", :style => "height:400px" %>
-
-
- <%= f.label :user_id %>
- <%= f.text_field :user_id %> <%= @topic.user.name if @topic.user %>
-
-
- <%= f.label :last_reply_user_id %>
- <%= f.text_field :last_reply_user_id %>
-
-
- <%= f.label :source %>
- <%= f.text_field :source %>
-
-
- 保存
-
-<% end %>
diff --git a/app/views/cpanel/topics/edit.html.erb b/app/views/cpanel/topics/edit.html.erb
deleted file mode 100644
index f11da9bc7..000000000
--- a/app/views/cpanel/topics/edit.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<% content_for :sitemap do %>
- 帖子 > 修改
-<% end %>
-Editing topic
-
-<%= render 'form' %>
-
-<%= link_to 'Back', cpanel_topics_path %>
diff --git a/app/views/cpanel/topics/index.html.erb b/app/views/cpanel/topics/index.html.erb
deleted file mode 100644
index caafa44bb..000000000
--- a/app/views/cpanel/topics/index.html.erb
+++ /dev/null
@@ -1,33 +0,0 @@
-<% content_for :sitemap do %>
- 帖子
-<% end %>
-Listing topics
-
-
-
- Title
- Node
- User
- Replies
- Reply user
- at
-
-
-
-<% @topics.each do |topic| %>
-
- <%= truncate(topic.title,:length => 30) %>
- <%= topic.node.name if topic.node %>
- <%= topic.user.name if topic.user %>
- <%= topic.replies_count %>
- <%= topic.last_reply_user_id %>
- <%= l topic.created_at,:format => :short %>
- <%= link_to 'Edit', edit_cpanel_topic_path(topic) %>
- <%= link_to 'Destroy', cpanel_topic_path(topic), :confirm => 'Are you sure?', :method => :delete %>
-
-<% end %>
-
-<%= will_paginate @topics %>
-
-
-<%= link_to 'New Topic', new_cpanel_topic_path %>
diff --git a/app/views/cpanel/topics/new.html.erb b/app/views/cpanel/topics/new.html.erb
deleted file mode 100644
index 083ef5375..000000000
--- a/app/views/cpanel/topics/new.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<% content_for :sitemap do %>
- 帖子 > 新建
-<% end %>
-New topic
-
-<%= render 'form' %>
-
-<%= link_to 'Back', cpanel_topics_path %>
diff --git a/app/views/cpanel/users/_form.html.erb b/app/views/cpanel/users/_form.html.erb
deleted file mode 100644
index 7c1fac38e..000000000
--- a/app/views/cpanel/users/_form.html.erb
+++ /dev/null
@@ -1,75 +0,0 @@
-<%= form_for(@user, :url => (@user.id.blank? ? cpanel_users_path : cpanel_user_path(@user.id)) , :html => { :enctype => "multipart/form-data" }) do |f| %>
- <% if @user.errors.any? %>
-
-
<%= pluralize(@user.errors.count, "error") %> prohibited this user from being saved:
-
-
- <% @user.errors.full_messages.each do |msg| %>
- <%= msg %>
- <% end %>
-
-
- <% end %>
-
-
- <%= f.label :email %>
- <%= f.text_field :email, :class => "middle" %>
-
-
- <%= f.label :password %>
- <%= f.password_field :password %>
-
-
- <%= f.label :password_confirmation %>
- <%= f.password_field :password_confirmation %>
-
-
- <%= f.label :name %>
- <%= f.text_field :name %>
-
-
- <%= f.label :location %>
- <%= f.text_field :location %>
-
-
- <%= f.label :qq %>
- <%= f.text_field :qq %>
-
-
- <%= f.label :tagline %>
- <%= f.text_field :tagline, :class => "middle" %>
-
-
- <%= f.label :bio %>
- <%= f.text_area :bio, :class => "middle", :style => "height:60px;" %>
-
-
- <%= f.label :website %>
- <%= f.text_field :website %>
-
-
- <%= f.label :avatar %>
- <%= f.file_field :avatar %>
-
- <% if !@user.avatar_file_name.blank? %>
-
-
预览
-
-
- <% end %>
-
- <%= f.label :verified %>
- <%= f.check_box :verified, :class => "checkbox" %>
-
-
- <%= f.label :state %>
- <%= f.select :state, User::STATE.collect { |s| [s[0],s[1]] } %>
-
-
- <%= f.label :last_login_at %>
- <%= f.text_field :last_login_at, :readonly => true %>
-
-
- 保存
-
-<% end %>
diff --git a/app/views/cpanel/users/edit.html.erb b/app/views/cpanel/users/edit.html.erb
deleted file mode 100644
index c0a99da0e..000000000
--- a/app/views/cpanel/users/edit.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<% content_for :sitemap do %>
- 用户 > 修改
-<% end %>
-Editing user
-
-<%= render 'form' %>
-
-<%= link_to 'Back', cpanel_users_path %>
diff --git a/app/views/cpanel/users/index.html.erb b/app/views/cpanel/users/index.html.erb
deleted file mode 100644
index ab8940467..000000000
--- a/app/views/cpanel/users/index.html.erb
+++ /dev/null
@@ -1,33 +0,0 @@
-<% content_for :sitemap do %>
- 用户
-<% end %>
-Listing users
-
-
-
- Email
- Name
- Location
- State
- QQ
- Last logined at
-
-
-
-<% @users.each do |user| %>
-
- <%= user.email %>
- <%= user.name %>
- <%= user.location %>
- <%= user.state %>
- <%= user.qq %>
- <%= user.last_login_at %>
- <%= link_to 'Edit', edit_cpanel_user_path(user) %>
- <%= link_to 'Destroy', cpanel_user_path(user), :confirm => 'Are you sure?', :method => :delete %>
-
-<% end %>
-
-<%= will_paginate @users %>
-
-
-<%= link_to 'New User', new_cpanel_user_path %>
diff --git a/app/views/cpanel/users/new.html.erb b/app/views/cpanel/users/new.html.erb
deleted file mode 100644
index 1fa21ea2d..000000000
--- a/app/views/cpanel/users/new.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<% content_for :sitemap do %>
- 用户 > 新建
-<% end %>
-New user
-
-<%= render 'form' %>
-
-<%= link_to 'Back', cpanel_users_path %>
diff --git a/app/views/devise/confirmations/new.html.erb b/app/views/devise/confirmations/new.html.erb
new file mode 100644
index 000000000..9f6bcf6d3
--- /dev/null
+++ b/app/views/devise/confirmations/new.html.erb
@@ -0,0 +1,18 @@
+<% title_tag 'Resend confirmation instructions' %>
+
+Resend confirmation instructions
+
+<%= simple_form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %>
+ <%= f.error_notification %>
+ <%= f.full_error :confirmation_token %>
+
+
+ <%= f.input :email, required: true, autofocus: true %>
+
+
+
+ <%= f.button :submit, "Resend confirmation instructions" %>
+
+<% end %>
+
+<%= render "devise/shared/links" %>
diff --git a/app/views/devise/mailer/confirmation_instructions.html.erb b/app/views/devise/mailer/confirmation_instructions.html.erb
new file mode 100644
index 000000000..2bc61744f
--- /dev/null
+++ b/app/views/devise/mailer/confirmation_instructions.html.erb
@@ -0,0 +1,6 @@
+欢迎 <%= @resource.fullname %>
+你已经成功注册 <%= link_to Setting.app_name, root_url %> 的账号,
+ 接下来你需要点击下面的连接 激活 你的账号:
+<%= link_to '激活账号', confirmation_url(@resource, confirmation_token: @token), class: 'btn' %>
+如果看不到链接,请复制下面的连接然后在浏览器里面打开:
+<%= confirmation_url(@resource, confirmation_token: @token) %>
diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb
new file mode 100644
index 000000000..6cb872d73
--- /dev/null
+++ b/app/views/devise/mailer/reset_password_instructions.html.erb
@@ -0,0 +1,6 @@
+你好 <%= @resource.fullname %>
+有人请求找回在 <%= link_to Setting.app_name, root_url %> 上面,账号为 <%= @resource.login %> 的密码,如果你是,那么你可以通过下面的连接进入网站修改密码。
+<%= link_to "修改我的密码", edit_password_url(@resource, reset_password_token: @token), class: 'btn' %>
+如果看不到连接,请复制下面的连接然后在浏览器里面打开:
+<%= edit_password_url(@resource, reset_password_token: @token) %>
+如果你没有申请,可以忽略这封邮件,你的账号不会受到影响。
diff --git a/app/views/devise/mailer/unlock_instructions.html.erb b/app/views/devise/mailer/unlock_instructions.html.erb
new file mode 100644
index 000000000..e90d48ab5
--- /dev/null
+++ b/app/views/devise/mailer/unlock_instructions.html.erb
@@ -0,0 +1,7 @@
+你好 <%= @resource.fullname %>
+
+由于多次错误密码尝试登录,你的帐号已经暂时被锁定了,锁定时间为 1 小时,锁定期间此帐号将无法登录,也无法尝试密码。
+
+你也可以点击下面的链接立即解锁:
+
+<%= link_to '解锁我的帐号', unlock_url(@resource, unlock_token: @token), class: 'btn' %>
diff --git a/app/views/devise/menu/_login_items.html.erb b/app/views/devise/menu/_login_items.html.erb
new file mode 100644
index 000000000..7c13761c7
--- /dev/null
+++ b/app/views/devise/menu/_login_items.html.erb
@@ -0,0 +1,5 @@
+<% if user_signed_in? %>
+ <%= link_to(t("common.logout"), destroy_user_session_path) %>
+<% else %>
+ <%= link_to(t("common.login"), new_user_session_path) %>
+<% end %>
diff --git a/app/views/devise/menu/_registration_items.html.erb b/app/views/devise/menu/_registration_items.html.erb
new file mode 100644
index 000000000..27025c0ba
--- /dev/null
+++ b/app/views/devise/menu/_registration_items.html.erb
@@ -0,0 +1,9 @@
+<% if user_signed_in? %>
+你好 <%= current_user.login %> ,
+<% if not params[:controller].match(/admin/) %>
+ <%= link_to('设置', setting_path) %> |
+ <% if admin? current_user %>后台 | <% end %>
+<% end %>
+<% else %>
+ <%= link_to('加入社区', new_user_registration_path) %>
+<% end %>
diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb
new file mode 100644
index 000000000..b7b997541
--- /dev/null
+++ b/app/views/devise/passwords/edit.html.erb
@@ -0,0 +1,20 @@
+<% title_tag t('users.update_password') %>
+
+
+
+
+
<%= t('users.update_password') %>
+
+ <%= simple_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %>
+ <%= render "shared/error_messages", target: resource %>
+ <%= f.hidden_field :reset_password_token %>
+ <%= f.input :password %>
+ <%= f.input :password_confirmation %>
+
+ <%= f.submit t('users.update_password'), class: "btn btn-success", 'data-disable-with' => t("common.submitting") %>
+
+ <% end %>
+
+
+
+
diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb
new file mode 100644
index 000000000..0f2abaaa1
--- /dev/null
+++ b/app/views/devise/passwords/new.html.erb
@@ -0,0 +1,28 @@
+<% title_tag t('users.forget_password') %>
+
+
+
+
+
<%= t('users.forget_password') %>
+
+ <%= simple_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %>
+ <%= render "shared/error_messages", target: resource %>
+
+
+
+
+ <%= f.submit "找回密码", class: "btn btn-lg btn-primary", 'data-disable-with' => t("common.submitting") %>
+
+ <% end %>
+
+
+
+
diff --git a/app/views/devise/shared/_links.erb b/app/views/devise/shared/_links.erb
new file mode 100644
index 000000000..78379c333
--- /dev/null
+++ b/app/views/devise/shared/_links.erb
@@ -0,0 +1,21 @@
+
+ <%- if controller_name != 'sessions' %>
+ <%= link_to t("common.login"), new_session_path(resource_name) %>
+ <% end -%>
+
+ <%- if devise_mapping.registerable? && controller_name != 'registrations' %>
+ <%= link_to t("common.register"), new_registration_path(resource_name) %>
+ <% end -%>
+
+ <%- if devise_mapping.recoverable? && controller_name != 'passwords' %>
+ <%= link_to t("users.forget_password"), new_password_path(resource_name) %>
+ <% end -%>
+
+ <%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
+ <%= link_to t("users.not_recieve_confirm_mail"), new_confirmation_path(resource_name) %>
+ <% end -%>
+
+ <%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
+ <%= link_to t("users.not_recieve_unlock_mail"), new_unlock_path(resource_name) %>
+ <% end -%>
+
\ No newline at end of file
diff --git a/app/views/devise/unlocks/new.html.erb b/app/views/devise/unlocks/new.html.erb
new file mode 100644
index 000000000..5dae18c2b
--- /dev/null
+++ b/app/views/devise/unlocks/new.html.erb
@@ -0,0 +1,20 @@
+<% title_tag '重新发送解锁邮件' %>
+
+
+
+
+
重新发送解锁邮件
+
+ <%= simple_form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %>
+ <%= devise_error_messages! %>
+
+ <%= f.input :email, hint: '' %>
+
+
+ <%= f.submit "重新发送解锁邮件", class: 'btn btn-primary' %>
+
+ <% end %>
+
+
+
+
\ No newline at end of file
diff --git a/app/views/doorkeeper/applications/_form.html.erb b/app/views/doorkeeper/applications/_form.html.erb
new file mode 100644
index 000000000..be0bd0fad
--- /dev/null
+++ b/app/views/doorkeeper/applications/_form.html.erb
@@ -0,0 +1,37 @@
+
+
+ <% if application.new_record? %>注册新应用<% else %>修改应用信息<% end %>
+
+
+ <%= form_with(model: application, url: doorkeeper_submit_path(application), html: { role: 'form'}) do |f| %>
+ <% if application.errors.any? %>
+
<%= t('doorkeeper.applications.form.error') %>
+ <% end %>
+
+ <%= content_tag :div, class: "form-group#{' has-error' if application.errors[:name].present?}" do %>
+ <%= f.label :name, class: 'control-label' %>
+ <%= f.text_field :name, class: 'form-control' %>
+ <%= doorkeeper_errors_for application, :name %>
+ <% end %>
+
+ <%= content_tag :div, class: "form-group#{' has-error' if application.errors[:redirect_uri].present?}" do %>
+ <%= f.label :redirect_uri, class: 'control-label' %>
+ <%= f.text_area :redirect_uri, class: 'form-control' %>
+ <%= doorkeeper_errors_for application, :redirect_uri %>
+
+ <%= t('doorkeeper.applications.help.redirect_uri') %>
+
+ <% if Doorkeeper.configuration.native_redirect_uri %>
+
+ <%= raw t('doorkeeper.applications.help.native_redirect_uri', native_redirect_uri: "#{ Doorkeeper.configuration.native_redirect_uri }
") %>
+
+ <% end %>
+ <% end %>
+
+
+ <%= f.submit "提交", class: "btn btn-primary" %>
+ <%= link_to "取消", oauth_applications_path, :class => "btn btn-default" %>
+
+ <% end %>
+
+
diff --git a/app/views/doorkeeper/applications/_menu.html.erb b/app/views/doorkeeper/applications/_menu.html.erb
new file mode 100644
index 000000000..0675eb96f
--- /dev/null
+++ b/app/views/doorkeeper/applications/_menu.html.erb
@@ -0,0 +1,3 @@
+
+ <%= link_to "应用列表", oauth_applications_path %>
+
\ No newline at end of file
diff --git a/app/views/doorkeeper/applications/edit.html.erb b/app/views/doorkeeper/applications/edit.html.erb
new file mode 100644
index 000000000..60f6b70b7
--- /dev/null
+++ b/app/views/doorkeeper/applications/edit.html.erb
@@ -0,0 +1,3 @@
+<%= render "menu" %>
+
+<%= render 'form', application: @application %>
diff --git a/app/views/doorkeeper/applications/index.html.erb b/app/views/doorkeeper/applications/index.html.erb
new file mode 100644
index 000000000..784142c4d
--- /dev/null
+++ b/app/views/doorkeeper/applications/index.html.erb
@@ -0,0 +1,101 @@
+
+ <%= render "/settings/menu" %>
+
+
+
+ 管理的应用列表
+ <%= link_to "注册新应用", new_oauth_application_path, class: 'btn btn-success pull-right' %>
+
+
+
+
+
+
+ <%= t('.name') %>
+ Client Id
+ Level
+ 用户量
+
+
+
+
+ <% @applications.each do |app| %>
+
+ <%= link_to app.name, oauth_application_path(app) %>
+ <%= app.uid %>
+ <%= app.level %>
+ <%= app.access_tokens.count %>
+
+ <%= link_to icon_tag('pencil'), edit_oauth_application_path(app)%>
+ <%= link_to icon_tag('trash'), oauth_application_path(app), data: { method: :delete, confirm: "确定要删除么?" }%>
+
+
+ <% end %>
+
+
+
+
+
+
+
+ 已授权的应用
+
+
+
+
下面列表是已经认证的应用,它们可以访问你的帐号。
+
+
+
+ <%= t('doorkeeper.authorized_applications.index.application') %>
+ <%= t('doorkeeper.authorized_applications.index.created_at') %>
+
+
+
+
+ <% @authorized_applications.each do |app| %>
+
+ <%= app.name %>
+ <%= app.created_at.strftime(t('doorkeeper.authorized_applications.index.date_format')) %>
+
+ <%= link_to "注销", oauth_authorized_application_path(app), class: "btn btn-warning btn-xs", data: { confirm: "确定要注销么?", method: :delete } %>
+
+
+ <% end %>
+
+
+
+
+
+ <% if @devices.length > 0 %>
+
+
+ 我的设备信息
+
+
+
+
下面列表是已经连接上的设备,它们将会收到 Push 通知。
+
+
+
+ <%= t('activerecord.attributes.device.platform') %>
+ <%= t('activerecord.attributes.device.token') %>
+ <%= t('activerecord.attributes.device.alive') %>
+
+
+
+
+ <% @devices.each do |device| %>
+
+ <%= device.platform_name %>
+ <%= device.token %>
+ <%= device.alive? %>
+ <%= link_to '删除', device, class: 'btn btn-warning btn-xs', data: { confirm: '确定要删除么?', method: :delete } %>
+
+ <% end %>
+
+
+
+
+ <% end %>
+
+
diff --git a/app/views/doorkeeper/applications/new.html.erb b/app/views/doorkeeper/applications/new.html.erb
new file mode 100644
index 000000000..60f6b70b7
--- /dev/null
+++ b/app/views/doorkeeper/applications/new.html.erb
@@ -0,0 +1,3 @@
+<%= render "menu" %>
+
+<%= render 'form', application: @application %>
diff --git a/app/views/doorkeeper/applications/show.html.erb b/app/views/doorkeeper/applications/show.html.erb
new file mode 100644
index 000000000..f74708d29
--- /dev/null
+++ b/app/views/doorkeeper/applications/show.html.erb
@@ -0,0 +1,40 @@
+<%= render "menu" %>
+
+
+
<%= t('.title', name: @application.name) %>
+
+
+
+ <%= t('.application_id') %>
+ <%= @application.uid %>
+
+
+ <%= t('.secret') %>
+ <%= @application.secret %>
+
+
+ <%= t('.level') %>
+ <%= @application.level %>
+
+
+ <%= t('.callback_urls') %>
+
+
+ <% @application.redirect_uri.split.each do |uri| %>
+
+ <%= uri %>
+
+ <%= link_to t('doorkeeper.applications.buttons.authorize'), oauth_authorization_path(client_id: @application.uid, redirect_uri: uri, response_type: 'code'), class: 'btn btn-sm btn-success', target: '_blank' %>
+
+
+ <% end %>
+
+
+
+
+
+
+
diff --git a/app/views/doorkeeper/authorizations/error.html.erb b/app/views/doorkeeper/authorizations/error.html.erb
new file mode 100644
index 000000000..633e45d09
--- /dev/null
+++ b/app/views/doorkeeper/authorizations/error.html.erb
@@ -0,0 +1,8 @@
+
+
+ <%= t('doorkeeper.authorizations.error.title') %>
+
+
+
<%= @pre_auth.error_response.body[:error_description] %>
+
+
diff --git a/app/views/doorkeeper/authorizations/new.html.erb b/app/views/doorkeeper/authorizations/new.html.erb
new file mode 100644
index 000000000..069a91f0c
--- /dev/null
+++ b/app/views/doorkeeper/authorizations/new.html.erb
@@ -0,0 +1,46 @@
+
+
+
+
<%= t('.title') %>
+
+
+
+ 应用 <%= @pre_auth.client.name %> 希望你授权使用你的帐号
+
+
+ <% if @pre_auth.scopes %>
+
+
<%= t('.able_to') %>:
+
+
+ <% @pre_auth.scopes.each do |scope| %>
+ <%= t scope, scope: [:doorkeeper, :scopes] %>
+ <% end %>
+
+
+ <% end %>
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/views/doorkeeper/authorizations/show.html.erb b/app/views/doorkeeper/authorizations/show.html.erb
new file mode 100644
index 000000000..06daa28a2
--- /dev/null
+++ b/app/views/doorkeeper/authorizations/show.html.erb
@@ -0,0 +1,13 @@
+
+
+
+
授权成功
+
+
+
由于应用没有配置回调地址,请使用授权码:
+
<%= params[:code] %>
+
+
+
+
+
diff --git a/app/views/errors/403.html.erb b/app/views/errors/403.html.erb
new file mode 100644
index 000000000..a5516cbfc
--- /dev/null
+++ b/app/views/errors/403.html.erb
@@ -0,0 +1,10 @@
+<% content_for :sitemap do %>404 <% end %>
+
+
403 没有权限
+
+ 抱歉,你没有做此操作的权限,如有问题,请到社区发帖询问。
+
+
+ 返回首页
+
+
\ No newline at end of file
diff --git a/app/views/errors/404.html.erb b/app/views/errors/404.html.erb
index 2680f6419..f961075d7 100644
--- a/app/views/errors/404.html.erb
+++ b/app/views/errors/404.html.erb
@@ -1,10 +1,8 @@
-<% content_for :sitemap do %>404 <% end %>
-
-
404 错误,该页不存在
-
- 我们很抱歉的告诉你,你现在访问的页面我们无法找到,或许是因为这个页面已经被删除了.
-
-
- 给你再来的不便我们深表歉意.
-
-
\ No newline at end of file
+
+
+
Oops, Page not found
+
+ 似乎没有这个页面哦!大哥,回去看看别的吧。
+
+
+
diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb
index 04aaf52f7..ed1dc548f 100644
--- a/app/views/home/index.html.erb
+++ b/app/views/home/index.html.erb
@@ -1,55 +1,27 @@
-<%= content_for :sitemap do %>首页 <% end %>
-<%= content_for :styles do %>
-<%= stylesheet_link_tag "home", :cache => "cached_home" %>
-<% end %>
-
-
楠香山论坛
-
- 这里是成都中和镇楠香山小区的论坛。
-
-
为什么要建立这个论坛?
-
- 其实在搜房网上面也有那么一个楠香山业主论坛,但是那个论坛奇慢无比,我无法忍受,而且上面充斥着无数的垃圾信息,你没法确认那个是真实的。
- 而这里将会有所不同,我将针对实际的情况开发一个合适的论坛程序,她包括实名认证,手机支持,更好的浏览速度和用户体验。
-
-
为什么不用QQ群?
-
- QQ群作为随意讨论确实没有问题,很多人也习惯了,但是如果你想整个小区的人都可以看到的话,发到论坛里面才是最好的选择,因为这些信息将会永远存在,而QQ群将会很快消失!
- 此外这里以话题来引导导论,如,“旁边的超市什么时候可以建成?”这样的话题,大家可以在一起讨论。
-
-
我是谁?
-
- 可以叫我小李, 职业是做网站的,我也买了楠香山的房子(5-14-3号),所以就准备建立这么一个论坛。把小区的管理拿回到自己人手里面。
-
-
-<%= cache("home/last_topics",:expires_in => 30.minutes) do %>
-
-<% end %>
-<%= cache("home/hot_topics",:expires_in => 30.minutes) do %>
-
-
最近活跃的贴子
-
+<%= raw Setting.index_html %>
+
+
+
社区精华帖
+
+ <% cache(["home_suggest_topics", @excellent_topics]) do %>
+ <%
+ odd_topics, even_topics = @excellent_topics.partition.each_with_index { |t, i| i.odd? }
+ %>
+
+ <%= render partial: "topics/topic", collection: odd_topics, locals: { suggest: false } %>
+
+
+
+ <%= render partial: "topics/topic", collection: even_topics, locals: { suggest: false } %>
+
+ <% end %>
+
+
+
+<% if !mobile? %>
+ <%= render "/shared/index_sections" %>
+ <%= render "/shared/hot_locations" %>
<% end %>
-
diff --git a/app/views/home/login.html.erb b/app/views/home/login.html.erb
deleted file mode 100644
index b16f075b2..000000000
--- a/app/views/home/login.html.erb
+++ /dev/null
@@ -1,41 +0,0 @@
-<%= content_for :sitemap do %>
登录 <% end %>
-
-
-
登录<%= APP_CONFIG['app_name'] %>
-
-
-
-
-
diff --git a/app/views/home/markdown.html.erb b/app/views/home/markdown.html.erb
new file mode 100644
index 000000000..cc2ac2a29
--- /dev/null
+++ b/app/views/home/markdown.html.erb
@@ -0,0 +1,17 @@
+<% title_tag 'Markdown 教程' %>
+
+<% cache(['markdown-guide', Digest::MD5.hexdigest(Homeland::Markdown.example)]) do %>
+
+
+
Markdown 教程
+
+
+
<%= Homeland::Markdown.example %>
+
+
+ <%= markdown(Homeland::Markdown.example)%>
+
+
+
+
+<% end %>
diff --git a/app/views/kaminari/_gap.html.erb b/app/views/kaminari/_gap.html.erb
new file mode 100644
index 000000000..b24ce77fb
--- /dev/null
+++ b/app/views/kaminari/_gap.html.erb
@@ -0,0 +1,3 @@
+
+ <%= content_tag :a, raw(t('pagination.gap')) %>
+
diff --git a/app/views/kaminari/_next_page.html.erb b/app/views/kaminari/_next_page.html.erb
new file mode 100644
index 000000000..00b4d384d
--- /dev/null
+++ b/app/views/kaminari/_next_page.html.erb
@@ -0,0 +1,3 @@
+
+ <%= link_to_unless current_page.last?, raw(t('pagination.next')), url, rel: 'next', remote: remote %>
+
diff --git a/app/views/kaminari/_page.html.erb b/app/views/kaminari/_page.html.erb
new file mode 100644
index 000000000..8028b455e
--- /dev/null
+++ b/app/views/kaminari/_page.html.erb
@@ -0,0 +1,9 @@
+<% if page.current? %>
+
+ <%= content_tag :a, page, remote: remote, rel: (page.next? ? 'next' : (page.prev? ? 'prev' : nil)) %>
+
+<% else %>
+
+ <%= link_to page, url, remote: remote, rel: (page.next? ? 'next' : (page.prev? ? 'prev' : nil)) %>
+
+<% end %>
diff --git a/app/views/kaminari/_paginator.html.erb b/app/views/kaminari/_paginator.html.erb
new file mode 100644
index 000000000..3f499edd5
--- /dev/null
+++ b/app/views/kaminari/_paginator.html.erb
@@ -0,0 +1,13 @@
+<%= paginator.render do -%>
+
+<% end -%>
diff --git a/app/views/kaminari/_prev_page.html.erb b/app/views/kaminari/_prev_page.html.erb
new file mode 100644
index 000000000..51d1ef763
--- /dev/null
+++ b/app/views/kaminari/_prev_page.html.erb
@@ -0,0 +1,3 @@
+
+ <%= link_to_unless current_page.first?, raw(t('pagination.prev')), url, rel: 'prev', remote: remote %>
+
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
new file mode 100644
index 000000000..dbe5b13d4
--- /dev/null
+++ b/app/views/layouts/admin.html.erb
@@ -0,0 +1,63 @@
+
+
+
+
<%= Setting.app_name %> - 控制台
+ <%= stylesheet_link_tag "admin" %>
+ <%= javascript_include_tag "app" %>
+ <%= csrf_meta_tag %>
+
+
+
+
+
+
+
+
+
+
控制台
+ <%= yield :sitemap %>
+
+
+
+ <%= notice_message %>
+ <%= yield %>
+
+
+
+
+
+
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 5d8967c94..17fe1df53 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -1,64 +1,98 @@
+
- <%= raw @page_title %><%= APP_CONFIG['app_name'] %>
- <%= stylesheet_link_tag "application","front","jquery.jdialog", :cache => "cached_front" %>
- <%= yield :styles %>
-
-
-
+
+
+ <%= content_for?(:title) ? yield(:title) : Setting.app_name %>
+
+
+
+ <%= stylesheet_link_tag "front", 'data-turbolinks-track': "reload" %>
+ <%= stylesheet_link_tag "turbolinks-app", 'data-turbolinks-track': 'reload' if turbolinks_app? %>
+ <%= yield :stylesheets %>
+ <%= action_cable_meta_tag %>
<%= csrf_meta_tag %>
-
- <%= javascript_include_tag "rails","jquery.jdialog","application", :cache => "cached_application" %>
- <%= auto_discovery_link_tag(:rss,feed_topics_url,:title => '订阅最新贴') %>
- <%= yield :scripts %>
+ <%= raw Setting.custom_head_html %>
+ <% if current_user %>
+
+ <% end %>
+
+ <%= javascript_include_tag "app", 'data-turbolinks-track': "reload" %>
+ <%= yield :javascripts %>
-
-
-
-
-
-
- <%= APP_CONFIG['app_name'] %> Beta
-
-
- <% if @current_user %>
- 你好 <%= user_name_tag @current_user %> ,
- href="<%= notes_path %>">记事本 |
- <%= link_to "设置", setting_path, :class => params[:controller] == "user/edit" ? 'actived' : "" %> |
- <% if admin? @current_user %>
- 后台 |
- <% end %>
- 登出
- <% else %>
- 加入社区 | 登录
- <% end %>
-
-
-
-
-
-
-
- <%= yield %>
+
+
-
diff --git a/app/views/replies/_create_callback.js.erb b/app/views/replies/_create_callback.js.erb
new file mode 100644
index 000000000..760ef9672
--- /dev/null
+++ b/app/views/replies/_create_callback.js.erb
@@ -0,0 +1,14 @@
+if($("#replies").length == 0){
+ Turbolinks.visit(location.href);
+} else {
+ if ($(".reply[data-id=<%= reply.id %>]").length == 0) {
+ var current_floor = parseInt($("#replies").data("last-floor")) + 1;
+ var dom = $('<%= j(render("reply", reply: reply, reply_counter: reply.topic.replies_count - 1, display_edit: true)) %>');
+ $("#replies .items").append(dom);
+ $("#replies .total b").text('<%= reply.topic.replies_count %>');
+ $('#topic-sidebar .total b').text('<%= reply.topic.replies_count %>');
+ dom.addClass('light').find("a.edit").css("display", "inline-block");
+ _topicView.itemsUpdated();
+ }
+}
+
diff --git a/app/views/replies/_reply.html.erb b/app/views/replies/_reply.html.erb
new file mode 100644
index 000000000..7ed16377a
--- /dev/null
+++ b/app/views/replies/_reply.html.erb
@@ -0,0 +1,45 @@
+<% cache([reply, reply.user_avatar_raw, "raw:#{@show_raw}"]) do %>
+<%
+ floor = reply_counter + 1
+ show_deleted = reply.deleted? && !@show_raw
+ class_names = ['reply']
+ class_names << 'popular' if reply.popular?
+ class_names << 'reply-system' if reply.system_event?
+ class_names << 'reply-deleted' if show_deleted
+%>
+
+
+ <% if show_deleted %>
+
<%= floor %><%= t("common.floor")%> <%= t("common.has_deleted")%>
+ <% elsif reply.system_event? %>
+ <%= render '/replies/system_event', reply: reply %>
+ <% else %>
+
<%= user_avatar_tag(reply.user, :md) %>
+
+
+
+ <%= user_name_tag(reply.user) %>
+ ·
+
#<%= floor %> ·
+
<%= timeago(reply.created_at) %>
+
+ <% if !reply.deleted? %>
+
+ <%= link_to('', edit_topic_reply_path(@topic,reply), class: "edit fa fa-pencil", 'data-uid' => reply.user_id, title: "修改回帖")%>
+ <%= link_to('', "#", 'data-id' => reply.id, 'data-login' => reply.user_login,
+ title: t("topics.reply_this_floor"), class: "btn-reply fa fa-mail-reply" )
+ %>
+
+ <%= likeable_tag(reply, cache: true) %>
+ <% end %>
+
+
+ <%= render 'replies/reply_to', reply: reply, show_body: false %>
+
+ <%= reply.body_html %>
+
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/replies/_reply_to.html.erb b/app/views/replies/_reply_to.html.erb
new file mode 100644
index 000000000..de70292d0
--- /dev/null
+++ b/app/views/replies/_reply_to.html.erb
@@ -0,0 +1,25 @@
+<% if reply.reply_to && reply.reply_to&.user %>
+ <%
+ reply_to = reply.reply_to
+ user = reply_to.user
+ %>
+
+
+ 对
+ <% if show_body %>
+ <%= user_avatar_tag(user, :xs) %><%= user_name_tag(user) %>
+ <% else %>
+ <%= link_to reply_to_topic_reply_path(@topic, reply), remote: true do %>
+ <%= user_avatar_tag(user, :xs, link: false) %><%= user.login %>
+
+ <% end %>
+ <% end %>
+ 回复
+
+ <% if show_body %>
+
+ <%= markdown(reply_to.body) %>
+
+ <% end %>
+
+<% end %>
diff --git a/app/views/replies/_system_event.html.erb b/app/views/replies/_system_event.html.erb
new file mode 100644
index 000000000..c9678dd29
--- /dev/null
+++ b/app/views/replies/_system_event.html.erb
@@ -0,0 +1,3 @@
+<%= user_avatar_tag(reply.user, :xs) %> <%= user_name_tag(reply.user) %>
+<%= render partial: "/replies/system_events/#{reply.action}", locals: { reply: reply } %>
+
<%= l reply.created_at, format: :short %>
diff --git a/app/views/replies/create.js.erb b/app/views/replies/create.js.erb
new file mode 100644
index 000000000..5c4288baf
--- /dev/null
+++ b/app/views/replies/create.js.erb
@@ -0,0 +1,11 @@
+<% if !@reply.errors.blank? %>
+ _topicView.replyCallback(0, '<%= j(@msg) %>');
+<% else %>
+ <% if @reply.upvote? %>
+ Turbolinks.visit(location.href);
+ <% else %>
+ <%= render 'create_callback', reply: @reply %>
+
+ _topicView.replyCallback(1, '<%= j(@msg) %>');
+ <% end %>
+<% end %>
diff --git a/app/views/replies/edit.html.erb b/app/views/replies/edit.html.erb
new file mode 100644
index 000000000..8fbfe0ee7
--- /dev/null
+++ b/app/views/replies/edit.html.erb
@@ -0,0 +1,27 @@
+
+
+ 修改回帖 <%= topic_title_tag(@topic) %>
+
+
+ <%= simple_form_for([@topic, @reply], remote: true, html: { class: "", tb: 'edit-reply' }) do |f| %>
+ <%= render "shared/error_messages", target: @reply %>
+
+ <%= render "/shared/editor_toolbar" %>
+
+
+ <%= f.text_area :body, class: "topic-editor form-control", rows: 10 %>
+
+
+ <%= f.submit t("common.save"), class: "btn btn-primary col-xs-2 hide-ios", 'data-disable-with' => t("common.saving") %>
+ <% if can? :destroy, @reply %>
+ <%= link_to "删除",[@topic,@reply], class: "pull-right btn btn-danger", method: :delete, data: { confirm: "确定要删除此回复么?" } %>
+ <% end %>
+
+ <% end %>
+
+
+
+
+ <%= render "/shared/editor_help_block" %>
+
+
diff --git a/app/views/replies/index.js.erb b/app/views/replies/index.js.erb
new file mode 100644
index 000000000..0f79b051c
--- /dev/null
+++ b/app/views/replies/index.js.erb
@@ -0,0 +1,3 @@
+<% @replies.each do |reply| %>
+ <%= render 'create_callback', reply: reply %>
+<% end %>
diff --git a/app/views/replies/reply_to.js.erb b/app/views/replies/reply_to.js.erb
new file mode 100644
index 000000000..7010ab4c0
--- /dev/null
+++ b/app/views/replies/reply_to.js.erb
@@ -0,0 +1 @@
+$(".reply[data-id=<%= @reply.id %>] .reply-to-block").replaceWith("<%= j(render('reply_to', reply: @reply, show_body: true)) %>")
diff --git a/app/views/replies/system_events/_ban.html.erb b/app/views/replies/system_events/_ban.html.erb
new file mode 100644
index 000000000..42f031fd9
--- /dev/null
+++ b/app/views/replies/system_events/_ban.html.erb
@@ -0,0 +1,5 @@
+<% if reply.body.present? %>
+屏蔽了此话题:
<%= reply.body %>
+<% else %>
+内容不符合版规屏蔽此话题
+<% end %>
diff --git a/app/views/replies/system_events/_close.html.erb b/app/views/replies/system_events/_close.html.erb
new file mode 100644
index 000000000..94c6f7eea
--- /dev/null
+++ b/app/views/replies/system_events/_close.html.erb
@@ -0,0 +1 @@
+关闭了讨论
diff --git a/app/views/replies/system_events/_excellent.html.erb b/app/views/replies/system_events/_excellent.html.erb
new file mode 100644
index 000000000..109749b15
--- /dev/null
+++ b/app/views/replies/system_events/_excellent.html.erb
@@ -0,0 +1 @@
+将本帖设为了精华贴
diff --git a/app/views/replies/system_events/_mention.html.erb b/app/views/replies/system_events/_mention.html.erb
new file mode 100644
index 000000000..4b084ad69
--- /dev/null
+++ b/app/views/replies/system_events/_mention.html.erb
@@ -0,0 +1,8 @@
+在
+
+<% if reply.target_type == "Topic" %>
+ <%= topic_title_tag(reply&.target) %>
+<% else %>
+ <%= topic_title_tag(reply&.target&.topic) %>
+<% end %>
+ 中提及了此贴
diff --git a/app/views/replies/system_events/_reopen.html.erb b/app/views/replies/system_events/_reopen.html.erb
new file mode 100644
index 000000000..9aa108609
--- /dev/null
+++ b/app/views/replies/system_events/_reopen.html.erb
@@ -0,0 +1 @@
+重新开启了讨论
diff --git a/app/views/replies/system_events/_unexcellent.html.erb b/app/views/replies/system_events/_unexcellent.html.erb
new file mode 100644
index 000000000..a5d082ede
--- /dev/null
+++ b/app/views/replies/system_events/_unexcellent.html.erb
@@ -0,0 +1 @@
+取消了精华贴
diff --git a/app/views/replies/update.js.erb b/app/views/replies/update.js.erb
new file mode 100644
index 000000000..5b8ca1c09
--- /dev/null
+++ b/app/views/replies/update.js.erb
@@ -0,0 +1,7 @@
+<% if @reply.errors.empty? %>
+ <% flash[:notice] = '回帖更新成功。' %>
+ Turbolinks.visit('<%= topic_path(@reply.topic_id) %>');
+<% else %>
+ $('form[tb="edit-reply"] .alert').remove();
+ $('form[tb="edit-reply"]').prepend('<%= j render("shared/error_messages", target: @reply) %>');
+<% end %>
diff --git a/app/views/search/_page.html.erb b/app/views/search/_page.html.erb
new file mode 100644
index 000000000..1e554007b
--- /dev/null
+++ b/app/views/search/_page.html.erb
@@ -0,0 +1,13 @@
+<% if Setting.has_module?(:wiki) %>
+<%
+title = hit.highlight.title.try(:first) || item.title
+%>
+
+
<%= link_to highlight(title), homeland_wiki.page_path(item) %> Wiki
+
+ <%= link_to homeland_wiki.page_url(item), homeland_wiki.page_path(item) %>
+ <%= item.updated_at.to_date %>
+
+
<%= highlight(hit.highlight.body.try(:first)) %>
+
+<% end %>
diff --git a/app/views/search/_team.html.erb b/app/views/search/_team.html.erb
new file mode 100644
index 000000000..d8b5ed321
--- /dev/null
+++ b/app/views/search/_team.html.erb
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/app/views/search/_topic.html.erb b/app/views/search/_topic.html.erb
new file mode 100644
index 000000000..619ddc340
--- /dev/null
+++ b/app/views/search/_topic.html.erb
@@ -0,0 +1,11 @@
+<%
+title = hit.highlight.title.try(:first) || item.title
+%>
+
+
<%= link_to highlight(title), item %>
+
+ <%= link_to polymorphic_url(item), item %>
+ <%= item.created_at.to_date %>
+
+
<%= highlight(hit.highlight.body.try(:first)) %>
+
diff --git a/app/views/search/_user.html.erb b/app/views/search/_user.html.erb
new file mode 100644
index 000000000..de6d36f1e
--- /dev/null
+++ b/app/views/search/_user.html.erb
@@ -0,0 +1,25 @@
+<% if item.organization? %>
+ <%= render 'team', item: item, hit: hit %>
+<% else %>
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/search/index.html.erb b/app/views/search/index.html.erb
new file mode 100644
index 000000000..aebe40944
--- /dev/null
+++ b/app/views/search/index.html.erb
@@ -0,0 +1,22 @@
+<% title_tag [params[:q], t('common.search_result')].join(' · ') %>
+
+
+
+ 关于 “<%= params[:q] %>” 的搜索结果, 共 <%= @result.records.total %> 条
+
+
+
+ <% if @result.records.total == 0 %>
+
没有搜索到任何有关 “<%= params[:q]%>” 的内容
+ <% else %>
+ <% @result.records.each_with_hit do |item, hit| %>
+ <% partial_view_name = item.class.name.downcase -%>
+ <%= render partial: (partial_view_name == 'team' ? 'user' : partial_view_name), locals: { item: item, hit: hit } %>
+ <% end %>
+ <% end %>
+
+
+
+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb
new file mode 100644
index 000000000..fc3caccbd
--- /dev/null
+++ b/app/views/sessions/new.html.erb
@@ -0,0 +1,50 @@
+<% title_tag t("common.login") %>
+
+
+
+
+
<%= t("common.login") %>
+
+ <%= simple_form_for(resource, as: resource_name, url: session_path(resource_name), remote: true, html: { class: "" }) do |f| %>
+
+ <%= f.text_field :login, type: :email, class: "form-control input-lg", placeholder: "用户名 / Email" %>
+
+
+ <%= f.text_field :password, type: :password, class: "form-control input-lg", placeholder: "密码" %>
+
+
+ <% if devise_mapping.rememberable? -%>
+
+
+ <%= f.check_box :remember_me %> <%= t("common.remember_me") %>
+
+
+ <% end -%>
+
+ <%= f.submit t("common.login"), class: "btn btn-primary btn-lg btn-block", 'data-disable-with' => t("common.logining") %>
+
+ <% end %>
+
+
+
+
+ <% if Setting.has_module? :github %>
+
+
<%= t("common.auth_with_other_services") %>
+
+ <%= link_to raw(" GitHub"), omniauth_authorize_path(:user, :github), class: "btn btn-default btn-lg btn-block" %>
+
+
+ <% end %>
+
+
+ <%= render partial: "devise/shared/links" %>
+
+
+
+
+
diff --git a/app/views/settings/_menu.html.erb b/app/views/settings/_menu.html.erb
new file mode 100644
index 000000000..a7cd105db
--- /dev/null
+++ b/app/views/settings/_menu.html.erb
@@ -0,0 +1,12 @@
+
diff --git a/app/views/settings/account.html.erb b/app/views/settings/account.html.erb
new file mode 100644
index 000000000..047f56056
--- /dev/null
+++ b/app/views/settings/account.html.erb
@@ -0,0 +1,48 @@
+
+ <%= render 'menu' %>
+
+
+
+
+
删除我的账号
+
+ <%= form_with(model: @user, url: setting_path, method: :delete) do |f| %>
+ <%= render "shared/error_messages", :target => resource if params[:by] == "destroy" %>
+
+
+
+ <%= f.password_field :current_password, class: "form-control", placeholder: t("users.confirm_delete_account") %>
+
+ <%= f.submit t("users.ensure_to_delete"), :class => "btn btn-danger", 'data-disable-with' => t("common.submitting") %>
+ <% end %>
+
+
绑定其他帐号用于登录
+
+ <% if !Setting.sso_enabled? %>
+ <% if Setting.has_module? :github %>
+
+ <% ["github"].each do |provider| %>
+
+ <% if @user.bind? provider %>
+ <%= t("omniauth.#{provider}") %>
+ <%= link_to(raw(' '),"/setting/auth/#{provider}", rel: "twitsy", title: "点击解除绑定", method: 'delete' )%>
+ <% else %>
+ <%= link_to(t("omniauth.#{provider}"), "/account/auth/#{provider}", rel: "twitsy", title: "点击绑定" )%>
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+ <% end %>
+
+
+
+
+
+
+
+
+
diff --git a/app/views/settings/password.html.erb b/app/views/settings/password.html.erb
new file mode 100644
index 000000000..61c16e3cf
--- /dev/null
+++ b/app/views/settings/password.html.erb
@@ -0,0 +1,22 @@
+
+ <%= render 'menu' %>
+
+
+
注意!更新密码以后需要重新登陆。
+
+
+
+ <%= simple_form_for(@user, url: setting_path, html: { method: :put }) do |f| %>
+ <%= render "shared/error_messages", target: @user %>
+
+ <%= f.input :current_password %>
+ <%= f.input :password %>
+ <%= f.input :password_confirmation %>
+
+ <%= f.submit t("users.update_password"), class: "btn btn-lg btn-success", 'data-disable-with' => t("common.saving") %>
+
+ <% end %>
+
+
+
+
diff --git a/app/views/settings/profile.html.erb b/app/views/settings/profile.html.erb
new file mode 100644
index 000000000..3b23cf157
--- /dev/null
+++ b/app/views/settings/profile.html.erb
@@ -0,0 +1,67 @@
+
+ <%= render 'menu' %>
+
+
+
+
+ <%= simple_form_for(@user, url: setting_path, html: { method: :put, enctype: "multipart/form-data" }) do |f| %>
+ <%= render "shared/error_messages", target: @user %>
+
+ <% if Setting.has_profile_field? :location %>
+ <%= f.input :location, input_html: { style: 'width: 200px' } %>
+ <% end %>
+ <% if Setting.has_profile_field? :company %>
+ <%= f.input :company, input_html: { style: 'width: 400px' } %>
+ <% end %>
+ <% if Setting.has_module? :github %>
+
+ <% end %>
+ <% if Setting.has_profile_field? :twitter %>
+
+ <% end %>
+ <% if Setting.has_profile_field? :website %>
+ <%= f.input :website, type: :url, input_html: { placeholder: "http://" } %>
+ <% end %>
+
+ <% User::PROFILE_FIELDS.each do |field| %>
+ <%
+ next if !Setting.has_profile_field? field
+ %>
+ <%
+ field_prefix = User.profile_field_prefix(field)
+ %>
+
+ <% end %>
+
+ <% if Setting.has_profile_field? :tagline %>
+ <%= f.input :tagline %>
+ <% end %>
+
+ "><%= t("users.update_profile")%>
+
+ <% end %>
+
+
+
+
+
diff --git a/app/views/settings/reward.html.erb b/app/views/settings/reward.html.erb
new file mode 100644
index 000000000..087203e5b
--- /dev/null
+++ b/app/views/settings/reward.html.erb
@@ -0,0 +1,55 @@
+
+ <%= render 'menu' %>
+
+
+
+
+
+
+ <%= simple_form_for(@user, url: setting_path, method: :put, html: { enctype: "multipart/form-data" }) do |f| %>
+ <%= render "shared/error_messages", target: @user %>
+
+
+ <% User::REWARD_FIELDS.each do |field| %>
+
+
+ <% if @user.reward_field(field) %>
+
+ <% end %>
+ <% end %>
+
+
+ "><%= t("users.update_profile")%>
+
+ <% end %>
+
+
+
+ <%= markdown("### :gift: 关于打赏\n\n
+
+ 打赏功能为了鼓励分享而设计。
+
+ 上传你的微信、支付宝 **收款二维码**,之后打赏按钮将会出现在 “个人主页” 和 “文章末尾” 显示。
+ ") %>
+
+
+
+
+
+
+
+
diff --git a/app/views/settings/show.html.erb b/app/views/settings/show.html.erb
new file mode 100644
index 000000000..8ee41c757
--- /dev/null
+++ b/app/views/settings/show.html.erb
@@ -0,0 +1,34 @@
+
+ <%= render 'menu' %>
+
+
+
+
+ <%= simple_form_for(@user, url: setting_path, html: { method: :put, enctype: "multipart/form-data" }) do |f| %>
+ <%= render "shared/error_messages", target: @user %>
+
+
+ <%= f.input :name, input_html: { style: 'width: 200px' } %>
+ <%= f.input :email, input_html: { disabled: (@user.email_locked?) } %>
+
+ <%= f.input :avatar %>
+ <%= f.input :bio, as: :text, input_html: { class: "xxlarge", rows: "6" } %>
+
+
+
+ <%= user_avatar_tag(@user, :lg, link: false) %>
+
+
+
+
+ "><%= t("users.update_profile")%>
+
+ <% end %>
+
+
+
+
diff --git a/app/views/shared/_comment.html.erb b/app/views/shared/_comment.html.erb
new file mode 100644
index 000000000..cf4350632
--- /dev/null
+++ b/app/views/shared/_comment.html.erb
@@ -0,0 +1,13 @@
+
diff --git a/app/views/shared/_comments.html.erb b/app/views/shared/_comments.html.erb
new file mode 100644
index 000000000..67663baa5
--- /dev/null
+++ b/app/views/shared/_comments.html.erb
@@ -0,0 +1,31 @@
+<%
+comments = Comment.where(commentable_type: commentable.class.name, commentable_id: commentable.id).includes(:user)
+new_comment = Comment.new(commentable_type: commentable.class.name, commentable_id: commentable.id)
+%>
+
+
+
+<% if @current_user %>
+
+<% end %>
diff --git a/app/views/shared/_editor_help_block.html.erb b/app/views/shared/_editor_help_block.html.erb
new file mode 100644
index 000000000..849e303ab
--- /dev/null
+++ b/app/views/shared/_editor_help_block.html.erb
@@ -0,0 +1,34 @@
+
+
+
+
diff --git a/app/views/shared/_editor_toolbar.html.erb b/app/views/shared/_editor_toolbar.html.erb
new file mode 100644
index 000000000..45a8e542c
--- /dev/null
+++ b/app/views/shared/_editor_toolbar.html.erb
@@ -0,0 +1,25 @@
+
diff --git a/app/views/shared/_error_messages.html.erb b/app/views/shared/_error_messages.html.erb
index f93184c46..b0a03f157 100644
--- a/app/views/shared/_error_messages.html.erb
+++ b/app/views/shared/_error_messages.html.erb
@@ -1,11 +1,11 @@
-<% if target.errors.any? %>
-
-
有 <%= target.errors.count %> 处问题导至无法提交:
-
-
- <% target.errors.full_messages.each do |msg| %>
- <%= msg %>
- <% end %>
-
-
+<% if target.errors.any? %>
+
+
×
+
有 <%= target.errors.count %> 处问题导致无法提交:
+
+ <% target.errors.full_messages.each do |msg| %>
+ <%= msg %>
+ <% end %>
+
+
<% end %>
diff --git a/app/views/shared/_hot_locations.html.erb b/app/views/shared/_hot_locations.html.erb
new file mode 100644
index 000000000..fc0d628d9
--- /dev/null
+++ b/app/views/shared/_hot_locations.html.erb
@@ -0,0 +1,10 @@
+<% cache(["index-locations",Time.now.strftime("%Y-%m-%d")]) do %>
+
+
<%=t("common.hot_locations")%>
+
+ <% Location.hot.limit(12).each do |item| %>
+ <%= location_name_tag(item) %>
+ <% end %>
+
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/shared/_index_sections.html.erb b/app/views/shared/_index_sections.html.erb
new file mode 100644
index 000000000..539c43b29
--- /dev/null
+++ b/app/views/shared/_index_sections.html.erb
@@ -0,0 +1,19 @@
+<% cache(["index_locations", CacheVersion.section_node_updated_at]) do %>
+
+
<%= t("common.index_node_navigation")%>
+
+
+ <% Section.includes(:nodes).all.each do |section| %>
+
+ <%= section.name %>
+
+ <% section.nodes.sorted.each do |node| %>
+ <%= link_to(node.name, node_topics_path(node), title: node.name, data: { id: node.id })%>
+ <% end %>
+
+
+ <% end %>
+
+
+
+<% end %>
diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb
new file mode 100644
index 000000000..b99826b98
--- /dev/null
+++ b/app/views/shared/_navbar.html.erb
@@ -0,0 +1,12 @@
+
+
+ <%= render_list_items do |li|
+ li << link_to(t("menu.topics"), Setting.has_module?(:home) ? main_app.topics_path : main_app.root_path)
+
+ Homeland.navbar_plugins.each do |plugin|
+ li << link_to(plugin.display_name, plugin.root_path) if Setting.has_module?(plugin.name)
+ end
+ end %>
+ <%= raw Setting.navbar_html %>
+
+
diff --git a/app/views/shared/_usernav.html.erb b/app/views/shared/_usernav.html.erb
new file mode 100644
index 000000000..97de4e496
--- /dev/null
+++ b/app/views/shared/_usernav.html.erb
@@ -0,0 +1,71 @@
+
+ <% if current_user %>
+
+
+ <%= user_avatar_tag(current_user, :sm, link: false) %>
+
+
+ Toggle
+ <%= user_avatar_tag(current_user, :sm, link: false) %>
+
+ <%= render_list class: "dropdown-menu", role: "menu" do |li|
+ li << link_to(current_user.login, main_app.user_path(current_user) )
+ li << "
"
+ li << link_to(t("menu.edit_account_path"), main_app.setting_path)
+ li << link_to(t("menu.likes"), main_app.favorites_topics_path)
+
+ Homeland.user_menu_plugins.each do |plugin|
+ li << link_to(plugin.display_name, plugin.root_path)
+ end
+
+ if admin?
+ li << "
"
+ if params[:controller].start_with?("admin")
+ li << link_to("回到前台", main_app.root_path)
+ else
+ li << link_to(t("menu.admin"), "/admin")
+ end
+ end
+ li << "
"
+ li << link_to(t("common.logout"), main_app.destroy_user_session_path, method: "delete" )
+ end %>
+
+ <% else %>
+ <% if !Setting.sso_enabled? %>
+ <%= link_to( t("common.register"), main_app.new_user_registration_path) %>
+ <% end %>
+ <%= link_to( t("common.login"), main_app.new_user_session_path ) %>
+ <% end %>
+
+
+
+
+
+
+ <% if current_user %>
+ <%
+ badge_class = ""
+ badge_class = "new" if unread_notify_count > 0
+ %>
+
+ <%= unread_notify_count %>
+
+
+
+
+
+ <%= render_list class: "dropdown-menu", role: "menu" do |li|
+ li << link_to(t("topics.new_topic"), main_app.new_topic_path)
+ if can?(:create, Team) && Setting.has_module?(:team)
+ li << "
"
+ li << link_to(t("teams.new_team"), main_app.new_team_path)
+ end
+ end %>
+
+ <% end %>
+
diff --git a/app/views/team_users/_form.html.erb b/app/views/team_users/_form.html.erb
new file mode 100644
index 000000000..bd4fea052
--- /dev/null
+++ b/app/views/team_users/_form.html.erb
@@ -0,0 +1,36 @@
+<%= simple_form_for @team_user, url: @team_user.new_record? ? user_team_users_path(@team) : user_team_user_path(@team, @team_user) do |f| %>
+ <%= render "shared/error_messages", target: @team_user %>
+
+ <% if @team_user.new_record? %>
+ <%= f.input :login %>
+ <% else %>
+
+ <% end %>
+ <%= f.input :role, as: :radio_buttons, collection: TeamUser.roles.keys %>
+
+
+
+<% end %>
diff --git a/app/views/team_users/_team_user.html.erb b/app/views/team_users/_team_user.html.erb
new file mode 100644
index 000000000..0dbefdde0
--- /dev/null
+++ b/app/views/team_users/_team_user.html.erb
@@ -0,0 +1,15 @@
+
+ <%= user_avatar_tag(team_user.user, :sm, link: false) %>
+
+ <%= user_name_tag(team_user.user) %>
+ <%= team_user&.user&.name %>
+
+
+ <%= team_user.pendding? ? team_user.status_name : team_user.role_name %>
+
+
+ <% if can?(:update, @team) && team_user.user_id != current_user&.id %>
+ <%= link_to icon_tag('pencil'), edit_user_team_user_path(@team, team_user), class: 'btn btn-default' %>
+ <% end %>
+
+
diff --git a/app/views/team_users/edit.html.erb b/app/views/team_users/edit.html.erb
new file mode 100644
index 000000000..8f3eb847d
--- /dev/null
+++ b/app/views/team_users/edit.html.erb
@@ -0,0 +1,14 @@
+<% title_tag t("teams.edit_team_user") %>
+
+<%= render '/teams/header' %>
+
+
+
+
+
+
<%= t("teams.edit_team_user") %>
+ <%= render '/team_users/form' %>
+
+
+
+
diff --git a/app/views/team_users/index.html.erb b/app/views/team_users/index.html.erb
new file mode 100644
index 000000000..e8ff36dd5
--- /dev/null
+++ b/app/views/team_users/index.html.erb
@@ -0,0 +1,19 @@
+<% title_tag t('teams.users') %>
+
+<%= render '/teams/header' %>
+
+
+
+ <% if can? :update, @team %>
+
+ <%= link_to t('teams.new_team_user'), new_user_team_user_path(@team), class: 'btn btn-success' %>
+
+ <% end %>
+
+ <%= render partial: '/team_users/team_user', collection: @team_users %>
+
+
+
+
diff --git a/app/views/team_users/new.html.erb b/app/views/team_users/new.html.erb
new file mode 100644
index 000000000..d29b82ac8
--- /dev/null
+++ b/app/views/team_users/new.html.erb
@@ -0,0 +1,14 @@
+<% title_tag t("teams.new_team_user") %>
+
+<%= render '/teams/header' %>
+
+
+
+
+
+
<%= t("teams.new_team_user") %>
+ <%= render '/team_users/form' %>
+
+
+
+
diff --git a/app/views/team_users/show.html.erb b/app/views/team_users/show.html.erb
new file mode 100644
index 000000000..b1cf70e48
--- /dev/null
+++ b/app/views/team_users/show.html.erb
@@ -0,0 +1,19 @@
+<% title_tag t("teams.show_team_user") %>
+<%= render '/teams/header' %>
+
+
+
+
+
+
<%= t("teams.show_team_user") %>
+
+ 你是否要接受邀请加入 <%= user_name_tag(@team) %> ?
+
+
+ <%= link_to t('teams.accept_team_user'), accept_user_team_user_path(@team, @team_user), data: { method: 'POST' }, class: 'btn btn-primary' %>
+ <%= link_to t('teams.reject_team_user'), reject_user_team_user_path(@team, @team_user), data: { method: 'POST' }, class: 'btn btn-default' %>
+
+
+
+
+
diff --git a/app/views/teams/_header.html.erb b/app/views/teams/_header.html.erb
new file mode 100644
index 000000000..f55c435e4
--- /dev/null
+++ b/app/views/teams/_header.html.erb
@@ -0,0 +1,44 @@
+<% content_for :sub_navbar do %>
+
+<% end %>
+
+<%= render_list class: "nav nav-tabs team-menu" do |li|
+ li << link_to(t("teams.topics"), user_path(@team))
+ li << link_to(t("teams.users"), user_team_users_path(@team))
+ li << link_to(t("teams.settings"), edit_team_path(@team)) if can?(:update, @team)
+end %>
diff --git a/app/views/teams/_sidebar.html.erb b/app/views/teams/_sidebar.html.erb
new file mode 100644
index 000000000..69b26ea42
--- /dev/null
+++ b/app/views/teams/_sidebar.html.erb
@@ -0,0 +1,15 @@
+
diff --git a/app/views/teams/_team_list.html.erb b/app/views/teams/_team_list.html.erb
new file mode 100644
index 000000000..4a807d0a5
--- /dev/null
+++ b/app/views/teams/_team_list.html.erb
@@ -0,0 +1,15 @@
+
+ <% teams.each do |team| %>
+
+ <% end %>
+
\ No newline at end of file
diff --git a/app/views/teams/edit.html.erb b/app/views/teams/edit.html.erb
new file mode 100644
index 000000000..d6da1327c
--- /dev/null
+++ b/app/views/teams/edit.html.erb
@@ -0,0 +1,70 @@
+<% title_tag t("teams.edit_team") %>
+
+<%= render '/teams/header' %>
+
+
+
+
+
+ <%= simple_form_for @team do |f| %>
+ <%= render "shared/error_messages", target: @team %>
+
+ <%= f.input :login, placeholder: user_url('xxx').gsub('xxx', ':slug') %>
+ <%= f.input :name %>
+ <%= f.input :email %>
+
+
+
+ <%= f.input :avatar %>
+
+
+ <%= user_avatar_tag(@team, :lg, link: false) %>
+
+
+ <%= f.input :location %>
+
+ <% if Setting.has_module? :github %>
+
+ <% end %>
+
+
+ <%= f.input :website, type: :url, input_html: { placeholder: "http://" } %>
+ <%= f.input :bio, as: :text %>
+
+
+ <%= f.submit t("common.save"), class: "btn btn-primary btn-block", 'data-disable-with' => t("common.saving") %>
+
+ <% end %>
+
+
+
+
+
+
+
+
<%= t('teams.teams')%>
+
+ 公司/组织品牌展示;
+ 独立的组织首页;
+ 汇集成员在社区里面的话题;
+ 为团队招揽人才;
+
+
+
+
diff --git a/app/views/teams/index.html.erb b/app/views/teams/index.html.erb
new file mode 100644
index 000000000..269be649e
--- /dev/null
+++ b/app/views/teams/index.html.erb
@@ -0,0 +1,13 @@
+<% title_tag t('menu.teams')%>
+
+
+
+
+
<%= t("users.hot_teams") %>
+
<%= t("users.current_have") %> <%= @total_team_count %> <%= t("users.teams_joined") %> <%= Setting.app_name %>
+
+
+ <%= render "team_list", teams: @active_teams %>
+
+
+
diff --git a/app/views/teams/new.html.erb b/app/views/teams/new.html.erb
new file mode 100644
index 000000000..ae2b6f662
--- /dev/null
+++ b/app/views/teams/new.html.erb
@@ -0,0 +1,38 @@
+<% title_tag t("teams.new_team") %>
+
+
+
+
+
+ <%= simple_form_for @team do |f| %>
+ <%= render "shared/error_messages", target: @team %>
+
+ <%= f.input :login, placeholder: user_url('xxx').gsub('xxx', ':slug') %>
+ <%= f.input :name %>
+ <%= f.input :email %>
+ <%= f.input :bio, as: :text %>
+
+
+ <%= f.submit t("teams.new_team"), class: "btn btn-primary btn-block", 'data-disable-with' => t("common.saving") %>
+
+
+
+ 如你发现你的公司或团队名称被抢注,可以尝试联系管理员,我们可以尽可能的协调。
+
+ <% end %>
+
+
+
+
+
+
+
<%= t('teams.teams')%>
+
+ 公司/组织品牌展示;
+ 独立的组织首页;
+ 汇集成员在社区里面的话题;
+ 为团队招揽人才;
+
+
+
+
diff --git a/app/views/teams/show.html.erb b/app/views/teams/show.html.erb
new file mode 100644
index 000000000..dd54c7f4c
--- /dev/null
+++ b/app/views/teams/show.html.erb
@@ -0,0 +1,20 @@
+<%= render '/teams/header' %>
+
+
+
+
+
+ <% if @topics.blank? %>
+
+ <% else %>
+ <%= render partial: '/topics/topic', collection: @topics, locals: { suggest: false }, cached: -> (topic) { [topic, 'normal'] } %>
+ <% end %>
+
+
+
+
+
+ <%= render '/teams/sidebar' %>
+
diff --git a/app/views/topic_mailer/got_reply.html.erb b/app/views/topic_mailer/got_reply.html.erb
deleted file mode 100644
index 414a4a083..000000000
--- a/app/views/topic_mailer/got_reply.html.erb
+++ /dev/null
@@ -1,11 +0,0 @@
-你的发贴收到了回复
-
- 你好<%= @topic.user.name %>,
- 你在<%= APP_CONFIG['app_name'] %>发布的《<%= @topic.title %> 》收到了回复.
-
- <%= @reply.user.name %>说:
- <%= format_topic_body @reply.body,'',false %>
-
-
- 贴子地址: <%= topic_url(@topic.id) %> .
-
diff --git a/app/views/topic_mailer/new_reply.html.erb b/app/views/topic_mailer/new_reply.html.erb
new file mode 100644
index 000000000..d90ed3b51
--- /dev/null
+++ b/app/views/topic_mailer/new_reply.html.erb
@@ -0,0 +1,10 @@
+你关注的帖子有了新回复
+
+ <%= user_name_tag(@reply.user) %> 回复了 《<%= topic_title_tag(@topic)%>》:
+
+
+ <%= @reply.body_html %>
+
+
+ 帖子地址: <%= topic_title_tag(@topic) %>.
+
diff --git a/app/views/topics/_ban.html.erb b/app/views/topics/_ban.html.erb
new file mode 100644
index 000000000..1c189594b
--- /dev/null
+++ b/app/views/topics/_ban.html.erb
@@ -0,0 +1,30 @@
+
+
+
+ <%= form_for('', url: action_topic_path(topic, type: "ban"), method: "post", remote: true) do |f| %>
+
+
+
+ 选择原因
+
+ 直接屏蔽
+ <% Setting.ban_reason_list.each do |str| %>
+ <%= str %>
+ <% end %>
+
+
+
+ 或手工输入原因:
+
+
+
+
+ <% end %>
+
+
+
diff --git a/app/views/topics/_ban_reason.html.erb b/app/views/topics/_ban_reason.html.erb
new file mode 100644
index 000000000..004e35d1e
--- /dev/null
+++ b/app/views/topics/_ban_reason.html.erb
@@ -0,0 +1,8 @@
+<% if @topic.node_id == Node.no_point.id %>
+
+
此贴已暂时被屏蔽!
+
+ <%= markdown @topic.node.summary %>
+
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/topics/_base.html.erb b/app/views/topics/_base.html.erb
deleted file mode 100644
index 1b3389fb8..000000000
--- a/app/views/topics/_base.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<%= content_for :styles do %>
- <%= stylesheet_link_tag "topics", :cache => "cached_topics" %>
-<% end %>
diff --git a/app/views/topics/_buttons.html.erb b/app/views/topics/_buttons.html.erb
new file mode 100644
index 000000000..84d89db1c
--- /dev/null
+++ b/app/views/topics/_buttons.html.erb
@@ -0,0 +1,37 @@
+
+ <% if !turbolinks_ios? %>
+ <%= likeable_tag(@topic) %>
+ <%= topic_follow_tag(@topic) %>
+ <%= topic_favorite_tag(@topic) %>
+ <% end %>
+
+ <% if admin? %>
+ <% if !@topic.suggested_at.blank? %>
+ <%= link_to raw(" 取消"), unsuggest_admin_topic_path(@topic), method: :post, remote: true %>
+ <% else %>
+ <%= link_to raw(" 置顶"), suggest_admin_topic_path(@topic), method: :post, remote: true %>
+ <% end %>
+ <% if !@topic.excellent? %>
+ <%= link_to raw(" 加精"), action_topic_path(@topic.id, type: 'excellent'), title: "设为精华帖" , method: "post", remote: true %>
+ <% end %>
+
+ <% if @topic.node_id != Node.no_point.id %>
+ <%= link_to raw(" 屏蔽"), ban_topic_path(@topic), remote: true, title: "屏蔽此贴,移动到 NoPoint 节点" %>
+ <% end %>
+ <% end %>
+
+ <% if owner?(@topic) or admin? %>
+ <% if can?(:close, @topic) %>
+ <% if !@topic.closed? %>
+ <%= link_to raw(" "), action_topic_path(@topic, type: 'close'), method: 'post', title: "关闭讨论/问题已解决", remote: true, data: { toggle: 'tooltip' } %>
+ <% else %>
+ <%= link_to raw(" "), action_topic_path(@topic, type: 'open'), method: 'post', title: "重新开启话题", remote: true, data: { toggle: 'tooltip' } %>
+ <% end %>
+ <% end %>
+ <%= link_to "", edit_topic_path(@topic), class: "fa fa-pencil", title: "修改本帖" %>
+ <% if can?(:destroy, @topic) %>
+ <%= link_to "", topic_path(@topic.id), method: :delete, remote: true, 'data-confirm': t("common.confirm_delete"), class: "fa fa-trash", title: "删除本帖" %>
+ <% end %>
+ <% end %>
+
+
diff --git a/app/views/topics/_form.html.erb b/app/views/topics/_form.html.erb
index b7e091cf9..15c615c08 100644
--- a/app/views/topics/_form.html.erb
+++ b/app/views/topics/_form.html.erb
@@ -1,42 +1,36 @@
-<% content_for :scripts do %>
-
-<% end %>
-<%= form_for(@topic, :url => (@topic.id.blank? ? topics_path : topic_path(@topic.id))) do |f| %>
- <%= render "shared/error_messages", :target => @topic %>
+<%= render 'node_selector', node: @topic.node %>
+<%= simple_form_for @topic, remote: true, html: { class: "form", tb: 'edit-topic' } do |f| %>
+ <%= render "shared/error_messages", target: @topic %>
<%= f.hidden_field :node_id %>
-
- <%= f.label :title %>
- <%= f.text_field :title, :class => "middle" %>
+
+
-
-
- <%= f.label :body %>
- <%= f.text_area :body, :class => "topic_body_text_area long", :style => "height:400px" %>
+
+ <%= render "/shared/editor_toolbar" %>
+
+
+ <%= f.text_area :body, class: "topic-editor form-control closewarning", rows: 20 %>
-
-
保存
+
+ <% if Setting.has_module?(:team) %>
+ <% if current_user.team_collection.any? %>
+
+ <%= f.input :team_id, collection: current_user.team_collection, include_blank: '不要关联组织' %>
+
+ <% end %>
+ <% end %>
+
+
<% end %>
diff --git a/app/views/topics/_node_info.html.erb b/app/views/topics/_node_info.html.erb
new file mode 100644
index 000000000..24434ec60
--- /dev/null
+++ b/app/views/topics/_node_info.html.erb
@@ -0,0 +1,38 @@
+<% content_for :sub_navbar do %>
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/topics/_node_selector.html.erb b/app/views/topics/_node_selector.html.erb
new file mode 100644
index 000000000..f091c76b1
--- /dev/null
+++ b/app/views/topics/_node_selector.html.erb
@@ -0,0 +1,13 @@
+
+
+
+
+
+ <%= render '/shared/index_sections' %>
+
+
+
+
diff --git a/app/views/topics/_related_topics.html.erb b/app/views/topics/_related_topics.html.erb
new file mode 100644
index 000000000..249f03c8c
--- /dev/null
+++ b/app/views/topics/_related_topics.html.erb
@@ -0,0 +1,12 @@
+<% cache([@topic, 'related_topic', Date.current]) do %>
+ <% if @topic.related_topics.present? %>
+
+
相关话题
+
+ <% @topic.related_topics.each do |topic| %>
+ <%= link_to topic.title, topic %>
+ <% end %>
+
+
+ <% end %>
+<% end %>
diff --git a/app/views/topics/_reply_form.html.erb b/app/views/topics/_reply_form.html.erb
new file mode 100644
index 000000000..7e87f3a2c
--- /dev/null
+++ b/app/views/topics/_reply_form.html.erb
@@ -0,0 +1,14 @@
+<%= render "/shared/editor_toolbar" %>
+<%= form_with(model: Reply.new, remote: true, url: topic_replies_path(@topic), id: "new_reply") do |f| %>
+
+
+ <%= f.text_area :body, class: "topic-editor form-control", rows: "4", tabindex: "1" %>
+
+ <%= f.hidden_field :reply_to_id %>
+
+<% end %>
diff --git a/app/views/topics/_sidebar.html.erb b/app/views/topics/_sidebar.html.erb
deleted file mode 100644
index 24a2c82a9..000000000
--- a/app/views/topics/_sidebar.html.erb
+++ /dev/null
@@ -1,77 +0,0 @@
-
diff --git a/app/views/topics/_sidebar_box_node_recent_topics.html.erb b/app/views/topics/_sidebar_box_node_recent_topics.html.erb
new file mode 100644
index 000000000..47a28a6b0
--- /dev/null
+++ b/app/views/topics/_sidebar_box_node_recent_topics.html.erb
@@ -0,0 +1,17 @@
+<% cache(["sidebar_for_node_recent_topics", topic.node_id, Time.now.strftime("%Y-%m-%d %H")]) do %>
+ <%
+ limit = [[topic.replies_count, 1].max, 10].min
+ topics = Topic.where(:id.ne => topic.id, :node_id => topic.node_id).recent.limit(limit)
+ %>
+
+ <% if topics.present? %>
+
+
<%= t("topics.node_recent_topics") %>
+
+ <% topics.each do |item| %>
+ <%= link_to(truncate(item.title, length: 30), topic_path(item), title: item.title) %>
+ <% end %>
+
+
+ <% end %>
+<% end %>
\ No newline at end of file
diff --git a/app/views/topics/_sidebar_box_tips.html.erb b/app/views/topics/_sidebar_box_tips.html.erb
new file mode 100644
index 000000000..f4d3244ec
--- /dev/null
+++ b/app/views/topics/_sidebar_box_tips.html.erb
@@ -0,0 +1,6 @@
+
+
小帖士
+
+ <%= random_tips %>
+
+
\ No newline at end of file
diff --git a/app/views/topics/_sidebar_for_topic_index.html.erb b/app/views/topics/_sidebar_for_topic_index.html.erb
new file mode 100644
index 000000000..632cd11c4
--- /dev/null
+++ b/app/views/topics/_sidebar_for_topic_index.html.erb
@@ -0,0 +1,29 @@
+<% if current_user && current_user.newbie? %>
+
+
新手必读
+
+ <%= raw Setting.newbie_notices %>
+
+
+<% else %>
+
+
+ <%= link_to t('topics.new_topic'), main_app.new_topic_path, class: 'btn btn-primary btn-block' %>
+
+
+<% end %>
+
+<%= render "topics/sidebar_box_tips" %>
+
+<%= raw Setting.topic_index_sidebar_html %>
+
+<% cache(["sidebar_statistics",Time.now.strftime("%Y-%m-%d %H")]) do %>
+
+
<%= t("common.statics")%>
+
+ 社区会员: <%= User.unscoped.count %> 人
+ 帖子数: <%= Topic.unscoped.count %> 个
+ 回帖数: <%= Reply.unscoped.count %> 条
+
+
+<% end %>
diff --git a/app/views/topics/_topic.html.erb b/app/views/topics/_topic.html.erb
new file mode 100644
index 000000000..111d57712
--- /dev/null
+++ b/app/views/topics/_topic.html.erb
@@ -0,0 +1,36 @@
+<% cache([topic, topic.user_avatar_raw, suggest]) do %>
+
+<% end %>
diff --git a/app/views/topics/_topic_info.html.erb b/app/views/topics/_topic_info.html.erb
new file mode 100644
index 000000000..5c2d29481
--- /dev/null
+++ b/app/views/topics/_topic_info.html.erb
@@ -0,0 +1,35 @@
+
diff --git a/app/views/topics/_topic_sidebar.html.erb b/app/views/topics/_topic_sidebar.html.erb
new file mode 100644
index 000000000..754094229
--- /dev/null
+++ b/app/views/topics/_topic_sidebar.html.erb
@@ -0,0 +1,68 @@
+
diff --git a/app/views/topics/ban.js.erb b/app/views/topics/ban.js.erb
new file mode 100644
index 000000000..1633f3db4
--- /dev/null
+++ b/app/views/topics/ban.js.erb
@@ -0,0 +1,3 @@
+$('#ban-modal').remove();
+$('body').append("<%= j(render('ban', topic: @topic)) %>");
+$('#ban-modal').modal();
diff --git a/app/views/topics/create.js.erb b/app/views/topics/create.js.erb
new file mode 100644
index 000000000..28b3e86b0
--- /dev/null
+++ b/app/views/topics/create.js.erb
@@ -0,0 +1,7 @@
+<% if @topic.errors.empty? %>
+ <% flash[:notice] = t('topics.create_topic_success') %>
+ Turbolinks.visit('<%= topic_path(@topic.id) %>');
+<% else %>
+ $('form[tb="edit-topic"] .alert').remove();
+ $('form[tb="edit-topic"]').prepend('<%= j render("shared/error_messages", target: @topic) %>');
+<% end %>
diff --git a/app/views/topics/edit.html.erb b/app/views/topics/edit.html.erb
index e544c923f..20ebe99ac 100644
--- a/app/views/topics/edit.html.erb
+++ b/app/views/topics/edit.html.erb
@@ -1,12 +1,8 @@
-<%= render 'base' %>
-<% content_for :sitemap do %>
-
修改
-<% end %>
+<% title_tag t('topics.edit_topic') %>
-
-
-
修改贴子
- <%= render 'form' %>
-
+
+
<%= t('topics.edit_topic') %> <%= topic_title_tag(@topic) %>
+
+ <%= render 'form' %>
+
-<%= render 'sidebar' %>
\ No newline at end of file
diff --git a/app/views/topics/feed.builder b/app/views/topics/feed.builder
new file mode 100644
index 000000000..9c9e8dbf7
--- /dev/null
+++ b/app/views/topics/feed.builder
@@ -0,0 +1,19 @@
+xml.instruct! :xml, version: "1.0"
+xml.rss(version: "2.0"){
+ xml.channel{
+ xml.title t("rss.recent_topics_title", name: Setting.app_name)
+ xml.link root_url
+ xml.description(t("rss.recent_topics_description", name: Setting.app_name ))
+ xml.language('en-us')
+ for topic in @topics
+ xml.item do
+ xml.title topic.title
+ xml.description markdown(topic.body)
+ xml.author topic.user.login
+ xml.pubDate(topic.created_at.strftime("%a, %d %b %Y %H:%M:%S %z"))
+ xml.link topic_url topic
+ xml.guid topic_url topic
+ end
+ end
+ }
+}
diff --git a/app/views/topics/feed.rxml b/app/views/topics/feed.rxml
deleted file mode 100644
index 4cae32224..000000000
--- a/app/views/topics/feed.rxml
+++ /dev/null
@@ -1,19 +0,0 @@
-xml.instruct! :xml, :version=>"1.0"
-xml.rss(:version=>"2.0"){
- xml.channel{
- xml.title "#{APP_CONFIG['app_name']}论坛"
- xml.link root_url
- xml.description("#{APP_CONFIG['app_name']}论坛最新发贴.")
- xml.language('en-us')
- for topic in @topics
- xml.item do
- xml.title h(topic.title)
- xml.description topic.body
- xml.author topic.user.name
- xml.pubDate(topic.created_at.strftime("%a, %d %b %Y %H:%M:%S %z"))
- xml.link topic_url topic
- xml.guid topic_url topic
- end
- end
- }
-}
diff --git a/app/views/topics/index.html.erb b/app/views/topics/index.html.erb
index 6ac60e18c..8c74e0ce5 100644
--- a/app/views/topics/index.html.erb
+++ b/app/views/topics/index.html.erb
@@ -1,105 +1,47 @@
-<%= render 'base' %>
-<% content_for :sitemap do %>
- <% if params[:action] == "node" %>
-
<%= h @node.name %>
- <% elsif params[:action] == "recent" %>
-
最近活跃的50个主题
- <% elsif params[:action] == "search" %>
-
搜索<%= h params[:s] %>
- <% else %>
-
活跃帖子
- <% end %>
-<% end %>
+<% title_tag(@page_title || t('menu.topics')) %>
-
-
- <% if !@node.blank? %>
-
-
-
<%= h @node.name %>
-
共有 <%= @node.topics_count %> 个讨论主题
-
-
<%= h @node.summary %>
-
- 发布新帖
-
-
- <% else %>
-
<%= APP_CONFIG['app_name'] %>论坛
- <% end %>
- <% if params[:action] == "node" %>
-
-
- 回复数
- 标题
- 作者
- 最后回复时间
-
- <% @topics.each do |topic| %>
-
- <%= topic.replies_count %>
- <%= truncate(topic.title, :length => 25) %>
- <%= user_name_tag(topic.user) %>
- <%= l (topic.replied_at || topic.created_at), :format => :short %>
-
+
+<% @suggest_topics = @suggest_topics.to_a %>
+
+<%= render "topics/node_info", node: @node %>
+
+
+
+
+
+
+ <% if @suggest_topics.present? %>
+ <%= render partial: '/topics/topic', collection: @suggest_topics, locals: { suggest: true } %>
+ <% end %>
+
+ <%= render partial: '/topics/topic', collection: @topics, locals: { suggest: false } %>
+
+
+
- <% else %>
-
- <% @topics.each do |topic| %>
-
-
- <%= user_avatar_tag(topic.user,:normal) %>
-
-
- <%= truncate(topic.title, :length => 100) %>
-
- 由 <%= user_name_tag(topic.user) %>
- 在 <%= topic.node.name %> 中发起
-
-
- <% if topic.last_reply_user.blank? %>
- 发布于 <%= time_ago_in_words(topic.created_at) %>前
- <% else %>
- 最后由 <%= user_name_tag(topic.last_reply_user) %> 回复于 <%= time_ago_in_words(topic.replied_at) %>前
- <% end %>
-
-
-
- <% if topic.replies_count > 0 %>
- <% readed_state = topic.user_readed?(@current_user.id) if @current_user %>
-
- <%= topic.replies_count %>
-
- <% end %>
-
-
- <% end %>
-
- <% end %>
-
- <% if params[:action] == "index" %>
-
- <% elsif params[:action] == "node" or params[:action] == "search" %>
- <%= will_paginate @topics %>
+
+
+
+ <% if current_user %>
+
<% end %>
-
- <% if params[:action] == "index" %>
-
-
讨论节点分类导航
-
- <% @sections.each do |section| %>
-
- <%= section.name %>
- <% section.nodes.each do |node| %>
- <%= node.name %>
- <% end %>
-
- <% end %>
-
-
- <% end %>
+
+
+
+
+
-<%= render 'sidebar' %>
diff --git a/app/views/topics/new.html.erb b/app/views/topics/new.html.erb
index ff62df3ea..a428b5930 100644
--- a/app/views/topics/new.html.erb
+++ b/app/views/topics/new.html.erb
@@ -1,12 +1,8 @@
-<%= render 'base' %>
-<% content_for :sitemap do %>
-
新建贴子
-<% end %>
+<% title_tag t('topics.new_topic') %>
-
-
-
新建贴子
- <%= render 'form' %>
-
+
+
<%= t('topics.new_topic') %>
+
+ <%= render 'form' %>
+
-<%= render 'sidebar' %>
\ No newline at end of file
diff --git a/app/views/topics/node_feed.builder b/app/views/topics/node_feed.builder
new file mode 100644
index 000000000..2a5f54c34
--- /dev/null
+++ b/app/views/topics/node_feed.builder
@@ -0,0 +1,18 @@
+xml.instruct! :xml, version: "1.0"
+xml.rss version: "2.0" do
+ xml.channel do
+ xml.title t("rss.recent_node_topics_title", name: Setting.app_name, node_name: @node.name)
+ xml.link root_url
+ xml.description t("rss.recent_node_topics_description", name: Setting.app_name, node_name: @node.name)
+ for topic in @topics
+ xml.item do
+ xml.title topic.title
+ xml.description markdown(topic.body)
+ xml.author topic.user.login
+ xml.pubDate topic.created_at.strftime("%a, %d %b %Y %H:%M:%S %z")
+ xml.link topic_url(topic)
+ xml.guid topic_url(topic)
+ end
+ end
+ end
+end
diff --git a/app/views/topics/preview.json.jbuilder b/app/views/topics/preview.json.jbuilder
new file mode 100644
index 000000000..7bdd91360
--- /dev/null
+++ b/app/views/topics/preview.json.jbuilder
@@ -0,0 +1 @@
+json.body markdown(@body)
diff --git a/app/views/topics/show.html.erb b/app/views/topics/show.html.erb
index b1c182a19..e5df6dabe 100644
--- a/app/views/topics/show.html.erb
+++ b/app/views/topics/show.html.erb
@@ -1,104 +1,107 @@
-<%= render 'base' %>
-<%= content_for :sitemap do %>
-
<%= @topic.node.name %> >
-
查看贴子
-<% end %>
-<%= content_for :scripts do %>
-
+<% title_tag @topic.title %>
+
+<% content_for :scripts do %>
+
<% end %>
-
-
-
-
-
- <%= truncate(@topic.title, :length => 100) %>
-
- 由 <%= user_name_tag(@topic.user,:location => true) %>
- 在 <%= @topic.node.name %> 节点中发起
-
-
- <% if @topic.last_reply_user.blank? %>
- 发布于 <%= time_ago_in_words(@topic.created_at) %>
- <% else %>
- 最后由 <%= @topic.last_reply_user.name %> 回复于 <%= time_ago_in_words(@topic.replied_at) %>
- <% end %>
-
-
-
- <%= image_tag(@topic.user.avatar(:large)) %>
-
-
-
-
- <%= format_topic_body(@topic.body) %>
-
-
- <% if @replies.blank? %>
-
- 暂无回复。
+
+
+
+
+ <%= render partial: "topics/topic_info", locals: { topic: @topic } %>
+
+ <% if @topic.excellent? %>
+
+
本帖已被设为精华帖!
+ <% if admin? %>
+
+ <%= link_to icon_tag("close"), action_topic_path(@topic.id, type: 'unexcellent'), data: { method: "post", remote: true } %>
+
+ <% end %>
+
+ <% end %>
+
+
+ <%= raw Setting.before_topic_html %>
+
+ <%= @topic.body_html %>
+
+ <%= raw Setting.after_topic_html %>
+
+
+
- <% else %>
-
-
- 截止 <%= l @topic.replied_at, :format => :long %>, 共收到 <%= @topic.replies_count %> 条回复
+
+ <%= render partial: "ban_reason" %>
+
+ <% if @replies.blank? %>
+
+ <%= t("topics.no_replies") %>
- <% @replies.each_with_index do |reply,i| %>
-
-
-
- <%= user_avatar_tag(reply.user, :normal) %>
-
-
-
<%= user_name_tag(reply.user,:location => true) %>
-
<%= i + 1 %>楼, 回复于 <%= time_ago_in_words(reply.created_at) %>前 <%= image_tag("reply.png") %>
-
-
- <%= format_topic_body(reply.body,"",false) %>
-
-
-
-
+ <% else %>
+
+
+ 共收到 <%= @topic.replies_count %> 条回复
- <% end %>
-
- <% end %>
- <% if @current_user %>
-
-<%= render 'sidebar' %>
diff --git a/app/views/topics/translation/_need_login_to_reply.zh-CN.html.erb b/app/views/topics/translation/_need_login_to_reply.zh-CN.html.erb
new file mode 100644
index 000000000..e0f6efc65
--- /dev/null
+++ b/app/views/topics/translation/_need_login_to_reply.zh-CN.html.erb
@@ -0,0 +1,5 @@
+
diff --git a/app/views/topics/translation/_need_login_to_reply.zh-TW.html.erb b/app/views/topics/translation/_need_login_to_reply.zh-TW.html.erb
new file mode 100644
index 000000000..aeeaf6c02
--- /dev/null
+++ b/app/views/topics/translation/_need_login_to_reply.zh-TW.html.erb
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/app/views/topics/update.js.erb b/app/views/topics/update.js.erb
new file mode 100644
index 000000000..065f5ccde
--- /dev/null
+++ b/app/views/topics/update.js.erb
@@ -0,0 +1,7 @@
+<% if @topic.errors.empty? %>
+ <% flash[:notice] = t('topics.update_topic_success') %>
+ Turbolinks.visit('<%= topic_path(@topic.id) %>');
+<% else %>
+ $('form[tb="edit-topic"] .alert').remove();
+ $('form[tb="edit-topic"]').prepend('<%= j render("shared/error_messages", target: @topic) %>');
+<% end %>
diff --git a/app/views/user_mailer/welcome.html.erb b/app/views/user_mailer/welcome.html.erb
index 88d04f74a..4fba31a6b 100644
--- a/app/views/user_mailer/welcome.html.erb
+++ b/app/views/user_mailer/welcome.html.erb
@@ -1,8 +1,5 @@
-
欢迎加入<%= APP_CONFIG['app_name'] %>, <%= @user.name %>
-
- 你已经成功在<%= APP_CONFIG['domain'] %>注册了账号,
- 请使用你的邮件: <%= @user.email %> 用于登录.
-
-
- 登录页面地址: <%= login_url %> .
-
+
你好 <%= @user.fullname %>
+
<%= t("mail.you_have_successfully") %> <%= link_to Setting.app_name, root_url %> <%= t("mail.registered_an_account") %>
+
<%= t("mail.welcome_title", app_name: Setting.app_name) %>
+
<%= link_to '立即登录', new_user_session_url, class: 'btn btn-primary' %>
+
<%= t("mail.login_from") %>:<%= link_to new_user_session_url, new_user_session_url %>
diff --git a/app/views/users/_bio.html.erb b/app/views/users/_bio.html.erb
new file mode 100644
index 000000000..42c9791b3
--- /dev/null
+++ b/app/views/users/_bio.html.erb
@@ -0,0 +1,4 @@
+
+
<%= t("users.bio")%>
+
<%= auto_link(simple_format(bio)) %>
+
diff --git a/app/views/users/_info.html.erb b/app/views/users/_info.html.erb
new file mode 100644
index 000000000..c0bdbc5c6
--- /dev/null
+++ b/app/views/users/_info.html.erb
@@ -0,0 +1,47 @@
+
+
+
+ <%
+ start_date = 12.months.ago.beginning_of_month
+ %>
+
+
+ <% if @user.profile_fields.present? %>
+
+ <% @user.profile_fields.each_key do |field| %>
+ <% next if @user.profile_field(field).blank? %>
+
+ <%= User.profile_field_label(field) %>
+ <%= auto_link @user.full_profile_field(field), link: :urls, html: { target: '_blank' } %>
+
+ <% end %>
+
+ <% end %>
+
+
diff --git a/app/views/users/_menu.html.erb b/app/views/users/_menu.html.erb
new file mode 100644
index 000000000..3d92bc637
--- /dev/null
+++ b/app/views/users/_menu.html.erb
@@ -0,0 +1,11 @@
+<%= render_list class: "nav nav-tabs" do |li|
+ li << link_to(t("users.menu.profile"), user_path(@user))
+ li << link_to(t("users.menu.topics"), topics_user_path(@user))
+ li << link_to(t("users.menu.replies"), replies_user_path(@user))
+ li << link_to(t("users.menu.favorites"), favorites_user_path(@user), class: 'hide-ios')
+ li << link_to(t("users.menu.following"), following_user_path(@user))
+ li << link_to(t("users.menu.followers"), followers_user_path(@user))
+ if owner?(@user) && current_user.block_users?
+ li << link_to(t("users.menu.blocked"), blocked_user_path(@user), class: 'hide-ios')
+ end
+end %>
diff --git a/app/views/users/_password.html.erb b/app/views/users/_password.html.erb
deleted file mode 100644
index 5d4197765..000000000
--- a/app/views/users/_password.html.erb
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
密码设置
- <%= form_for(@user) do |f| %>
- <%= render "shared/error_messages", :target => @user %>
-
- <%= f.label :password %>
- <%= f.password_field :password %>
-
-
- <%= f.label :password_confirmation %>
- <%= f.password_field :password_confirmation %>
-
-
- <%= f.submit "更改密码", :class => "button submit", :name => 'change_pwd' %>
-
- <% end %>
-
\ No newline at end of file
diff --git a/app/views/users/_profile.html.erb b/app/views/users/_profile.html.erb
deleted file mode 100644
index 48432cf26..000000000
--- a/app/views/users/_profile.html.erb
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
个人资料
- <%= form_for(@user, :html => { :enctype => "multipart/form-data" }) do |f| %>
- <%= render "shared/error_messages", :target => @user %>
-
-
<%= @user.name %>
-
-
- <%= f.label :location %>
- <%= f.text_field :location %>
-
-
- <%= f.label :avatar %>
- <%= f.file_field :avatar %>
-
-
- 预览
- <%= image_tag(@user.avatar(:large)) %>
-
-
- <%= f.label :qq %>
- <%= f.text_field :qq %>
-
-
- <%= f.label :website %>
- <%= f.text_field :website %>
-
-
- <%= f.label :tagline %>
- <%= f.text_field :tagline %>
-
-
- <%= f.label :bio %>
- <%= f.text_area :bio, :size => "20x8" %>
-
-
- <%= f.submit "保存", :class => "button submit" %>
-
- <% end %>
-
-
diff --git a/app/views/users/_recent_publish_topics.html.erb b/app/views/users/_recent_publish_topics.html.erb
new file mode 100644
index 000000000..4d5240818
--- /dev/null
+++ b/app/views/users/_recent_publish_topics.html.erb
@@ -0,0 +1,17 @@
+
+
<%= t("users.recent_publish_topic")%>
+
+
+ <%= t("common.reply_count")%>
+ <%= t("common.title")%>
+ <%= t("common.last_reply_time")%>
+
+ <% last_topics.each do |topic| %>
+
+ <%= topic.replies_count %>
+ <%= link_to(truncate(topic.title, length: 25), topic_path(topic))%>
+ <%= l((topic.replied_at || topic.created_at), format: :short) %>
+
+ <% end %>
+
+
diff --git a/app/views/users/_replies.html.erb b/app/views/users/_replies.html.erb
new file mode 100644
index 000000000..854957092
--- /dev/null
+++ b/app/views/users/_replies.html.erb
@@ -0,0 +1,16 @@
+
+ <% replies.each do |reply| %>
+ <% cache(['users', reply]) do %>
+ <% next if reply.topic.blank? %>
+
+
+ <%= link_to(reply.topic.title, topic_path(reply.topic_id)) %>
+ at <%= timeago(reply.created_at) %>
+
+
+ <%= reply.body_html %>
+
+
+ <% end %>
+ <% end %>
+
diff --git a/app/views/users/_repos.html.erb b/app/views/users/_repos.html.erb
new file mode 100644
index 000000000..5271bd481
--- /dev/null
+++ b/app/views/users/_repos.html.erb
@@ -0,0 +1,26 @@
+<% if Setting.has_module? :github %>
+
+ <% if not user.github.blank? %>
+
GitHub Public Repos
+
+ <% user.github_repositories.each do |item| %>
+
+
+ <%= link_to truncate(item[:name], length: 25), item[:url], target: "_blank", rel: "nofollow" %>
+ <%= item[:watchers] %>
+
+
+ <%= truncate(item[:description], length: 100) %>
+
+ <% end %>
+
+
+ <% else %>
+
+ 未设置 GitHub 信息.
+
+ <% end %>
+
+<% end %>
diff --git a/app/views/users/_reward.html.erb b/app/views/users/_reward.html.erb
new file mode 100644
index 000000000..05a7ce84f
--- /dev/null
+++ b/app/views/users/_reward.html.erb
@@ -0,0 +1,20 @@
+
+
+
+
+
+ <% @user.reward_fields.each_with_index do |(field, val), idx| %>
+
+ <% end %>
+
+
+
+
+
+
diff --git a/app/views/users/_sidebar.html.erb b/app/views/users/_sidebar.html.erb
new file mode 100644
index 000000000..cb5aadaab
--- /dev/null
+++ b/app/views/users/_sidebar.html.erb
@@ -0,0 +1,102 @@
+
diff --git a/app/views/users/_topics.html.erb b/app/views/users/_topics.html.erb
new file mode 100644
index 000000000..69825b7a5
--- /dev/null
+++ b/app/views/users/_topics.html.erb
@@ -0,0 +1,14 @@
+
+
+ <%=t("common.node")%>
+ <%=t("common.title")%>
+ <%=t("common.replies_count")%>/<%= t("common.likes_count") %>
+
+ <% topics.each do |topic| %>
+ <%= 'deleted' if topic.deleted? %>">
+ <%= render_node_name(topic.node_name,topic.node_id) %>
+ <%= link_to(topic.title, topic_path(topic)) %> <%= topic_excellent_tag(topic) %> <%= timeago(topic.created_at) %>
+ <%= topic.replies_count %>/<%= topic.likes_count %>
+
+ <% end %>
+
diff --git a/app/views/users/_user_list.html.erb b/app/views/users/_user_list.html.erb
new file mode 100644
index 000000000..41893f4ba
--- /dev/null
+++ b/app/views/users/_user_list.html.erb
@@ -0,0 +1,15 @@
+
+ <% users.each do |user| %>
+
+ <% end %>
+
\ No newline at end of file
diff --git a/app/views/users/blocked.html.erb b/app/views/users/blocked.html.erb
new file mode 100644
index 000000000..96535dc89
--- /dev/null
+++ b/app/views/users/blocked.html.erb
@@ -0,0 +1,17 @@
+<% title_tag [@user.fullname, t('users.menu.blocked')].join(' · ') %>
+
+
+ <%= render "sidebar", user: @user %>
+
+ <%= render "menu" %>
+
+
<%= t('users.menu.blocked') %>
+
+ <%= render "user_list", users: @block_users %>
+
+
+
+
+
diff --git a/app/views/users/city.html.erb b/app/views/users/city.html.erb
new file mode 100644
index 000000000..a2391254b
--- /dev/null
+++ b/app/views/users/city.html.erb
@@ -0,0 +1,18 @@
+<% title_tag params[:id] %>
+
+
+
+
<%= params[:id] %>的会员
+
+ <% @users.each do |item| %>
+
+
<%= user_avatar_tag(item, :md) %>
+
<%= user_name_tag(item) %>
+
+ <% end %>
+
+
+
+
diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb
deleted file mode 100644
index 44435f0ee..000000000
--- a/app/views/users/edit.html.erb
+++ /dev/null
@@ -1,2 +0,0 @@
-<% content_for :sitemap do %>
设置 <% end %>
-<%= render 'profile' %>
\ No newline at end of file
diff --git a/app/views/users/favorites.html.erb b/app/views/users/favorites.html.erb
new file mode 100644
index 000000000..08fa40664
--- /dev/null
+++ b/app/views/users/favorites.html.erb
@@ -0,0 +1,27 @@
+<% title_tag [@user.fullname, t('users.menu.favorites')].join(' · ') %>
+
+
+ <%= render "sidebar", user: @user %>
+
+ <%= render "menu" %>
+
+
+
+
+ <%= t("common.node") %>
+ <%= t("common.title") %>
+
+ <% @topics.each do |item| %>
+ ">
+ <%= render_node_name(item.node_name,item.node_id) %>
+ <%= topic_title_tag(item) %>
+
+ <% end %>
+
+
+
+
+
+
diff --git a/app/views/users/followers.html.erb b/app/views/users/followers.html.erb
new file mode 100644
index 000000000..1a8ba4783
--- /dev/null
+++ b/app/views/users/followers.html.erb
@@ -0,0 +1,16 @@
+<% title_tag [@user.fullname, t('users.menu.followers')].join(' · ') %>
+
+
+ <%= render "sidebar", user: @user %>
+
+ <%= render "menu" %>
+
+
+ <%= render "user_list", users: @users %>
+
+
+
+
+
diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb
new file mode 100644
index 000000000..138c13085
--- /dev/null
+++ b/app/views/users/index.html.erb
@@ -0,0 +1,13 @@
+<% title_tag t('users.hot_users') %>
+
+
+
+
+
<%= t("users.hot_users") %>
+
<%= t("users.current_have") %> <%= @total_user_count %> <%= t("users.users_joined") %> <%= Setting.app_name %>
+
+
+ <%= render "user_list", users: @active_users, columns: 4 %>
+
+
+
diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb
deleted file mode 100644
index 449306b12..000000000
--- a/app/views/users/new.html.erb
+++ /dev/null
@@ -1,42 +0,0 @@
-<% content_for :sitemap do %>
加入社区 <% end %>
-
-
-
加入<%= APP_CONFIG['app_name'] %>社区
- <%= form_for(@user) do |f| %>
- <%= render "shared/error_messages", :target => @user %>
-
- <%= f.label :email %>
- <%= f.text_field :email %>
-
-
- <%= f.label :name %>
- <%= f.text_field :name %>
-
注意!注册后将不可修改
-
-
- <%= f.label :location %>
- <%= f.text_field :location %>
-
-
- <%= f.label :password %>
- <%= f.password_field :password %>
-
-
- <%= f.label :password_confirmation %>
- <%= f.password_field :password_confirmation %>
-
-
- <%= f.submit "保存", :class => "button submit" %>
-
- <% end %>
-
-
-
-
diff --git a/app/views/users/password.html.erb b/app/views/users/password.html.erb
deleted file mode 100644
index a4d26fc08..000000000
--- a/app/views/users/password.html.erb
+++ /dev/null
@@ -1,2 +0,0 @@
-<% content_for :sitemap do %>
设置 <% end %>
-<%= render 'password' %>
\ No newline at end of file
diff --git a/app/views/users/replies.html.erb b/app/views/users/replies.html.erb
new file mode 100644
index 000000000..35f58f41f
--- /dev/null
+++ b/app/views/users/replies.html.erb
@@ -0,0 +1,14 @@
+<% title_tag [@user.fullname, t('users.menu.replies')].join(' · ') %>
+
+
+ <%= render "sidebar", user: @user %>
+
+ <%= render "menu" %>
+
+ <%= render "replies", replies: @replies %>
+
+
+
+
diff --git a/app/views/users/reward.js.erb b/app/views/users/reward.js.erb
new file mode 100644
index 000000000..15e750781
--- /dev/null
+++ b/app/views/users/reward.js.erb
@@ -0,0 +1,3 @@
+$('#reward-model').remove();
+$('body').append("<%= j(render('reward'))%>");
+$('#reward-modal').modal();
diff --git a/app/views/users/setting.html.erb b/app/views/users/setting.html.erb
deleted file mode 100644
index d8ad26d20..000000000
--- a/app/views/users/setting.html.erb
+++ /dev/null
@@ -1,20 +0,0 @@
-<% content_for :sitemap do %>
设置 <% end %>
-
- <%= render 'profile' %>
- <%= render 'password' %>
-
-
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
index 3710cf516..a83e9d029 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -1,63 +1,55 @@
-<% content_for :sitemap do %>
<%= @user.name %> <% end %>
-<% content_for :styles do %>
- <%= stylesheet_link_tag "users", :cache => "cached_users" %>
-<% end %>
-
-
-
-
-
-
- 大名: <%= @user.name %>
- 房号: <%= @user.location %>
- 加入时间: <%= I18n.l(@user.created_at.to_date, :format => :long) %>
- 签名: <%= @user.tagline %>
- <% if !@user.website.blank? %>
- 个人主页: <%= @user.website %>
- <% end %>
- QQ: <%= @user.qq %>
-
-
- <%= image_tag(@user.avatar(:large)) %>
-
-
-
- <% if !@user.bio.blank? %>
-
-
个人介绍
-
<%= auto_link(simple_format(@user.bio)) %>
-
- <% end %>
- <%= cache("users/show/#{params[:id]}/last_topics",:expires_in => 1.hours) do %>
-
-
最近发布的贴子
-
-
- 回复数
- 标题
- 最后回复时间
-
- <% @last_topics.each do |topic| %>
-
- <%= topic.replies_count %>
- <%= truncate(topic.title, :length => 25) %>
- <%= l (topic.replied_at || topic.created_at), :format => :short %>
-
- <% end %>
-
-
- <% end %>
-
-
-