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("
#{msg}
") + + # 成功信息显示, to 显示在那个dom前(可以用 css selector) + notice : (msg,to) -> + $(".alert").remove() + $(to).before("
#{msg}
") + + 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 "#{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\]/,''+ h(title) +'') - # 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") %> +
    +
    +
    + <%= rucaptcha_input_tag(class: 'form-control input-lg', placeholder: t('common.captcha')) %> + <%= rucaptcha_image_tag %> +
    +
    + +
    + <%= 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 %> + +
    +
    +
    + +
    +
    + <%= select_tag(:level, options_for_select([['全部 Level',''], ['Level 0', 0], ['Level 1', 1], ['Level 2', 2], ['Level 3', 3]], params[:level]), class: 'form-control') %> +
    + +
    +
    + +
    + + + + + + + + + + + + <% @applications.each do |item| %> + "> + + + + + + + + + + <% end %> +
    编号名称uidOwnerTokensLevel创建时间
    <%= 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) %> +
    + + <%= 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 %>
    • +
    • + <%= @application.uid %>
    • +
    • + <%= @application.secret %>
    • +
    • + <%= user_name_tag(@application.owner) %>
    • +
    • + <%= @application.access_tokens.count %>
    • +
    • + <%= @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 %> + + + + + + + + + +<% @comments.each do |item| %> + + + + + + + +<% end %> +
    被评论对象 + 评论人内容At
    <%= item.commentable_type %>#<%= item.commentable_id %><%= item.user.login if !item.user.blank? %><%= item.body %><%= l item.created_at, format: :short %><%= link_to '', edit_admin_comment_path(item), class: "fa fa-pencil" %> + <%= link_to '', admin_comment_path(item), 'data-confirm' => 'Are you sure?', method: :delete, remote: true, class: "fa fa-trash" %>
    +<%= 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 +
    +
    +
    + + +
    +
    +
    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 @@ +
    + +<%= simple_form_for([:admin,@location]) do |f| %> + <%= render "/shared/error_messages", target: @location %> +
    + <%= t("common.edit") %> + <%= f.input :name %> + <%= f.input :users_count %> +
    + <%= f.submit t("common.save"), class: "btn btn-primary" %> or <%= link_to t("common.cancel"), admin_locations_path %> +
    +
    +<% end %> +
    \ 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| %> + "> + + + + + + <% end %> +
    #地名会员数
    <%= item.id %><%= item.name %><%= item.users_count %> + <%= link_to "", edit_admin_location_path(item.id), class: "fa fa-pencil" %> +
    + <%= 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" %> +
    + + + + + + + + + + + +<% @nodes.each do |node| %> + + + + + + + + +<% end %> +
    #Name分类排序帖子数量
    <%= 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" %>
    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| %> + + + + + + +<% end %> +
    #预览上传者
    <%= 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" %>
    +<%= 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| %> + + + + <% if reply.topic %> + + <% else %> + + <% end %> + + + + +<% end %> +
    #回帖内容话题回复人时间
    <%= reply.id %><%= truncate(reply.body, length: 50) %> + <%= link_to(reply.topic_id, topic_path(reply.topic_id), title: reply.topic.title)%> + <%= reply.topic_id %><%= 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" %>
    +<%= 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| %> + + + + + + + +<% end %> +
    #名称排序
    <%= 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" %>
    + 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| %> +
    + + <%= f.input :value, input_html: { rows: 20, value: Setting[@site_config.var] }, label: false, class: 'form-control' %> +
    <%= t("setting.#{@site_config.var}") %>
    +
    +
    + <%= f.submit t("common.save"), class: "btn btn-primary", 'data-disable-with' => t("common.saving") %> + <%= t("common.cancel") %> +
    +<% 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") %>

    + + + + + + + + +<% Setting::KEYS_IN_ADMIN.each do |key| %> + + + + + +<% end %> +
    Key说明
    <%= key %><%= t("setting.#{key}") %><%= link_to icon_tag("pencil"), edit_admin_site_config_path(key) %>
    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| %> + + + + + + + + + + +<% end %> +
    #标题节点发帖人回帖时间
    <%= 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 %> +
    +<%= 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? %> +
    + + <% @user.authorizations.each do |auth| %> + + + + + <% end %> +
    <%= auth.provider %><%= auth.uid %>
    +
    + <% 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 %> +
    +
    +
    + +
    +
    + <%= select_tag(:type, options_for_select([['全部类别',''], ['组织','Team']], params[:type]), class: 'form-control') %> +
    + +
    +
    + + + + + + + + + + + +<% @users.each do |user| %> + + + + + + + + +<% end %> +
    #帐号姓名Email注册时间操作
    <%= 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" %> +
    +<%= 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 @@ +
    +
    <%= user_avatar_tag(item.user, :md) %>
    +
    +
    + <%= user_name_tag(item.user) %> <%= t("common.published_at") %> <%= timeago(item.created_at) %> +
    +
    + <%= item.body_html %> +
    +
    +
    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

    - - - - - - - - - - -<% @nodes.each do |node| %> - - - - - - - -<% end %> -
    NameSectionSortTopics count
    <%= 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 %>
    - -
    - -<%= 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

    - - - - - - - - - - - - -<% @photos.each do |photo| %> - - - - - - - - -<% end %> -
    TitleImage file nameImage file sizeUser
    <%= 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 %>
    -<%= 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

    - - - - - - - - - - - -<% @replies.each do |reply| %> - - - - - - - -<% end %> -
    BodyTopicUserAt
    <%= 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 %>
    -<%= 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

    - - - - - - - - -<% @sections.each do |section| %> - - - - - -<% end %> -
    NameSort
    <%= 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 %>
    - -
    - -<%= 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

    - - - - - - - - - - - - -<% @topics.each do |topic| %> - - - - - - - - - -<% end %> -
    TitleNodeUserRepliesReply userat
    <%= 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 %>
    -<%= 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

    - - - - - - - - - - - - -<% @users.each do |user| %> - - - - - - - - - -<% end %> -
    EmailNameLocationStateQQLast logined at
    <%= 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 %>
    -<%= 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.text_field :email, type: :email, class: "form-control input-lg", placeholder: t("activerecord.attributes.user.email") %> +

    此功能将会发送一个找回密码的特别链接到你的邮箱,通过改链接可以进入重置密码的页面。

    +
    +
    +
    + <%= rucaptcha_input_tag(class: 'form-control input-lg', placeholder: t('common.captcha')) %> + <%= rucaptcha_image_tag %> +
    +
    + +
    + <%= 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 @@ + \ 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' %> +
    + +
    + + + + + + + + + + + + <% @applications.each do |app| %> + + + + + + + + <% end %> + +
    <%= t('.name') %>Client IdLevel用户量
    <%= 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: "确定要删除么?" }%> +
    +
    +
    + +
    +
    + 已授权的应用 +
    + +
    +

    下面列表是已经认证的应用,它们可以访问你的帐号。

    + + + + + + + + + + <% @authorized_applications.each do |app| %> + + + + + + <% end %> + +
    <%= t('doorkeeper.authorized_applications.index.application') %><%= t('doorkeeper.authorized_applications.index.created_at') %>
    <%= 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 } %> +
    +
    +
    + + <% if @devices.length > 0 %> +
    +
    + 我的设备信息 +
    + +
    +

    下面列表是已经连接上的设备,它们将会收到 Push 通知。

    + + + + + + + + + + + <% @devices.each do |device| %> + + + + + + + <% end %> + +
    <%= t('activerecord.attributes.device.platform') %><%= t('activerecord.attributes.device.token') %><%= t('activerecord.attributes.device.alive') %>
    <%= device.platform_name %><%= device.token %><%= device.alive? %><%= link_to '删除', device, class: 'btn btn-warning btn-xs', data: { confirm: '确定要删除么?', method: :delete } %>
    +
    +
    + <% 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| %> + + + + + <% end %> +
    <%= 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' %> +
    +
    +
    + +
    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'] %>

    - -
    - <%= form_for :user_session, :url => login_create_path do |f| %> - <%= render "shared/error_messages", :target => @user_session %> -
    - <%= f.label :email, "Email:" %>
    - <%= f.text_field :email %> -
    -
    - <%= f.label :password, "密码:" %>
    - <%= f.password_field :password %> -
    -
    - <%= f.check_box :remember_me, :class => "checkbox" %><%= f.label :remember_me, "一月之内不用再次登录" %>
    -
    -

    - 注册新用户 -

    - <% end %> -
    -
    -
    - 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 @@ + 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 -%> +
      + <%= prev_page_tag unless current_page.first? %> + <% each_page do |page| -%> + <% if page.left_outer? || page.right_outer? || page.inside_window? -%> + <%= page_tag page %> + <% elsif !page.was_truncated? -%> + <%= gap_tag %> + <% end -%> + <% end -%> + <%= next_page_tag unless current_page.last? %> +
    +<% 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 @@ + 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 %> + + + +
    +
    +
    + + +
    + +
    +
    + <%= notice_message %> + <%= yield %> +
    +
    +
    + +
    +
    +

    使用 FirefoxChrome 浏览访问本站将会获得更佳的视觉体验.

    +
    +
    + + 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'] %> <%= yield :base_sitemap %> > <%= yield :sitemap %> -
    -
    - <%= 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 @@ +
    +
    +
    +
    <%= user_avatar_tag(item, :md) %>
    +
    +
    +
    + <%= link_to item.name, item %> +
    +
    + <%= markdown item.bio %> +
    +
    +
    +
    \ 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 %> +
    +
    +
    +
    <%= user_avatar_tag(item, :md) %>
    +
    +
    +
    + <%= link_to item.fullname, item %> + <%= render_user_level_tag(item) %> +
    +
    + 第 <%= item.id %> 位<%= t("menu.users")%> + • + <%= item.created_at.to_date %> + <% if item.location.present? %> • <%= location_name_tag(item.location) %><% end %> + • + <%= item.topics_count %> 篇帖子 • <%= item.replies_count %> 条回帖 +
    +
    +
    +
    +<% 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? -%> +
    + +
    + <% 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 @@ +
    +
    + <%= render_list class: "nav nav-pills nav-stacked" do |li| + li << link_to(icon_tag('user-circle', label: '基本信息'), setting_path) + li << link_to(icon_tag('address-card', label: '详细资料'), profile_setting_path, class: 'hide-ios') + li << link_to(icon_tag('qrcode', label: '打赏'), reward_setting_path) + li << link_to(icon_tag('gear', label: '账户设置'), account_setting_path, class: 'hide-ios') + li << link_to(icon_tag('keyboard-o', label: '登陆密码'), password_setting_path, class: 'hide-ios') unless Setting.sso_enabled? + li << link_to(icon_tag('cube', label: t("users.manage_my_apps")), oauth_applications_path, class: 'hide-ios') + end %> +
    +
    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 %> +
    + <%= f.label :github, class: 'control-label' %> +
    + https://github.com/ + <%= f.text_field :github, class: "form-control", placeholder: I18n.t('simple_form.placeholders.user.yourname') %> +
    +
    + <% end %> + <% if Setting.has_profile_field? :twitter %> +
    + <%= f.label :twitter, class: 'control-label' %> +
    + https://twitter.com/ + <%= f.text_field :twitter, class: "form-control", placeholder: I18n.t('simple_form.placeholders.user.yourname') %> +
    +
    + <% 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) + %> +
    + + <% if field_prefix %> +
    + <%= field_prefix %> + <% end %> + + <% if field_prefix %>
    <% end %> +
    + <% end %> + + <% if Setting.has_profile_field? :tagline %> + <%= f.input :tagline %> + <% end %> +
    + +
    + <% 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 field == :alipay %> + 打开支付宝客户端:右上角 “+” -> 我要收款 -> 设定金额 -> 保存二维码图片 + <% elsif field == :wechat %> + 打开微信手机客户端:右上角 “+” -> 收付款 -> 我要收款 -> 保存二维码图片 + <% end %> +

    +
    + + <% if @user.reward_field(field) %> +
    +
    + +
    +
    + <% end %> + <% end %> + +
    + +
    + <% 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) %> +
    +
    +
    +
    + +
    + <% 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 @@ +
    +
    + <%= user_avatar_tag(comment.user, :md) %> +
    +
    +
    + <%= user_name_tag(comment.user) %> 发表于 <%= timeago(comment.created_at) %> +
    +
    + <%= comment.body_html %> +
    +
    +
    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 !comments.blank? %> + <%= render :partial => "/shared/comment", :collection => comments %> + <% else %> +
    <%= t("comments.no_comment") %>.
    + <% end %> +
    +
    + +<% if @current_user %> +
    +
    发表新评论
    + <%= form_with(model: new_comment, remote: true, html: { class: "panel-body" }) do |f| %> + <%= f.hidden_field :commentable_type %> + <%= f.hidden_field :commentable_id %> +
    + <%= f.text_area :body, :class => "form-control", :rows => "3" %> +
    +
    + <%= f.submit t("comments.create_comment"), :class => "btn btn-primary", 'data-disable-with' => t("common.saving") %> Ctrl+Enter +
    + <% end %> +
    +<% 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 @@ +
      +
    • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
    • +
    • 支持表情,见 Emoji cheat sheet
    • +
    • 按 “M” 键查看更多 帮助
    • +
    • 注意单词拼写,以及中英文排版,参考此页 +
    + + + 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 @@ +
    +
    + + <% if Setting.has_module? 'editor.code' %> + + " href="#editor-toolbar-insert-code"> + + + <% end %> + <%= link_to(icon_tag("image"), "#", id: "editor-upload-image", rel: "twipsy", title: t("common.editor_add_image") )%> +
    + +
    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.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 @@ + 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 @@ + + + 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 %> +
    +
    +
    + <%= user_avatar_tag(@team_user.user, :sm) %> +
    +
    <%= user_name_tag(@team_user.user) %>
    +
    +
    + <% end %> + <%= f.input :role, as: :radio_buttons, collection: TeamUser.roles.keys %> + +
    + <% if @team_user.new_record? %> + <%= f.submit t("teams.new_team_user"), class: "btn btn-primary btn-block", 'data-disable-with' => t("common.saving") %> + <% else %> +
    +
    + <%= f.submit t("common.save"), class: "btn btn-primary btn-block", 'data-disable-with' => t("common.saving") %> +
    +
    + <%= link_to t('teams.delete_team_user'), + user_team_user_path(@team, @team_user), + data: { method: 'DELETE', confirm: t('teams.delete_team_user_confirm') }, + class: 'btn btn-danger btn-block' %> +
    +
    + <% end %> +
    + +<% 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| %> +
    +
    +
    <%= team_avatar_tag(team, :md) %>
    +
    +
    <%= team_name_tag(team) %>
    +
    +
    <%= team_member_counts_tag(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 %> +
    + <%= f.label :github, class: 'control-label' %> +
    + https://github.com/ + <%= f.text_field :github, class: "form-control", placeholder: I18n.t('simple_form.placeholders.user.yourname') %> +
    +
    + <% end %> + +
    + <%= f.label :twitter, class: 'control-label' %> +
    + https://twitter.com/ + <%= f.text_field :twitter, class: "form-control", placeholder: I18n.t('simple_form.placeholders.user.yourname') %> +
    +
    + <%= 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") %> + +
    +
    + <%= 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 @@ + 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.text_field :title, class: "form-control", placeholder: "在这里填写标题" %> +
    -
    - - <%= 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 %> + +
    + <%= f.submit t("common.save"), class: "btn btn-primary col-xs-2", 'data-disable-with' => t("common.saving"), 'data-tb' => "save-topic" %> + +
    <% 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 @@ + 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 %> +
    + + Command + Enter + + +
    +<% 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 %> +
    +
    + <%= user_avatar_tag(topic.user, :md) %> +
    +
    +
    + <%= link_to(main_app.topic_path(topic), title: topic.title) do %> + <%= raw content_tag(:span, topic.node_name, class: 'node') %> + <%= topic.title %> + <% end %> + <% if suggest %> + + <% end %> + <%= topic_excellent_tag(topic) %> + <%= topic_close_tag(topic) %> +
    +
    + <%= user_name_tag(topic.user) %> + + • + <% if topic.last_reply_user_login.blank? %> + <%= raw t("common.created_at", time: timeago(topic.created_at))%> + <% else %> + <%= t("common.last_by")%> <%= user_name_tag(topic.last_reply_user_login) %> <%= raw t("common.reply_at", time: timeago(topic.replied_at))%> + <% end %> + +
    +
    +
    + <% if topic.replies_count > 0 %> + <%= link_to(topic.replies_count,"#{main_app.topic_path(topic)}#reply-#{topic.last_reply_id}", class: "state-false") %> + <% end %> +
    +
    +<% 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 @@ +
    +
    +

    + <%= render_node_name(topic.node_name, topic.node_id) %> + <%= topic.title %> + <%= topic_close_tag(topic) %> +

    + <% content_for :header do %> + + <% end %> +
    + <%= user_name_tag(topic.user, data: { author: true }) %> + <% if topic.team %> + for <%= user_name_tag(topic.team) %> + <% end %> + · + <%= raw t("common.created_at", time: timeago(topic.created_at))%> + <% if !topic.last_reply_user_login.blank? %> + · + <%= t("common.last_by")%> <%= user_name_tag(topic.last_reply_user_login) %> <%= raw t("common.reply_at", time: timeago(topic.replied_at))%> + <% end %> + · + <%= topic.hits %> <%= t("common.read_times")%> +
    +
    +
    + <%= user_avatar_tag(@topic.user, :md) %> +
    +
    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 @@ +
    +
    +
    + +
    + +
    +
    + <%= topic_follow_tag(@topic, class: 'btn btn-default') %> + <%= topic_favorite_tag(@topic, class: 'btn btn-default') %> +
    +
    +
    +
    +
    + <%= social_share_button_tag h("#{@topic.title}") %> +
    +
    +
    +
    + 共收到 <%= @topic.replies_count %> 条回复 +
    + <%= reward_user_tag(@topic.user) %> +
    +
    +
    + <% 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, type: 'excellent'), 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 raw(' 编辑'), edit_topic_path(@topic), title: "修改本帖" %> + <% if can?(:destroy, @topic) %> + <%= link_to raw(' 删除'), topic_path(@topic.id), method: :delete, 'data-confirm' => t("common.confirm_delete"), title: "删除本帖", remote: true %> + <% end %> +
    + <% end %> +
    + +
    +
    + + +
    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') %>
    +
    + <%= 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| %> - - - - - - + +<% @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 } %> +
    + +
    回复数标题作者最后回复时间
    <%= topic.replies_count %><%= truncate(topic.title, :length => 25) %><%= user_name_tag(topic.user) %><%= l (topic.replied_at || topic.created_at), :format => :short %>
    - <% else %> - - <% @topics.each do |topic| %> - - - - - - <% end %> -
    - <%= 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 %> - - <% 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.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 %> -
    - <%= notice_message %> -

    回复

    - <%= form_for(Reply.new,:url => reply_topic_path(params[:id])) do |f| %> -
    - <%= f.text_area :body,:class => "long", :style => "height:80px;" %> +
    + <%= render partial: "/replies/reply", collection: @replies %>
    -
    - +
    + <% end %> + + <% if current_user %> + <% if @topic.closed? %> +
    此话题已经于 <%= l @topic.closed_at, format: :long %> 关闭,不再接受任何回帖。
    + <% else %> +
    +
    回帖
    +
    + <% if can? :create, Reply %> + <%= render 'reply_form' %> + <% else %> +
    + 当前设置新手用户不能在 22:00 ~ 9:00 发帖。 +
    + <% end %> +
    <% end %> + <% else %> +
    +
    + <%= render partial: "topics/translation/need_login_to_reply" %> +
    +
    + <% end %> + + <%= render 'related_topics' %> +
    + +<% if !mobile? %> + - <% else %> -
    -

    - 需要 登录 后回复方可回复, 如果你还没有账号 点击这里注册。 -

    -
    - <% end %> +<% else %> +
    + + +
    +<% end %>
    -<%= 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 @@ +
    +
    + 需要 <%= link_to(t("common.login"), "/account/sign_in", class: "btn btn-primary")%> 后方可回复, 如果你还没有账号请点击这里 <%= link_to("#{t("common.register")}", "/account/sign_up", class: "btn btn-danger")%>。 +
    +
    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 @@ +
    +
    + 需要 <%= link_to(t("common.login"), "/account/sign_in", class: "btn btn-primary") %> 後方可回應,如果你還沒有帳號按這裡 <%= link_to("#{t("common.register")}", "/account/sign_up", class: "btn btn-danger") %>。 +
    +
    \ 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? %> + + <% 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")%>
    + + + + + + + <% last_topics.each do |topic| %> + + + + + + <% end %> +
    <%= t("common.reply_count")%><%= t("common.title")%><%= t("common.last_reply_time")%>
    <%= topic.replies_count %> <%= link_to(truncate(topic.title, length: 25), topic_path(topic))%> <%= l((topic.replied_at || topic.created_at), format: :short) %>
    +
    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 @@ + + + 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 @@ + + + + + + + <% topics.each do |topic| %> + <%= 'deleted' if topic.deleted? %>"> + + + + + <% end %> +
    <%=t("common.node")%><%=t("common.title")%><%=t("common.replies_count")%>/<%= t("common.likes_count") %>
    <%= 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 %>
    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| %> +
    +
    +
    <%= user_avatar_tag(user, :md) %>
    +
    +
    <%= user_name_tag(user) %>
    +
    +
    <%= follow_user_tag(user, class: "") %>
    +
    +
    +
    +
    + <% 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" %> +
    +
    + + + + + + <% @topics.each do |item| %> + "> + + + + <% end %> +
    <%= t("common.node") %><%= t("common.title") %>
    <%= render_node_name(item.node_name,item.node_id) %><%= topic_title_tag(item) %>
    +
    + +
    +
    +
    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 %> -
    • <%= @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| %> - - - - - - <% end %> -
    回复数标题最后回复时间
    <%= topic.replies_count %><%= truncate(topic.title, :length => 25) %><%= l (topic.replied_at || topic.created_at), :format => :short %>
    -
    - <% end %> - -
    -