From 093747b7961de77fefb9b4409825ad05d3275987 Mon Sep 17 00:00:00 2001 From: joshua stein Date: Sat, 16 Jun 2012 20:15:46 -0500 Subject: [PATCH] initial work on conversion from php tree --- .gitignore | 15 + .rspec | 1 + Gemfile | 33 + Gemfile.lock | 114 ++ README.rdoc | 261 +++ Rakefile | 7 + app/assets/images/l.png | Bin 0 -> 268 bytes app/assets/images/rails.png | Bin 0 -> 6646 bytes app/assets/images/select2.png | Bin 0 -> 396 bytes app/assets/images/superblock.png | Bin 0 -> 213 bytes app/assets/javascripts/application.js | 149 ++ app/assets/javascripts/jquery_class.js | 64 + app/assets/javascripts/select2.js | 1578 +++++++++++++++++ app/assets/stylesheets/application.css | 866 +++++++++ app/assets/stylesheets/select2.css | 456 +++++ app/controllers/application_controller.rb | 29 + app/controllers/comments_controller.rb | 75 + app/controllers/home_controller.rb | 50 + app/controllers/login_controller.rb | 68 + app/controllers/messages_controller.rb | 107 ++ app/controllers/signup_controller.rb | 59 + app/controllers/stories_controller.rb | 225 +++ app/controllers/users_controller.rb | 51 + app/helpers/application_helper.rb | 2 + app/mailers/.gitkeep | 0 app/mailers/password_reset.rb | 11 + app/models/.gitkeep | 0 app/models/comment.rb | 58 + app/models/keystore.rb | 43 + app/models/story.rb | 186 ++ app/models/tag.rb | 2 + app/models/tagging.rb | 4 + app/models/user.rb | 40 + app/models/vote.rb | 102 ++ app/views/global/_footer.html.erb | 2 + app/views/global/_header.html.erb | 43 + app/views/home/index.html.erb | 10 + app/views/layouts/application.html.erb | 28 + app/views/login/forgot_password.html.erb | 24 + app/views/login/index.html.erb | 36 + app/views/login/set_new_password.html.erb | 32 + app/views/messages/_list.phtml | 30 + app/views/messages/compose.phtml | 23 + app/views/messages/index.phtml | 13 + app/views/messages/show.phtml | 69 + .../password_reset_link.text.erb | 7 + app/views/signup/index.html.erb | 42 + app/views/stories/_comment.html.erb | 41 + app/views/stories/_commentbox.html.erb | 23 + app/views/stories/_form.html.erb | 84 + app/views/stories/_listdetail.html.erb | 55 + app/views/stories/edit.phtml | 13 + app/views/stories/manage.phtml | 2 + app/views/stories/new.html.erb | 15 + app/views/stories/show.html.erb | 23 + app/views/users/settings.phtml | 51 + app/views/users/show.phtml | 24 + config.ru | 4 + config/application.rb | 59 + config/boot.rb | 6 + config/environment.rb | 5 + config/environments/development.rb | 37 + config/environments/production.rb | 67 + config/environments/test.rb | 37 + config/initializers/backtrace_silencers.rb | 7 + config/initializers/inflections.rb | 15 + config/initializers/mime_types.rb | 5 + config/initializers/session_store.rb | 9 + config/initializers/wrap_parameters.rb | 14 + config/locales/en.yml | 5 + config/routes.rb | 35 + db/schema.rb | 100 ++ db/seeds.rb | 7 + extras/markdowner.rb | 144 ++ extras/sponge.rb | 217 +++ extras/utils.rb | 16 + log/.gitkeep | 0 public/404.html | 26 + public/422.html | 26 + public/500.html | 25 + public/favicon.ico | Bin 0 -> 1406 bytes public/robots.txt | 5 + script/rails | 6 + spec/models/markdowner_spec.rb | 104 ++ spec/spec_helper.rb | 32 + spec/support/blueprints.rb | 7 + vendor/assets/javascripts/.gitkeep | 0 vendor/assets/stylesheets/.gitkeep | 0 vendor/plugins/.gitkeep | 0 89 files changed, 6366 insertions(+) create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 README.rdoc create mode 100644 Rakefile create mode 100644 app/assets/images/l.png create mode 100644 app/assets/images/rails.png create mode 100644 app/assets/images/select2.png create mode 100644 app/assets/images/superblock.png create mode 100644 app/assets/javascripts/application.js create mode 100644 app/assets/javascripts/jquery_class.js create mode 100644 app/assets/javascripts/select2.js create mode 100644 app/assets/stylesheets/application.css create mode 100644 app/assets/stylesheets/select2.css create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/comments_controller.rb create mode 100644 app/controllers/home_controller.rb create mode 100644 app/controllers/login_controller.rb create mode 100644 app/controllers/messages_controller.rb create mode 100644 app/controllers/signup_controller.rb create mode 100644 app/controllers/stories_controller.rb create mode 100644 app/controllers/users_controller.rb create mode 100644 app/helpers/application_helper.rb create mode 100644 app/mailers/.gitkeep create mode 100644 app/mailers/password_reset.rb create mode 100644 app/models/.gitkeep create mode 100644 app/models/comment.rb create mode 100644 app/models/keystore.rb create mode 100644 app/models/story.rb create mode 100644 app/models/tag.rb create mode 100644 app/models/tagging.rb create mode 100644 app/models/user.rb create mode 100644 app/models/vote.rb create mode 100644 app/views/global/_footer.html.erb create mode 100644 app/views/global/_header.html.erb create mode 100644 app/views/home/index.html.erb create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/login/forgot_password.html.erb create mode 100644 app/views/login/index.html.erb create mode 100644 app/views/login/set_new_password.html.erb create mode 100644 app/views/messages/_list.phtml create mode 100644 app/views/messages/compose.phtml create mode 100644 app/views/messages/index.phtml create mode 100644 app/views/messages/show.phtml create mode 100644 app/views/password_reset/password_reset_link.text.erb create mode 100644 app/views/signup/index.html.erb create mode 100644 app/views/stories/_comment.html.erb create mode 100644 app/views/stories/_commentbox.html.erb create mode 100644 app/views/stories/_form.html.erb create mode 100644 app/views/stories/_listdetail.html.erb create mode 100644 app/views/stories/edit.phtml create mode 100644 app/views/stories/manage.phtml create mode 100644 app/views/stories/new.html.erb create mode 100644 app/views/stories/show.html.erb create mode 100644 app/views/users/settings.phtml create mode 100644 app/views/users/show.phtml create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/initializers/backtrace_silencers.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/initializers/mime_types.rb create mode 100644 config/initializers/session_store.rb create mode 100644 config/initializers/wrap_parameters.rb create mode 100644 config/locales/en.yml create mode 100644 config/routes.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 extras/markdowner.rb create mode 100644 extras/sponge.rb create mode 100644 extras/utils.rb create mode 100644 log/.gitkeep create mode 100644 public/404.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/favicon.ico create mode 100644 public/robots.txt create mode 100755 script/rails create mode 100644 spec/models/markdowner_spec.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/support/blueprints.rb create mode 100644 vendor/assets/javascripts/.gitkeep create mode 100644 vendor/assets/stylesheets/.gitkeep create mode 100644 vendor/plugins/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb3489a --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. +# +# If you find yourself ignoring temporary files generated by your text editor +# or operating system, you probably want to add a global ignore instead: +# git config --global core.excludesfile ~/.gitignore_global + +# Ignore bundler config +/.bundle + +# Ignore the default SQLite database. +/db/*.sqlite3 + +# Ignore all logfiles and tempfiles. +/log/*.log +/tmp diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..38dcdb5 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--no-colour diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..9b578a7 --- /dev/null +++ b/Gemfile @@ -0,0 +1,33 @@ +source 'https://rubygems.org' + +gem 'rails', '3.2.2' + +# Bundle edge Rails instead: +# gem 'rails', :git => 'git://github.com/rails/rails.git' + +gem "mysql2" + +gem 'jquery-rails' + +# To use ActiveModel has_secure_password +gem 'bcrypt-ruby', '~> 3.0.0' + +gem "dynamic_form" + +group :test, :development do + gem "rspec-rails", "~> 2.6" + gem "machinist" + gem "sqlite3" +end + +# To use Jbuilder templates for JSON +# gem 'jbuilder' + +# Use unicorn as the app server +# gem 'unicorn' + +# Deploy with Capistrano +# gem 'capistrano' + +# To use debugger +# gem 'ruby-debug19', :require => 'ruby-debug' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..86ba7e0 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,114 @@ +GEM + remote: https://rubygems.org/ + specs: + actionmailer (3.2.2) + actionpack (= 3.2.2) + mail (~> 2.4.0) + actionpack (3.2.2) + activemodel (= 3.2.2) + activesupport (= 3.2.2) + builder (~> 3.0.0) + erubis (~> 2.7.0) + journey (~> 1.0.1) + rack (~> 1.4.0) + rack-cache (~> 1.1) + rack-test (~> 0.6.1) + sprockets (~> 2.1.2) + activemodel (3.2.2) + activesupport (= 3.2.2) + builder (~> 3.0.0) + activerecord (3.2.2) + activemodel (= 3.2.2) + activesupport (= 3.2.2) + arel (~> 3.0.2) + tzinfo (~> 0.3.29) + activeresource (3.2.2) + activemodel (= 3.2.2) + activesupport (= 3.2.2) + activesupport (3.2.2) + i18n (~> 0.6) + multi_json (~> 1.0) + arel (3.0.2) + bcrypt-ruby (3.0.1) + builder (3.0.0) + diff-lcs (1.1.3) + dynamic_form (1.1.4) + erubis (2.7.0) + hike (1.2.1) + i18n (0.6.0) + journey (1.0.3) + jquery-rails (2.0.1) + railties (>= 3.2.0, < 5.0) + thor (~> 0.14) + json (1.6.5) + machinist (2.0) + mail (2.4.4) + i18n (>= 0.4.0) + mime-types (~> 1.16) + treetop (~> 1.4.8) + mime-types (1.18) + multi_json (1.1.0) + mysql2 (0.3.11) + polyglot (0.3.3) + rack (1.4.1) + rack-cache (1.2) + rack (>= 0.4) + rack-ssl (1.3.2) + rack + rack-test (0.6.1) + rack (>= 1.0) + rails (3.2.2) + actionmailer (= 3.2.2) + actionpack (= 3.2.2) + activerecord (= 3.2.2) + activeresource (= 3.2.2) + activesupport (= 3.2.2) + bundler (~> 1.0) + railties (= 3.2.2) + railties (3.2.2) + actionpack (= 3.2.2) + activesupport (= 3.2.2) + rack-ssl (~> 1.3.2) + rake (>= 0.8.7) + rdoc (~> 3.4) + thor (~> 0.14.6) + rake (0.9.2.2) + rdoc (3.12) + json (~> 1.4) + rspec (2.9.0) + rspec-core (~> 2.9.0) + rspec-expectations (~> 2.9.0) + rspec-mocks (~> 2.9.0) + rspec-core (2.9.0) + rspec-expectations (2.9.0) + diff-lcs (~> 1.1.3) + rspec-mocks (2.9.0) + rspec-rails (2.9.0) + actionpack (>= 3.0) + activesupport (>= 3.0) + railties (>= 3.0) + rspec (~> 2.9.0) + sprockets (2.1.2) + hike (~> 1.2) + rack (~> 1.0) + tilt (~> 1.1, != 1.3.0) + sqlite3 (1.3.6) + thor (0.14.6) + tilt (1.3.3) + treetop (1.4.10) + polyglot + polyglot (>= 0.3.1) + tzinfo (0.3.32) + +PLATFORMS + ruby + +DEPENDENCIES + bcrypt-ruby (~> 3.0.0) + dynamic_form + jquery-rails + machinist + mysql2 + rails (= 3.2.2) + rspec-rails (~> 2.6) + sqlite3 diff --git a/README.rdoc b/README.rdoc new file mode 100644 index 0000000..7c36f23 --- /dev/null +++ b/README.rdoc @@ -0,0 +1,261 @@ +== Welcome to Rails + +Rails is a web-application framework that includes everything needed to create +database-backed web applications according to the Model-View-Control pattern. + +This pattern splits the view (also called the presentation) into "dumb" +templates that are primarily responsible for inserting pre-built data in between +HTML tags. The model contains the "smart" domain objects (such as Account, +Product, Person, Post) that holds all the business logic and knows how to +persist themselves to a database. The controller handles the incoming requests +(such as Save New Account, Update Product, Show Post) by manipulating the model +and directing data to the view. + +In Rails, the model is handled by what's called an object-relational mapping +layer entitled Active Record. This layer allows you to present the data from +database rows as objects and embellish these data objects with business logic +methods. You can read more about Active Record in +link:files/vendor/rails/activerecord/README.html. + +The controller and view are handled by the Action Pack, which handles both +layers by its two parts: Action View and Action Controller. These two layers +are bundled in a single package due to their heavy interdependence. This is +unlike the relationship between the Active Record and Action Pack that is much +more separate. Each of these packages can be used independently outside of +Rails. You can read more about Action Pack in +link:files/vendor/rails/actionpack/README.html. + + +== Getting Started + +1. At the command prompt, create a new Rails application: + rails new myapp (where myapp is the application name) + +2. Change directory to myapp and start the web server: + cd myapp; rails server (run with --help for options) + +3. Go to http://localhost:3000/ and you'll see: + "Welcome aboard: You're riding Ruby on Rails!" + +4. Follow the guidelines to start developing your application. You can find +the following resources handy: + +* The Getting Started Guide: http://guides.rubyonrails.org/getting_started.html +* Ruby on Rails Tutorial Book: http://www.railstutorial.org/ + + +== Debugging Rails + +Sometimes your application goes wrong. Fortunately there are a lot of tools that +will help you debug it and get it back on the rails. + +First area to check is the application log files. Have "tail -f" commands +running on the server.log and development.log. Rails will automatically display +debugging and runtime information to these files. Debugging info will also be +shown in the browser on requests from 127.0.0.1. + +You can also log your own messages directly into the log file from your code +using the Ruby logger class from inside your controllers. Example: + + class WeblogController < ActionController::Base + def destroy + @weblog = Weblog.find(params[:id]) + @weblog.destroy + logger.info("#{Time.now} Destroyed Weblog ID ##{@weblog.id}!") + end + end + +The result will be a message in your log file along the lines of: + + Mon Oct 08 14:22:29 +1000 2007 Destroyed Weblog ID #1! + +More information on how to use the logger is at http://www.ruby-doc.org/core/ + +Also, Ruby documentation can be found at http://www.ruby-lang.org/. There are +several books available online as well: + +* Programming Ruby: http://www.ruby-doc.org/docs/ProgrammingRuby/ (Pickaxe) +* Learn to Program: http://pine.fm/LearnToProgram/ (a beginners guide) + +These two books will bring you up to speed on the Ruby language and also on +programming in general. + + +== Debugger + +Debugger support is available through the debugger command when you start your +Mongrel or WEBrick server with --debugger. This means that you can break out of +execution at any point in the code, investigate and change the model, and then, +resume execution! You need to install ruby-debug to run the server in debugging +mode. With gems, use sudo gem install ruby-debug. Example: + + class WeblogController < ActionController::Base + def index + @posts = Post.all + debugger + end + end + +So the controller will accept the action, run the first line, then present you +with a IRB prompt in the server window. Here you can do things like: + + >> @posts.inspect + => "[#nil, "body"=>nil, "id"=>"1"}>, + #"Rails", "body"=>"Only ten..", "id"=>"2"}>]" + >> @posts.first.title = "hello from a debugger" + => "hello from a debugger" + +...and even better, you can examine how your runtime objects actually work: + + >> f = @posts.first + => #nil, "body"=>nil, "id"=>"1"}> + >> f. + Display all 152 possibilities? (y or n) + +Finally, when you're ready to resume execution, you can enter "cont". + + +== Console + +The console is a Ruby shell, which allows you to interact with your +application's domain model. Here you'll have all parts of the application +configured, just like it is when the application is running. You can inspect +domain models, change values, and save to the database. Starting the script +without arguments will launch it in the development environment. + +To start the console, run rails console from the application +directory. + +Options: + +* Passing the -s, --sandbox argument will rollback any modifications + made to the database. +* Passing an environment name as an argument will load the corresponding + environment. Example: rails console production. + +To reload your controllers and models after launching the console run +reload! + +More information about irb can be found at: +link:http://www.rubycentral.org/pickaxe/irb.html + + +== dbconsole + +You can go to the command line of your database directly through rails +dbconsole. You would be connected to the database with the credentials +defined in database.yml. Starting the script without arguments will connect you +to the development database. Passing an argument will connect you to a different +database, like rails dbconsole production. Currently works for MySQL, +PostgreSQL and SQLite 3. + +== Description of Contents + +The default directory structure of a generated Ruby on Rails application: + + |-- app + | |-- assets + | |-- images + | |-- javascripts + | `-- stylesheets + | |-- controllers + | |-- helpers + | |-- mailers + | |-- models + | `-- views + | `-- layouts + |-- config + | |-- environments + | |-- initializers + | `-- locales + |-- db + |-- doc + |-- lib + | `-- tasks + |-- log + |-- public + |-- script + |-- test + | |-- fixtures + | |-- functional + | |-- integration + | |-- performance + | `-- unit + |-- tmp + | |-- cache + | |-- pids + | |-- sessions + | `-- sockets + `-- vendor + |-- assets + `-- stylesheets + `-- plugins + +app + Holds all the code that's specific to this particular application. + +app/assets + Contains subdirectories for images, stylesheets, and JavaScript files. + +app/controllers + Holds controllers that should be named like weblogs_controller.rb for + automated URL mapping. All controllers should descend from + ApplicationController which itself descends from ActionController::Base. + +app/models + Holds models that should be named like post.rb. Models descend from + ActiveRecord::Base by default. + +app/views + Holds the template files for the view that should be named like + weblogs/index.html.erb for the WeblogsController#index action. All views use + eRuby syntax by default. + +app/views/layouts + Holds the template files for layouts to be used with views. This models the + common header/footer method of wrapping views. In your views, define a layout + using the layout :default and create a file named default.html.erb. + Inside default.html.erb, call <% yield %> to render the view using this + layout. + +app/helpers + Holds view helpers that should be named like weblogs_helper.rb. These are + generated for you automatically when using generators for controllers. + Helpers can be used to wrap functionality for your views into methods. + +config + Configuration files for the Rails environment, the routing map, the database, + and other dependencies. + +db + Contains the database schema in schema.rb. db/migrate contains all the + sequence of Migrations for your schema. + +doc + This directory is where your application documentation will be stored when + generated using rake doc:app + +lib + Application specific libraries. Basically, any kind of custom code that + doesn't belong under controllers, models, or helpers. This directory is in + the load path. + +public + The directory available for the web server. Also contains the dispatchers and the + default HTML files. This should be set as the DOCUMENT_ROOT of your web + server. + +script + Helper scripts for automation and generation. + +test + Unit and functional tests along with fixtures. When using the rails generate + command, template test files will be generated for you and placed in this + directory. + +vendor + External libraries that the application depends on. Also includes the plugins + subdirectory. If the app has frozen rails, those gems also go here, under + vendor/rails/. This directory is in the load path. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..6408ab5 --- /dev/null +++ b/Rakefile @@ -0,0 +1,7 @@ +#!/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__) + +Lobsters::Application.load_tasks diff --git a/app/assets/images/l.png b/app/assets/images/l.png new file mode 100644 index 0000000000000000000000000000000000000000..514a4d092607f314d1cae2e712793d86d6e2dfe0 GIT binary patch literal 268 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|*pj^6T^RN< z=rZW0#Ql>2if|TqL>4nJa0`PlBg3pY5H=O_B%`*0(yFPSFd^j6zcJGaSX9I zeRZ-S*P#Fbmw1pVcQya!6@Dsmj@#jv7C*qh zIhOJ6_K0n?*d`*T7TDuW-}m`9Kz3~>+7`DUkbAraU%yi+R{N~~XA2B%zt-4=tLimUer9!2M~N{G5bftFij_O&)a zsHnOppFIzebQ`RA0$!yUM-lg#*o@_O2wf422iLnM6cU(ktYU8#;*G!QGhIy9+ZfzKjLuZo%@a z-i@9A`X%J{^;2q&ZHY3C(B%gqCPW!8{9C0PMcNZccefK){s|V5-xxtHQc@uf>XqhD z7#N^siWqetgq29aX>G^olMf=bbRF6@Y(}zYxw6o!9WBdG1unP}<(V;zKlcR2p86fq zYjaqB^;Ycq>Wy@5T1xOzG3tucG3e%nPvajaN{CrFbnzv^9&K3$NrDm*eQe4`BGQ2bI;dFEwyt>hK%X!L6)82aOZp zsrGcJ#7PoX7)s|~t6is?FfX*7vWdREi58tiY4S)t6u*|kv?J)d_$r+CH#eZ?Ef+I_ z(eVlX8dh~4QP?o*E`_MgaNFIKj*rtN(0Raj3ECjSXcWfd#27NYs&~?t`QZFT}!Zaf=ldZIhi}LhQlqLo+o5(Pvui&{7PD__^53f9j>HW`Q z_V8X5j~$|GP9qXu0C#!@RX2}lXD35@3N5{BkUi%jtaPQ*H6OX2zIz4QPuqmTv3`vG{zc>l3t0B9E75h< z8&twGh%dp7WPNI+tRl%#gf2}Epg8st+~O4GjtwJsXfN;EjAmyr6z5dnaFU(;IV~QK zW62fogF~zA``(Q>_SmD!izc6Y4zq*97|NAPHp1j5X7Op2%;GLYm>^HEMyObo6s7l) zE3n|aOHi5~B84!}b^b*-aL2E)>OEJX_tJ~t<#VJ?bT?lDwyDB&5SZ$_1aUhmAY}#* zs@V1I+c5md9%R-o#_DUfqVtRk>59{+Opd5Yu%dAU#VQW}^m}x-30ftBx#527{^pI4 z6l2C6C7QBG$~NLYb3rVdLD#Z{+SleOp`(Lg5J}`kxdTHe(nV5BdpLrD=l|)e$gEqA zwI6vuX-PFCtcDIH>bGY2dwq&^tf+&R?)nY-@7_j%4CMRAF}C9w%p86W<2!aSY$p+k zrkFtG=cGo38RnrG28;?PNk%7a@faaXq&MS*&?1Z`7Ojw7(#>}ZG4nMAs3VXxfdW>i zY4VX02c5;f7jDPY_7@Oa)CHH}cH<3y#}_!nng^W+h1e-RL*YFYOteC@h?BtJZ+?sE zy)P5^8Mregx{nQaw1NY-|3>{Z)|0`?zc?G2-acYiSU`tj#sSGfm7k86ZQ0SQgPevcklHxM9<~4yW zR796sisf1|!#{Z=e^)0;_8iUhL8g(;j$l=02FTPZ(dZV@s#aQ`DHkLM6=YsbE4iQ!b#*374l0Jw5;jD%J;vQayq=nD8-kHI~f9Ux|32SJUM`> zGp2UGK*4t?cRKi!2he`zI#j0f${I#f-jeT?u_C7S4WsA0)ryi-1L0(@%pa^&g5x=e z=KW9+Nn(=)1T&S8g_ug%dgk*~l2O-$r9#zEGBdQsweO%t*6F4c8JC36JtTizCyy+E4h%G(+ z5>y$%0txMuQ$e~wjFgN(xrAndHQo`Za+K*?gUVDTBV&Ap^}|{w#CIq{DRe}+l@(Ec zCCV6f_?dY_{+f{}6XGn!pL_up?}@>KijT^$w#Lb6iHW&^8RP~g6y=vZBXx~B9nI^i zGexaPjcd(%)zGw!DG_dDwh-7x6+ST#R^${iz_M$uM!da8SxgB_;Z0G%Y*HpvLjKw; zX=ir7i1O$-T|*TBoH$dlW+TLf5j5sep^DlDtkox;Kg{Q%EXWedJq@J@%VAcK)j3y1 zShM!CS#qax;D@RND%2t3W6kv+#Ky0F9<3YKDbV^XJ=^$s(Vtza8V72YY)577nnldI zHMA0PUo!F3j(ubV*CM@PiK<^|RM2(DuCbG7`W}Rg(xdYC>C~ z;1KJGLN&$cRxSZunjXcntykmpFJ7;dk>shY(DdK&3K_JDJ6R%D`e~6Qv67@Rwu+q9 z*|NG{r}4F8f{Dfzt0+cZMd$fvlX3Q`dzM46@r?ISxr;9gBTG2rmfiGOD*#c*3f)cc zF+PFZobY$-^}J8 z%n=h4;x2}cP!@SiVd!v;^Wwo0(N??-ygDr7gG^NKxDjSo{5T{?$|Qo5;8V!~D6O;F*I zuY!gd@+2j_8Rn=UWDa#*4E2auWoGYDddMW7t0=yuC(xLWky?vLimM~!$3fgu!dR>p z?L?!8z>6v$|MsLb&dU?ob)Zd!B)!a*Z2eTE7 zKCzP&e}XO>CT%=o(v+WUY`Az*`9inbTG& z_9_*oQKw;sc8{ipoBC`S4Tb7a%tUE)1fE+~ib$;|(`|4QbXc2>VzFi%1nX%ti;^s3~NIL0R}!!a{0A zyCRp0F7Y&vcP&3`&Dzv5!&#h}F2R-h&QhIfq*ts&qO13{_CP}1*sLz!hI9VoTSzTu zok5pV0+~jrGymE~{TgbS#nN5+*rF7ij)cnSLQw0Ltc70zmk|O!O(kM<3zw-sUvkx~ z2`y+{xAwKSa-0}n7{$I@Zop7CWy%_xIeN1e-7&OjQ6vZZPbZ^3_ z(~=;ZSP98S2oB#35b1~_x`2gWiPdIVddEf`AD9<@c_s)TM;3J$T_l?pr{<7PTgdiy zBc5IGx)g~n=s+Z$RzYCmv8PlJu%gkh^;%mTGMc)UwRINVD~K;`Rl!5@hhGg;y>5qj zq|u-Yf0q_~Y+Mbivkkfa0nAOzB1acnytogsj_m7FB(-FjihMek#GAU4M!iXCgdK8a zjoKm?*|iz7;dHm4$^hh(`Ufl>yb>$hjIA-;>{>C}G0Di%bGvUsJkfLAV|xq32c>RqJqTBJ3Dx zYC;*Dt|S$b6)aCJFnK(Eey$M1DpVV~_MIhwK> zygo(jWC|_IRw|456`roEyXtkNLWNAt-4N1qyN$I@DvBzt;e|?g<*HK1%~cq|^u*}C zmMrwh>{QAq?Ar~4l^DqT%SQ)w)FA(#7#u+N;>E975rYML>)LgE`2<7nN=C1pC{IkV zVw}_&v6j&S?QVh*)wF3#XmE@0($^BVl1969csLKUBNer{suVd!a~B!0MxWY?=(GD6 zy$G&ERFR#i6G4=2F?R4}Mz3B?3tnpoX3)qFF2sh9-Jn*e%9F>i{WG7$_~XyOO2!+@ z6k+38KyD@-0=uee54D0!Z1@B^ilj~StchdOn(*qvg~s5QJpWGc!6U^Aj!xt-HZn_V zS%|fyQ5YS@EP2lBIodXCLjG_+a)%En+7jzngk@J>6D~^xbxKkvf-R0-c%mX+o{?&j zZZ%RxFeav8Y0gkwtdtrwUb-i0Egd2C=ADu%w5VV-hNJvl)GZ?M;y$!?b=S+wKRK7Q zcOjPT!p<*#8m;TsBih=@Xc&c)?Vy`Ys>IvK@|1%N+M6J-^RCRaZcPP2eQh9DEGZr+ z?8B~wF14mk4Xkuen{wY^CWwS1PI<8gikY*)3?RSo5l8es4*J z43k_BIwc}of=6Pfs%xIxlMDGOJN zvl!a>G)52XMqA%fbgkZi%)%bN*ZzZw2!rn4@+J)2eK#kWuEW{)W~-`y1vhA5-7p%R z&f5N!a9f8cK1Xa=O}=9{wg%}Ur^+8Y(!UCeqw>%wj@|bYHD-bZO~mk3L$9_^MmF3G zvCiK^e@q6G?tHkM8%GqsBMZaB20W$UEt_5r~jc#WlR>Bv{6W>A=!#InoY zLOd04@Rz?*7PpW8u|+}bt`?+Z(GsX{Br4A2$ZZ(26Degmr9`O=t2KgHTL*==R3xcP z&Y(J7hC@6_x8zVz!CX3l4Xtss6i7r#E6kXMNN1~>9KTRzewfp))ij%)SBBl0fZdYP zd!zzQD5u8yk-u|41|Rqz7_tCFUMThZJVj)yQf6^Cwtn|Ew6cm5J|u1Bq>MWX-AfB&NE;C z62@=-0le`E6-CurMKjoIy)BuUmhMGJb}pPx!@GLWMT+wH2R?wA=MEy)o57~feFp8P zY@YXAyt4<1FD<|iw{FGQu~GEI<4C64)V*QiVk+VzOV^9GWf4ir#oYgHJz!wq>iZV#_6@_{)&lum)4x z_Of*CLVQ7wdT#XT-(h0qH%mcIF7yzMIvvTN3bPceK>PpJi(=3Nny zbSn}p$dGKQUlX&-t~RR)#F7I<8NCD^yke(vdf#4^aAh}M-{tS9-&^tC4`KU_pToXy z+|K8sx}a)Kh{h{;*V1#hs1xB%(?j>)g~`Wv(9F)f=Qn)(daVB7hZtcp^#LrEr1T1J zZSJ*lVyVVjhy)mkex9Whn=EinKDHe@KlfQI-Fl7M?-c~HnW0;C;+MbUY8?FToy;A+ zs&Nc7VZ=Of+e!G6s#+S5WBU)kgQq_I1@!uH74GJ-+O|%0HXm9Mqlvp|j%0`T>fr9^ zK;qo>XdwZW<>%tTA+<(1^6(>=-2N;hRgBnjvEjN;VbKMbFg--WrGy|XESoH1p|M4` z86(gC^vB4qScASZ&cdpT{~QDN-jC|GJ(RYoW1VW4!SSn- zhQds9&RBKn6M&GVK_Aayt(Hekbnw=tr>f z^o@v9_*iQO1*zeOrts9Q-$pc@!StS&kz$cF`s@pM`rmJXTP&h5G)A74!0e%ZJbl}( zssI|_!%~_hZFypv*S^JE5N&Kvmx7KiG<|fGMO=WrH+@Yhuj+KwiS#l4>@%2nl zS)mDikfmokO4q2A)hRVZBq2-5q&XC>%HOLkOYxZ66(s86?=0s4z5xbiOV)}L-&6b)h6(~CIaR#JNw~46+WBiU7IhB zq!NuR4!TsYnyBg>@G=Ib*cMq^k<}AMpCeYEf&dzfiGI-wOQ7hb+nA zkN7_){y&c3xC0 AQ~&?~ literal 0 HcmV?d00001 diff --git a/app/assets/images/select2.png b/app/assets/images/select2.png new file mode 100644 index 0000000000000000000000000000000000000000..d08e4b7e624c44f4fb862f23f046262780847490 GIT binary patch literal 396 zcmV;70dxL|P)~c7VWO z{Q}IDn{i!TF4#od;X3Q_#kIK3wRl==hwEDRz~*jyTo-K>u-u(nATT<+akgC{FyDRG qFW$B*q*ia+6;i9W9%5_PVDkwXFTX6NOX?H=0000P9+Qs1M>gTe~DWM4f3P?+^ literal 0 HcmV?d00001 diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js new file mode 100644 index 0000000..19aa279 --- /dev/null +++ b/app/assets/javascripts/application.js @@ -0,0 +1,149 @@ +//= require jquery +//= require jquery_ujs +//= require_tree . + +"use strict"; + +var _Lobsters = Class.extend({ + commentDownvoteReasons: { "": "Cancel" }, + storyDownvoteReasons: { "": "Cancel" }, + + upvote: function(story_id) { + Lobsters.vote("story", story_id, 1); + }, + downvote: function(story_id) { + Lobsters._showDownvoteWhyAt("#story_downvoter_" + story_id, + function(k) { Lobsters.vote('story', story_id, -1, k); }); + }, + + upvoteComment: function(comment_id) { + Lobsters.vote("comment", comment_id, 1); + }, + downvoteComment: function(comment_id) { + Lobsters._showDownvoteWhyAt("#comment_downvoter_" + comment_id, + function(k) { Lobsters.vote('comment', comment_id, -1, k); }); + }, + + _showDownvoteWhyAt: function(el, onChooseWhy) { + if ($("#downvote_why")) + $("#downvote_why").remove(); + + var d = $("
"); + + var reasons; + if ($(el).attr("id").match(/comment/)) + reasons = Lobsters.commentDownvoteReasons; + else + reasons = Lobsters.storyDownvoteReasons; + + $.each(reasons, function(k, v) { + var a = $("" + v + ""); + + a.click(function() { + $('#downvote_why').remove(); + + if (k != "") + onChooseWhy(k); + + return false; + }); + + d.append(a); + }); + + $(el).after(d); + + d.position({ + my: "left top", + at: "left bottom", + offset: "-2 -2", + of: $(el), + collision: "none", + }); + }, + + vote: function(thing_type, thing_id, point, reason) { + var li = $("#" + thing_type + "_" + thing_id); + var score_d = li.find("div.score").get(0); + var score = parseInt(score_d.innerHTML); + var action = ""; + + if (li.hasClass("upvoted") && point > 0) { + /* already upvoted, neutralize */ + li.removeClass("upvoted"); + score--; + action = "unvote"; + } + else if (li.hasClass("downvoted") && point < 0) { + /* already downvoted, neutralize */ + li.removeClass("downvoted"); + score++; + action = "unvote"; + } + else if (point > 0) { + if (li.hasClass("downvoted")) + /* flip flop */ + score++; + + li.removeClass("downvoted").addClass("upvoted"); + score++; + action = "upvote"; + } + else if (point < 0) { + if (li.hasClass("upvoted")) + /* flip flop */ + score--; + + li.removeClass("upvoted").addClass("downvoted"); + score--; + action = "downvote"; + } + + score_d.innerHTML = score; + + $.post("/" + (thing_type == "story" ? "stories" : + thing_type + "s") + "/" + thing_id + "/" + action, + { why: reason }); + }, + + postComment: function(form) { + $(form).load($(form).attr("action"), $(form).serializeArray()); + }, + + previewComment: function(form) { + $(form).load($(form).attr("action").replace(/^\/comments/, + "/comments/preview"), $(form).serializeArray()); + }, + + fetchURLTitle: function(button, url_field, title_field) { + if (url_field.val() == "") + return; + + var old_value = button.val(); + button.prop("disabled", true); + button.val("Fetching..."); + + $.post("/stories/fetch_url_title", { + fetch_url: url_field.val(), + }) + .success(function(data) { + if (data && data.title) + title_field.val(data.title.substr(0, + title_field.maxLength)); + + button.val(old_value); + button.prop("disabled", false); + }) + .error(function() { + button.val(old_value); + button.prop("disabled", false); + }); + }, +}); + +var Lobsters = new _Lobsters(); + +/* FIXME */ +/* $(document).click(function() { + $("#downvote_why").remove(); +}); */ diff --git a/app/assets/javascripts/jquery_class.js b/app/assets/javascripts/jquery_class.js new file mode 100644 index 0000000..93e1aec --- /dev/null +++ b/app/assets/javascripts/jquery_class.js @@ -0,0 +1,64 @@ +/* Simple JavaScript Inheritance + * By John Resig http://ejohn.org/ + * MIT Licensed. + */ +// Inspired by base2 and Prototype +(function(){ + var initializing = false, fnTest = /xyz/.test(function(){xyz;}) ? /\b_super\b/ : /.*/; + + // The base Class implementation (does nothing) + this.Class = function(){}; + + // Create a new Class that inherits from this class + Class.extend = function(prop) { + var _super = this.prototype; + + // Instantiate a base class (but only create the instance, + // don't run the init constructor) + initializing = true; + var prototype = new this(); + initializing = false; + + // Copy the properties over onto the new prototype + for (var name in prop) { + // Check if we're overwriting an existing function + prototype[name] = typeof prop[name] == "function" && + typeof _super[name] == "function" && fnTest.test(prop[name]) ? + (function(name, fn){ + return function() { + var tmp = this._super; + + // Add a new ._super() method that is the same method + // but on the super-class + this._super = _super[name]; + + // The method only need to be bound temporarily, so we + // remove it when we're done executing + var ret = fn.apply(this, arguments); + this._super = tmp; + + return ret; + }; + })(name, prop[name]) : + prop[name]; + } + + // The dummy class constructor + function Class() { + // All construction is actually done in the init method + if ( !initializing && this.init ) + this.init.apply(this, arguments); + } + + // Populate our constructed prototype object + Class.prototype = prototype; + + // Enforce the constructor to be what we expect + Class.prototype.constructor = Class; + + // And make this class extendable + Class.extend = arguments.callee; + + return Class; + }; +})(); diff --git a/app/assets/javascripts/select2.js b/app/assets/javascripts/select2.js new file mode 100644 index 0000000..e2a8db5 --- /dev/null +++ b/app/assets/javascripts/select2.js @@ -0,0 +1,1578 @@ +/* + Copyright 2012 Igor Vaynberg + + Version: @@ver@@ Timestamp: @@timestamp@@ + + Licensed under the Apache License, Version 2.0 (the "License"); you may not use this work except in + compliance with the License. You may obtain a copy of the License in the LICENSE file, or at: + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under the License is + distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and limitations under the License. + */ +(function ($, undefined) { + "use strict"; + /*global document, window, jQuery, console */ + + if (window.Select2 !== undefined) { + return; + } + + var KEY, AbstractSelect2, SingleSelect2, MultiSelect2; + + KEY = { + TAB: 9, + ENTER: 13, + ESC: 27, + SPACE: 32, + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40, + SHIFT: 16, + CTRL: 17, + ALT: 18, + PAGE_UP: 33, + PAGE_DOWN: 34, + HOME: 36, + END: 35, + BACKSPACE: 8, + DELETE: 46, + isArrow: function (k) { + k = k.which ? k.which : k; + switch (k) { + case KEY.LEFT: + case KEY.RIGHT: + case KEY.UP: + case KEY.DOWN: + return true; + } + return false; + }, + isControl: function (k) { + k = k.which ? k.which : k; + switch (k) { + case KEY.SHIFT: + case KEY.CTRL: + case KEY.ALT: + return true; + } + return false; + }, + isFunctionKey: function (k) { + k = k.which ? k.which : k; + return k >= 112 && k <= 123; + } + }; + + function indexOf(value, array) { + var i = 0, l = array.length, v; + + if (typeof value == 'undefined') { + return -1; + } + + if (value.constructor === String) { + for (; i < l; i = i + 1) if (value.localeCompare(array[i]) === 0) return i; + } else { + for (; i < l; i = i + 1) { + v = array[i]; + if (v.constructor === String) { + if (v.localeCompare(value) === 0) return i; + } else { + if (v === value) return i; + } + } + } + return -1; + } + + /** + * Compares equality of a and b taking into account that a and b may be strings, in which case localCompare is used + * @param a + * @param b + */ + function equal(a, b) { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + if (a === null || b === null) return false; + if (a.constructor === String) return a.localeCompare(b) === 0; + if (b.constructor === String) return b.localeCompare(a) === 0; + return false; + } + + /** + * Splits the string into an array of values, trimming each value. An empty array is returned for nulls or empty + * strings + * @param string + * @param separator + */ + function splitVal(string, separator) { + var val, i, l; + if (string === null || string.length < 1) return []; + val = string.split(separator); + for (i = 0, l = val.length; i < l; i = i + 1) val[i] = $.trim(val[i]); + return val; + } + + function getSideBorderPadding(element) { + return element.outerWidth() - element.width(); + } + + function installKeyUpChangeEvent(element) { + element.bind("keydown", function () { + element.data("keyup-change-value", element.val()); + }); + element.bind("keyup", function () { + if (element.val() !== element.data("keyup-change-value")) { + element.trigger("keyup-change"); + } + }); + } + + /** + * filters mouse events so an event is fired only if the mouse moved. + * + * filters out mouse events that occur when mouse is stationary but + * the elements under the pointer are scrolled. + */ + $(document).delegate("*", "mousemove", function (e) { + $(document).data("select2-lastpos", {x: e.pageX, y: e.pageY}); + }); + function installFilteredMouseMove(element) { + element.bind("mousemove", function (e) { + var lastpos = $(document).data("select2-lastpos"); + if (lastpos === undefined || lastpos.x !== e.pageX || lastpos.y !== e.pageY) { + $(e.target).trigger("mousemove-filtered", e); + } + }); + } + + /** + * Debounces a function. Returns a function that calls the original fn function only if no invocations have been made + * within the last quietMillis milliseconds. + * + * @param quietMillis number of milliseconds to wait before invoking fn + * @param fn function to be debounced + * @return debounced version of fn + */ + function debounce(quietMillis, fn) { + var timeout; + return function () { + window.clearTimeout(timeout); + timeout = window.setTimeout(fn, quietMillis); + }; + } + + function installDebouncedScroll(threshold, element) { + var notify = debounce(threshold, function (e) { element.trigger("scroll-debounced", e);}); + element.bind("scroll", function (e) { + if (indexOf(e.target, element.get()) >= 0) notify(e); + }); + } + + function killEvent(event) { + event.preventDefault(); + event.stopPropagation(); + } + + function measureTextWidth(e) { + var sizer, width; + sizer = $("
").css({ + position: "absolute", + left: "-1000px", + top: "-1000px", + display: "none", + fontSize: e.css("fontSize"), + fontFamily: e.css("fontFamily"), + fontStyle: e.css("fontStyle"), + fontWeight: e.css("fontWeight"), + letterSpacing: e.css("letterSpacing"), + textTransform: e.css("textTransform"), + whiteSpace: "nowrap" + }); + sizer.text(e.val()); + $("body").append(sizer); + width = sizer.width(); + sizer.remove(); + return width; + } + + /** + * Produces an ajax-based query function + * + * @param options object containing configuration paramters + * @param options.transport function that will be used to execute the ajax request. must be compatible with parameters supported by $.ajax + * @param options.url url for the data + * @param options.data a function(searchTerm, pageNumber, context) that should return an object containing query string parameters for the above url. + * @param options.dataType request data type: ajax, jsonp, other datatatypes supported by jQuery's $.ajax function or the transport function if specified + * @param options.quietMillis (optional) milliseconds to wait before making the ajaxRequest, helps debounce the ajax function if invoked too often + * @param options.results a function(remoteData, pageNumber) that converts data returned form the remote request to the format expected by Select2. + * The expected format is an object containing the following keys: + * results array of objects that will be used as choices + * more (optional) boolean indicating whether there are more results available + * Example: {results:[{id:1, text:'Red'},{id:2, text:'Blue'}], more:true} + */ + function ajax(options) { + var timeout, // current scheduled but not yet executed request + requestSequence = 0, // sequence used to drop out-of-order responses + handler = null, + quietMillis = options.quietMillis || 100; + + return function (query) { + window.clearTimeout(timeout); + timeout = window.setTimeout(function () { + requestSequence += 1; // increment the sequence + var requestNumber = requestSequence, // this request's sequence number + data = options.data, // ajax data function + transport = options.transport || $.ajax; + + data = data.call(this, query.term, query.page, query.context); + + if( null !== handler){ + handler.abort(); + } + handler = transport.call(null, { + url: options.url, + dataType: options.dataType, + data: data, + success: function (data) { + if (requestNumber < requestSequence) { + return; + } + // TODO 3.0 - replace query.page with query so users have access to term, page, etc. + var results = options.results(data, query.page); + self.context = results.context; + query.callback(results); + } + }); + }, quietMillis); + }; + } + + /** + * Produces a query function that works with a local array + * + * @param options object containing configuration parameters. The options parameter can either be an array or an + * object. + * + * If the array form is used it is assumed that it contains objects with 'id' and 'text' keys. + * + * If the object form is used ti is assumed that it contains 'data' and 'text' keys. The 'data' key should contain + * an array of objects that will be used as choices. These objects must contain at least an 'id' key. The 'text' + * key can either be a String in which case it is expected that each element in the 'data' array has a key with the + * value of 'text' which will be used to match choices. Alternatively, text can be a function(item) that can extract + * the text. + */ + function local(options) { + var data = options, // data elements + text = function (item) { return ""+item.text; }; // function used to retrieve the text portion of a data item that is matched against the search + + if (!$.isArray(data)) { + text = data.text; + // if text is not a function we assume it to be a key name + if (!$.isFunction(text)) text = function (item) { return item[data.text]; }; + data = data.results; + } + + return function (query) { + var t = query.term, filtered = {}; + if (t === "") { + query.callback({results: data}); + return; + } + filtered.results = $(data) + .filter(function () {return query.matcher(t, text(this));}) + .get(); + query.callback(filtered); + }; + } + + // TODO javadoc + function tags(data) { + // TODO even for a function we should probably return a wrapper that does the same object/string check as + // the function for arrays. otherwise only functions that return objects are supported. + if ($.isFunction(data)) { + return data; + } + + // if not a function we assume it to be an array + + return function (query) { + var t = query.term, filtered = {results: []}; + $(data).each(function () { + var isObject = this.text !== undefined, + text = isObject ? this.text : this; + if (t === "" || query.matcher(t, text)) { + filtered.results.push(isObject ? this : {id: this, text: this}); + } + }); + query.callback(filtered); + }; + } + + /** + * blurs any Select2 container that has focus when an element outside them was clicked or received focus + */ + $(document).ready(function () { + $(document).delegate("*", "mousedown focusin touchend", function (e) { + var target = $(e.target).closest("div.select2-container").get(0); + $(document).find("div.select2-container-active").each(function () { + if (this !== target) $(this).data("select2").blur(); + }); + }); + }); + + /** + * Creates a new class + * + * @param superClass + * @param methods + */ + function clazz(SuperClass, methods) { + var constructor = function () {}; + constructor.prototype = new SuperClass; + constructor.prototype.constructor = constructor; + constructor.prototype.parent = SuperClass.prototype; + constructor.prototype = $.extend(constructor.prototype, methods); + return constructor; + } + + AbstractSelect2 = clazz(Object, { + + bind: function (func) { + var self = this; + return function () { + func.apply(self, arguments); + }; + }, + + init: function (opts) { + var results, search, resultsSelector = ".select2-results"; + + // prepare options + this.opts = opts = this.prepareOpts(opts); + + this.id=opts.id; + + // destroy if called on an existing component + if (opts.element.data("select2") !== undefined && + opts.element.data("select2") !== null) { + this.destroy(); + } + + this.enabled=true; + this.container = this.createContainer(); + + if (opts.element.attr("class") !== undefined) { + this.container.addClass(opts.element.attr("class")); + } + + // swap container for the element + this.opts.element + .data("select2", this) + .hide() + .after(this.container); + this.container.data("select2", this); + + this.dropdown = this.container.find(".select2-drop"); + this.results = results = this.container.find(resultsSelector); + this.search = search = this.container.find("input[type=text]"); + + this.resultsPage = 0; + this.context = null; + + // initialize the container + this.initContainer(); + + installFilteredMouseMove(this.results); + this.container.delegate(resultsSelector, "mousemove-filtered", this.bind(this.highlightUnderEvent)); + + installDebouncedScroll(80, this.results); + this.container.delegate(resultsSelector, "scroll-debounced", this.bind(this.loadMoreIfNeeded)); + + // if jquery.mousewheel plugin is installed we can prevent out-of-bounds scrolling of results via mousewheel + if ($.fn.mousewheel) { + results.mousewheel(function (e, delta, deltaX, deltaY) { + var top = results.scrollTop(), height; + if (deltaY > 0 && top - deltaY <= 0) { + results.scrollTop(0); + killEvent(e); + } else if (deltaY < 0 && results.get(0).scrollHeight - results.scrollTop() + deltaY <= results.height()) { + results.scrollTop(results.get(0).scrollHeight - results.height()); + killEvent(e); + } + }); + } + + installKeyUpChangeEvent(search); + search.bind("keyup-change", this.bind(this.updateResults)); + search.bind("focus", function () { search.addClass("select2-focused");}); + search.bind("blur", function () { search.removeClass("select2-focused");}); + + this.container.delegate(resultsSelector, "click", this.bind(function (e) { + if ($(e.target).closest(".select2-result:not(.select2-disabled)").length > 0) { + this.highlightUnderEvent(e); + this.selectHighlighted(e); + } else { + killEvent(e); + this.focusSearch(); + } + })); + + if ($.isFunction(this.opts.initSelection)) { + // initialize selection based on the current value of the source element + this.initSelection(); + + // if the user has provided a function that can set selection based on the value of the source element + // we monitor the change event on the element and trigger it, allowing for two way synchronization + this.monitorSource(); + } + + if (opts.element.is(":disabled")) this.disable(); + }, + + destroy: function () { + var select2 = this.opts.element.data("select2"); + if (select2 !== undefined) { + select2.container.remove(); + select2.opts.element + .removeData("select2") + .unbind(".select2") + .show(); + } + }, + + prepareOpts: function (opts) { + var element, select, idKey; + + element = opts.element; + + if (element.get(0).tagName.toLowerCase() === "select") { + this.select = select = opts.element; + } + + if (select) { + // these options are not allowed when attached to a select because they are picked up off the element itself + $.each(["id", "multiple", "ajax", "query", "createSearchChoice", "initSelection", "data", "tags"], function () { + if (this in opts) { + throw new Error("Option '" + this + "' is not allowed for Select2 when attached to a " , + " " , + "
    " , + "
" , + ""].join("")); + }, + + open: function () { + + if (this.opened()) return; + + this.parent.open.apply(this, arguments); + + }, + + close: function () { + if (!this.opened()) return; + this.parent.close.apply(this, arguments); + }, + + focus: function () { + this.close(); + this.selection.focus(); + }, + + isFocused: function () { + return this.selection.is(":focus"); + }, + + cancel: function () { + this.parent.cancel.apply(this, arguments); + this.selection.focus(); + }, + + initContainer: function () { + + var selection, container = this.container, clickingInside = false, + selector = ".select2-choice"; + + this.selection = selection = container.find(selector); + + this.search.bind("keydown", this.bind(function (e) { + switch (e.which) { + case KEY.UP: + case KEY.DOWN: + this.moveHighlight((e.which === KEY.UP) ? -1 : 1); + killEvent(e); + return; + case KEY.TAB: + case KEY.ENTER: + this.selectHighlighted(); + killEvent(e); + return; + case KEY.ESC: + this.cancel(e); + e.preventDefault(); + return; + } + })); + + container.delegate(selector, "click", this.bind(function (e) { + clickingInside = true; + + if (this.opened()) { + this.close(); + selection.focus(); + } else if (this.enabled) { + this.open(); + } + e.preventDefault(); + + clickingInside = false; + })); + container.delegate(selector, "keydown", this.bind(function (e) { + if (!this.enabled || e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) || e.which === KEY.ESC) { + return; + } + this.open(); + if (e.which === KEY.PAGE_UP || e.which === KEY.PAGE_DOWN || e.which === KEY.SPACE) { + // prevent the page from scrolling + killEvent(e); + } + if (e.which === KEY.ENTER) { + // do not propagate the event otherwise we open, and propagate enter which closes + killEvent(e); + } + })); + container.delegate(selector, "focus", function () { if (this.enabled) container.addClass("select2-container-active"); }); + container.delegate(selector, "blur", this.bind(function () { + if (clickingInside) return; + if (!this.opened()) this.blur(); + })); + + selection.delegate("abbr", "click", this.bind(function (e) { + if (!this.enabled) return; + this.val(""); + killEvent(e); + this.close(); + this.triggerChange(); + })); + + this.setPlaceholder(); + }, + + /** + * Sets selection based on source element's value + */ + initSelection: function () { + var selected; + if (this.opts.element.val() === "") { + this.updateSelection({id: "", text: ""}); + } else { + selected = this.opts.initSelection.call(null, this.opts.element); + if (selected !== undefined && selected !== null) { + this.updateSelection(selected); + } + } + + this.close(); + this.setPlaceholder(); + }, + + prepareOpts: function () { + var opts = this.parent.prepareOpts.apply(this, arguments); + + if (opts.element.get(0).tagName.toLowerCase() === "select") { + // install sthe selection initializer + opts.initSelection = function (element) { + var selected = element.find(":selected"); + // a single select box always has a value, no need to null check 'selected' + return {id: selected.attr("value"), text: selected.text()}; + }; + } + + return opts; + }, + + setPlaceholder: function () { + var placeholder = this.getPlaceholder(); + + if (this.opts.element.val() === "" && placeholder !== undefined) { + + // check for a first blank option if attached to a select + if (this.select && this.select.find("option:first").text() !== "") return; + + if (typeof(placeholder) === "object") { + this.updateSelection(placeholder); + } else { + this.selection.find("span").html(placeholder); + } + this.selection.addClass("select2-default"); + + this.selection.find("abbr").hide(); + } + }, + + postprocessResults: function (data, initial) { + var selected = 0, self = this, showSearchInput = true; + + // find the selected element in the result list + + this.results.find(".select2-result").each(function (i) { + if (equal(self.id($(this).data("select2-data")), self.opts.element.val())) { + selected = i; + return false; + } + }); + + // and highlight it + + this.highlight(selected); + + // hide the search box if this is the first we got the results and there are a few of them + + if (initial === true) { + showSearchInput = this.showSearchInput = data.results.length >= this.opts.minimumResultsForSearch; + this.container.find(".select2-search")[showSearchInput ? "removeClass" : "addClass"]("select2-search-hidden"); + + //add "select2-with-searchbox" to the container if search box is shown + this.container[showSearchInput ? "addClass" : "removeClass"]("select2-with-searchbox"); + } + + }, + + onSelect: function (data) { + var old = this.opts.element.val(); + + this.opts.element.val(this.id(data)); + this.updateSelection(data); + this.close(); + this.selection.focus(); + + if (!equal(old, this.id(data))) { this.triggerChange(); } + }, + + updateSelection: function (data) { + this.selection + .find("span") + .html(this.opts.formatSelection(data)); + + this.selection.removeClass("select2-default"); + + if (this.opts.allowClear && this.getPlaceholder() !== undefined) { + this.selection.find("abbr").show(); + } + }, + + val: function () { + var val, data = null; + + if (arguments.length === 0) { + return this.opts.element.val(); + } + + val = arguments[0]; + + if (this.select) { + // val is an id + this.select + .val(val) + .find(":selected").each(function () { + data = {id: $(this).attr("value"), text: $(this).text()}; + return false; + }); + this.updateSelection(data); + } else { + // val is an object. !val is true for [undefined,null,''] + this.opts.element.val(!val ? "" : this.id(val)); + this.updateSelection(val); + } + this.setPlaceholder(); + + }, + + clearSearch: function () { + this.search.val(""); + } + }); + + MultiSelect2 = clazz(AbstractSelect2, { + + createContainer: function () { + return $("
", { + "class": "select2-container select2-container-multi", + "style": "width: " + this.getContainerWidth() + }).html([ + "
    ", + //"
  • California
  • " , + "
  • " , + " " , + "
  • " , + "
" , + ""].join("")); + }, + + prepareOpts: function () { + var opts = this.parent.prepareOpts.apply(this, arguments); + + opts = $.extend({}, { + closeOnSelect: true + }, opts); + + // TODO validate placeholder is a string if specified + + if (opts.element.get(0).tagName.toLowerCase() === "select") { + // install sthe selection initializer + opts.initSelection = function (element) { + var data = []; + element.find(":selected").each(function () { + data.push({id: $(this).attr("value"), text: $(this).text()}); + }); + return data; + }; + } + + return opts; + }, + + initContainer: function () { + + var selector = ".select2-choices", selection; + + this.searchContainer = this.container.find(".select2-search-field"); + this.selection = selection = this.container.find(selector); + + this.search.bind("keydown", this.bind(function (e) { + if (!this.enabled) return; + + if (e.which === KEY.BACKSPACE && this.search.val() === "") { + this.close(); + + var choices, + selected = selection.find(".select2-search-choice-focus"); + if (selected.length > 0) { + this.unselect(selected.first()); + this.search.width(10); + killEvent(e); + return; + } + + choices = selection.find(".select2-search-choice"); + if (choices.length > 0) { + choices.last().addClass("select2-search-choice-focus"); + } + } else { + selection.find(".select2-search-choice-focus").removeClass("select2-search-choice-focus"); + } + + if (this.opened()) { + switch (e.which) { + case KEY.UP: + case KEY.DOWN: + this.moveHighlight((e.which === KEY.UP) ? -1 : 1); + killEvent(e); + return; + case KEY.ENTER: + case KEY.TAB: + this.selectHighlighted(); + killEvent(e); + return; + case KEY.ESC: + this.cancel(e); + e.preventDefault(); + return; + } + } + + if (e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) || e.which === KEY.BACKSPACE || e.which === KEY.ESC) { + return; + } + + this.open(); + + if (e.which === KEY.PAGE_UP || e.which === KEY.PAGE_DOWN) { + // prevent the page from scrolling + killEvent(e); + } + })); + + this.search.bind("keyup", this.bind(this.resizeSearch)); + + this.container.delegate(selector, "click", this.bind(function (e) { + if (!this.enabled) return; + this.open(); + this.focusSearch(); + e.preventDefault(); + })); + + this.container.delegate(selector, "focus", this.bind(function () { + if (!this.enabled) return; + this.container.addClass("select2-container-active"); + this.clearPlaceholder(); + })); + + // set the placeholder if necessary + this.clearSearch(); + }, + + enable: function() { + if (this.enabled) return; + + this.parent.enable.apply(this, arguments); + + this.search.show(); + }, + + disable: function() { + if (!this.enabled) return; + + this.parent.disable.apply(this, arguments); + + this.search.hide(); + }, + + initSelection: function () { + var data; + if (this.opts.element.val() === "") { + this.updateSelection([]); + } + if (this.select || this.opts.element.val() !== "") { + data = this.opts.initSelection.call(null, this.opts.element); + if (data !== undefined && data !== null) { + this.updateSelection(data); + } + } + + this.close(); + + // set the placeholder if necessary + this.clearSearch(); + }, + + clearSearch: function () { + var placeholder = this.getPlaceholder(); + + if (placeholder !== undefined + && this.getVal().length === 0 + && this.search.hasClass("select2-focused") === false) { + + this.search.val(placeholder).addClass("select2-default"); + // stretch the search box to full width of the container so as much of the placeholder is visible as possible + this.search.width(this.getContainerWidth()); + } else { + this.search.val("").width(10); + } + }, + + clearPlaceholder: function () { + if (this.search.hasClass("select2-default")) { + this.search.val("").removeClass("select2-default"); + } + }, + + open: function () { + if (this.opened()) return; + this.parent.open.apply(this, arguments); + this.resizeSearch(); + this.focusSearch(); + }, + + close: function () { + if (!this.opened()) return; + this.parent.close.apply(this, arguments); + }, + + focus: function () { + this.close(); + this.search.focus(); + }, + + isFocused: function () { + return this.search.hasClass("select2-focused"); + }, + + updateSelection: function (data) { + var ids = [], filtered = [], self = this; + + // filter out duplicates + $(data).each(function () { + if (indexOf(self.id(this), ids) < 0) { + ids.push(self.id(this)); + filtered.push(this); + } + }); + data = filtered; + + this.selection.find(".select2-search-choice").remove(); + $(data).each(function () { + self.addSelectedChoice(this); + }); + self.postprocessResults(); + }, + + onSelect: function (data) { + this.addSelectedChoice(data); + if (this.select) { this.postprocessResults(); } + + if (this.opts.closeOnSelect) { + this.close(); + this.search.width(10); + } else { + this.search.width(10); + this.resizeSearch(); + } + + // since its not possible to select an element that has already been + // added we do not need to check if this is a new element before firing change + this.triggerChange(); + + this.focusSearch(); + }, + + cancel: function () { + this.close(); + this.focusSearch(); + }, + + addSelectedChoice: function (data) { + var choice, + id = this.id(data), + parts, + val = this.getVal(); + + parts = ["
  • ", + this.opts.formatSelection(data), + "", + "
  • " + ]; + + choice = $(parts.join("")); + choice.find("a") + .bind("click dblclick", this.bind(function (e) { + if (!this.enabled) return; + + this.unselect($(e.target)); + this.selection.find(".select2-search-choice-focus").removeClass("select2-search-choice-focus"); + killEvent(e); + this.close(); + this.focusSearch(); + })).bind("focus", this.bind(function () { + if (!this.enabled) return; + this.container.addClass("select2-container-active"); + })); + + choice.data("select2-data", data); + choice.insertBefore(this.searchContainer); + + val.push(id); + this.setVal(val); + }, + + unselect: function (selected) { + var val = this.getVal(), + index; + + selected = selected.closest(".select2-search-choice"); + + if (selected.length === 0) { + throw "Invalid argument: " + selected + ". Must be .select2-search-choice"; + } + + index = indexOf(this.id(selected.data("select2-data")), val); + + if (index >= 0) { + val.splice(index, 1); + this.setVal(val); + if (this.select) this.postprocessResults(); + } + selected.remove(); + this.triggerChange(); + }, + + postprocessResults: function () { + var val = this.getVal(), + choices = this.results.find(".select2-result"), + self = this; + + choices.each(function () { + var choice = $(this), id = self.id(choice.data("select2-data")); + if (indexOf(id, val) >= 0) { + choice.addClass("select2-disabled"); + } else { + choice.removeClass("select2-disabled"); + } + }); + + choices.each(function (i) { + if (!$(this).hasClass("select2-disabled")) { + self.highlight(i); + return false; + } + }); + + }, + + resizeSearch: function () { + + var minimumWidth, left, maxWidth, containerLeft, searchWidth; + + minimumWidth = measureTextWidth(this.search) + 10; + + left = this.search.offset().left; + + maxWidth = this.selection.width(); + containerLeft = this.selection.offset().left; + + searchWidth = maxWidth - (left - containerLeft) - getSideBorderPadding(this.search); + + if (searchWidth < minimumWidth) { + searchWidth = maxWidth - getSideBorderPadding(this.search); + } + + if (searchWidth < 40) { + searchWidth = maxWidth - getSideBorderPadding(this.search); + } + this.search.width(searchWidth); + }, + + getVal: function () { + var val; + if (this.select) { + val = this.select.val(); + return val === null ? [] : val; + } else { + val = this.opts.element.val(); + return splitVal(val, ","); + } + }, + + setVal: function (val) { + var unique; + if (this.select) { + this.select.val(val); + } else { + unique = []; + // filter out duplicates + $(val).each(function () { + if (indexOf(this, unique) < 0) unique.push(this); + }); + this.opts.element.val(unique.length === 0 ? "" : unique.join(",")); + } + }, + + val: function () { + var val, data = [], self=this; + + if (arguments.length === 0) { + return this.getVal(); + } + + val = arguments[0]; + + if (this.select) { + // val is a list of ids + this.setVal(val); + this.select.find(":selected").each(function () { + data.push({id: $(this).attr("value"), text: $(this).text()}); + }); + this.updateSelection(data); + } else { + val = (val === null) ? [] : val; + this.setVal(val); + // val is a list of objects + st + $(val).each(function () { data.push(self.id(this)); }); + this.setVal(data); + this.updateSelection(val); + } + + this.clearSearch(); + }, + onSortStart: function() { + if (this.select) { + throw new Error("Sorting of elements is not supported when attached to instead."); + } + + // collapse search field into 0 width so its container can be collapsed as well + this.search.width(0); + // hide the container + this.searchContainer.hide(); + }, + onSortEnd:function() { + + var val=[], self=this; + + // show search and move it to the end of the list + this.searchContainer.show(); + // make sure the search container is the last item in the list + this.searchContainer.appendTo(this.searchContainer.parent()); + // since we collapsed the width in dragStarteed, we resize it here + this.resizeSearch(); + + // update selection + + this.selection.find(".select2-search-choice").each(function() { + val.push(self.opts.id($(this).data("select2-data"))); + }); + this.setVal(val); + this.triggerChange(); + } + }); + + $.fn.select2 = function () { + + var args = Array.prototype.slice.call(arguments, 0), + opts, + select2, + value, multiple, allowedMethods = ["val", "destroy", "open", "close", "focus", "isFocused", "container", "onSortStart", "onSortEnd", "enable", "disable"]; + + this.each(function () { + if (args.length === 0 || typeof(args[0]) === "object") { + opts = args.length === 0 ? {} : $.extend({}, args[0]); + opts.element = $(this); + + if (opts.element.get(0).tagName.toLowerCase() === "select") { + multiple = opts.element.attr("multiple"); + } else { + multiple = opts.multiple || false; + if ("tags" in opts) {opts.multiple = multiple = true;} + } + + select2 = multiple ? new MultiSelect2() : new SingleSelect2(); + select2.init(opts); + } else if (typeof(args[0]) === "string") { + + if (indexOf(args[0], allowedMethods) < 0) { + throw "Unknown method: " + args[0]; + } + + value = undefined; + select2 = $(this).data("select2"); + if (select2 === undefined) return; + if (args[0] === "container") { + value=select2.container; + } else { + value = select2[args[0]].apply(select2, args.slice(1)); + } + if (value !== undefined) {return false;} + } else { + throw "Invalid arguments to select2 plugin: " + args; + } + }); + return (value === undefined) ? this : value; + }; + + // exports + window.Select2 = { + query: { + ajax: ajax, + local: local, + tags: tags + }, util: { + debounce: debounce + }, "class": { + "abstract": AbstractSelect2, + "single": SingleSelect2, + "multi": MultiSelect2 + } + }; + +}(jQuery)); diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 0000000..a067656 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,866 @@ +/* + *= require_tree . + *= require_self +*/ + + +/* generics */ + +/* force a vertical scrollbar on firefox to avoid page-shifting */ +html { + overflow: -moz-scrollbars-vertical !important; +} + +body, textarea, input { + font-family: "helvetica neue", arial, sans-serif; + font-size: 10pt; + color: #333; +} + +a { + color: #0855f1; +} + +a:visited { + color: #4342AC; +} + +h1 { + margin: 0px; + margin-bottom: 0.75em; + padding: 0px; + font-size: 14pt; + font-weight: bold; +} + +div.clear { + clear: both; +} + +div.s { + height: 1px; +} + +a.tag { + border: 1px solid #b8b599; + background-color: #fffcd7; + font-size: 7.5pt; + margin-left: 0.25em; + padding: 0px 0.5em 1px 0.5em; + text-decoration: none; + -moz-border-radius: 10px; + -webkit-border-radius: 10px; + border-radius: 10px; + color: #555; + vertical-align: text-top; +} + + +/* default form styling */ + +input, +select, +textarea { + color: #555; + line-height: 1.2em; + padding: 3px 5px; +} +input[type="text"], +input[type="search"], +input[type="password"], +input[type="email"], +textarea { + border: 1px solid #ccc; +} +input[type="checkbox"] { + margin-top: 10px; +} +select { + border: 1px solid #ccc; +} +input:focus, +textarea:focus { + border-color: #aaa; + color: #303030; + outline: 0; +} + +body.mobile input, +body.mobile select, +body.mobile textarea { + max-width: 275px; +} + +/* these must be separate */ +::-webkit-input-placeholder { + color: #aaa; + font-style: italic; +} +:-moz-placeholder { + color: #aaa; + font-style: italic; +} + +button, +input[type="button"], +input[type="reset"], +input[type="submit"], +div.select2-choices { + display: inline-block; + padding: 2px 10px 2px 10px; + line-height: 18px; + color: #333333; + text-align: center; + background-color: #fafafa; + border: 1px solid #ccc; + border-bottom-color: #bbb; + cursor: pointer; +} +button:first-child, +input[type="button"]:first-child, +input[type="reset"]:first-child, +input[type="submit"]:first-child { + *margin-left: 0; +} +button:hover, +input[type="button"]:hover, +input[type="reset"]:hover, +input[type="submit"]:hover { + color: #333333; + text-decoration: none; + background-color: #e6e6e6; +} + +select { + margin-top: 3px; + min-width: 100px; +} +select:focus { + border-color: rgba(160,160,160,.8); + color: #303030; + outline: 0; +} +select::-moz-focus-inner { + border: 0; + outline: 0; +} + +div.field_with_errors { + display: inline; +} + +div.field_with_errors input[type="text"], +div.field_with_errors input[type="email"], +div.field_with_errors input[type="password"] { + border: 1px solid red; +} +div.field_with_errors input:focus { + border-color: rgba(255,0,0,.8); +} + +input:disabled { + background-color: #e9e9e9; + color: gray; +} + + +/* outliners */ + +div#wrapper { + margin-left: auto; + margin-right: auto; + padding-bottom: 1em; + width: 900px; +} +div#inside { + margin-left: 0.25em; + margin-right: 0.25em; +} + + +/* header */ + +#header { + font-weight: bold; + padding: 5px 42px 5px 10px; + margin-top: 1em; + margin-bottom: 0.75em; +} + +#l_holder { + background-color: #ee0000; + float: left; + width: 16px; + height: 16px; + padding: 1px; + margin-left: 4px; + margin-right: 11px; +} +#l_holder.dead { + background-color: black; +} +#l_holder.boring { + background-color: darkred; +} + +#header h1 { + font-size: 11pt; + margin: 0px; + margin-left: 22px; + margin-right: 1em; + padding: 0px; + display: inline; +} +#header h1 a { + color: #333; + text-decoration: none; +} + +#headertitle { + padding-right: 0.75em; +} +#headertitle a { + color: #333; + text-decoration: none; +} + +#headerlinks a { + color: #666; + text-decoration: none; + padding-right: 0.75em; +} + +#headerright { + float: right; + color: #888; + text-align: right; +} + +#headerright a { + color: #666; + text-decoration: none; +} + +#headerright.loggedin a { + padding-left: 0.75em; +} + + +/* footer */ + +div#footer { + clear: both; + color: gray; + font-style: italic; + margin-bottom: 2em; + padding-top: 1em; +} +div#footer a { + color: gray; +} + + +/* stories */ + +ol.stories, +ol.comments { + padding: 0; + list-style: none; + margin: 0; +} + +ol.comments { + margin-top: 3em; +} + +div.voters { + float: left; + margin-top: -5px; + width: 40px; +} + +div.voters div.score { + color: #aaa; + font-size: 9pt; + margin-top: 1px; + margin-bottom: -2px; + text-align: center; +} + +div.voters a.upvoter, +div.voters a.downvoter { + border-color: transparent transparent #bbb transparent; + border-style: solid; + border-width: 6px; + text-decoration: none; + width: 0px; + height: 0; + margin-bottom: 0px; + margin-left: 14px; + padding: 0; + display: block; +} + +div.voters a.upvoter:hover, +li.upvoted div.voters a.upvoter { + border-bottom-color: #ac130d; +} + +div.voters a.upvoter { + border-bottom-width: 11px; +} + +div.voters a.downvoter { + border-color: #bbb transparent transparent transparent; + border-width: 5px; + margin-top: 4px; + margin-left: 15px; + margin-bottom: -5px; + border-top-width: 9px; +} +div.voters a.downvoter:hover, +li.downvoted div.voters a.downvoter { + border-top-color: gray; +} + +ol.stories li, +ol.comments li { + clear: both; + margin-bottom: 0.75em; +} +li div.details { + padding-top: 0.1em; +} +ol.stories li.downvoted { + opacity: 0.5; +} + +ol.comments li.negative { + opacity: 0.7; + color: gray !important; +} +ol.comments li.negative_3 { + opacity: 0.4; +} +ol.comments li.negative_5 { + opacity: 0.3; +} +ol.comments li.negative_7 { + opacity: 0.2; +} + +li .link { + font-weight: bold; + vertical-align: middle; +} + +li .link a { + font-size: 12pt; + text-decoration: none; +} + +ol.stories a.tag { + vertical-align: middle; +} + +li .domain { + color: #888; + font-style: italic; + font-size: 8.5pt; + margin-left: 0.5em; + margin-right: 0.25em; + vertical-align: middle; +} + +li .byline { + color: #888; + font-size: 8.5pt; +} +ol.stories li .byline { + margin-top: 1px; +} +ol.comments li .byline { + margin-top: -2px; + margin-bottom: -1px; +} +li .byline a { + color: #888; + text-decoration: none; +} + +li.story.expired { + opacity: 0.6; +} +li.story.expired a { + color: gray !important; +} + +li div.details, +div.story_content { + margin-left: 40px; + margin-right: 40px; +} + +div.story_text { + font-size: 10pt; + margin-bottom: 1.5em; +} +div.story_text p { + margin: 0.75em 0; +} +blockquote { + display: inline; + font-style: italic; + margin: 0px; + padding: 0px 0px 0px 0.5em; + border-left: 2px solid gray; +} + +div.comment_text p { + margin: 0.5em 0; +} +div.comment_actions a { + color: #888; + font-weight: bold; + font-size: 8.5pt; + text-decoration: none; +} + + +a.pagelink { + border: 1px solid #ddd; + background-color: #fbfbfb; + padding: 4px 8px; +} +a.pagelink.curpage { + border: 1px solid gray; + background-color: white; + font-weight: bold; +} + + +#downvote_why { + position: absolute; + width: 100px; + border: 1px solid #aaa; + border-bottom: 0; +} +#downvote_why a { + background-color: white; + color: #555; + border-bottom: 1px solid #aaa; + display: block; + padding: 3px; + font-size: 9pt; +} +#downvote_why a:hover { + background-color: #eee; +} + +/* data tables */ + +table.data caption { + font-weight: bold; + text-align: left; + padding-bottom: 3px; +} +table.data .capright { + float: right; +} + +table.data th { + background-color: #eaeaea; + border-bottom: 1px solid #cacaca; + border-top: 1px solid #cacaca; + padding: 2px; + padding-left: 3px; + text-align: left; +} +table.data th img { + vertical-align: middle; + margin-bottom: 5px; +} + +table.data th.r, table.data td.r { + text-align: right; + padding-right: 3px; +} + +table.data td { + padding-left: 3px; + padding-top: 4px; + padding-bottom: 3px; +} + +table.thread td { + padding: 0px 0px 0px 3px; + margin-bottom: 0px; + line-height: 17px; + /* text-vertical-align: middle; */ + overflow: hidden; +} +table.thread td img { + vertical-align: middle; +} + +table.data tr.row0 td { + background-color: #f8f8f8; + border-bottom: 1px solid #eaeaea; +} +table.data tr.row1 td { + background-color: #f5f5f5; + border-bottom: 1px solid #eaeaea; +} +table.data tr.nobottom td { + border-bottom: 0px; + padding-bottom: 0px; +} + +table.data tr.void td, table.data tr.void td a { + text-decoration: line-through; + color: gray !important; +} + + +/* boxes */ +.box { + border: 0; + margin: 0 40px; + padding: 0; +} +.box_submitter { + border: 1px solid #cacaca; + border-top: 0px; + background-color: #eaeaea; + padding: 1em; +} +.box_submitter input { + width: 10em; +} + +.box .legend { + background-color: white; + margin-bottom: 1em; + padding: 0; + font-weight: bold; + font-size: 11pt; +} +.box .boxtitle { + background-color: #f0f0f0; + border-bottom: 1px solid #cacaca; + display: block; + font-weight: bold; + margin: -3px -7px 4px -7px; + padding: 3px 5px 3px 5px; +} + +.box label, +.box .label_holder { + display: block; + float: left; + margin-bottom: 4px; +} + +.box label.required { + font-weight: bold; +} +.box img { + vertical-align: middle; +} +.box label, +.box span, +.box select, +.box br { + line-height: 2em; +} +.box br { + clear: both; +} +.box .boxline { + clear: both; + margin-bottom: 0.5em; +} +.box p { + margin-top: 1em; +} + +.box textarea { + margin-bottom: 4px; + width: 75%; +} + +.box input.normal, +.box label.normal { + display: inline; + float: none; + vertical-align: middle; +} + +.box label, +.box .label_holder { + width: 7em; + line-height: 2em; + vertical-align: middle; +} + +.box .d { + margin-left: 10em; +} + +.box .label_holder label, +.box .label_holder input { + line-height: 2.5em; + vertical-align: middle; +} + +.hint { + color: gray; + font-style: italic; + margin-left: 1em; +} + +.hintblock { + color: gray; + font-style: italic; + margin-left: 7em; +} + +.box.wide label, +.box.wide .label_holder { + width: 12em; +} +.box.wide .hintblock { + margin-left: 12em; +} + + + +/* for flash_notices() and flash_errors() */ + +div.flash-error, +div.flash-notice, +div.flash-success, +div.errorExplanation { + position: relative; + padding: 7px 15px; + margin-bottom: 18px; + color: white; + background-color: #eedc94; + background-repeat: repeat-x; + background-image: -khtml-gradient(linear, left top, left bottom, from(#fceec1), to(#eedc94)); + background-image: -moz-linear-gradient(top, #fceec1, #eedc94); + background-image: -ms-linear-gradient(top, #fceec1, #eedc94); + background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #fceec1), color-stop(100%, #eedc94)); + background-image: -webkit-linear-gradient(top, #fceec1, #eedc94); + background-image: -o-linear-gradient(top, #fceec1, #eedc94); + background-image: linear-gradient(top, #fceec1, #eedc94); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fceec1', endColorstr='#eedc94', GradientType=0); + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); + border-color: #eedc94 #eedc94 #e4c652; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + border-width: 1px; + border-style: solid; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25); + -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25); +} +div.flash-error a, +div.flash-notice a, +div.flash-success a, +div.errorExplanation a { + font-weight: bold; + color: #404040; +} +div.flash-error p a, +div.flash-notice p a, +div.flash-success p a, +div.errorExplanation a { + color: #ffffff; +} +div.flash-error p, +div.flash-notice p, +div.flash-success p, +div.errorExplanation p { + margin-bottom: 0; +} +div.flash-error div, +div.flash-notice div, +div.flash-success div, +div.errorExplanation div { + margin-top: 5px; + margin-bottom: 2px; + line-height: 28px; +} +div.flash-error, +div.errorExplanation { + background-color: #c43c35; + background-repeat: repeat-x; + background-image: -khtml-gradient(linear, left top, left bottom, from(#ee5f5b), to(#c43c35)); + background-image: -moz-linear-gradient(top, #ee5f5b, #c43c35); + background-image: -ms-linear-gradient(top, #ee5f5b, #c43c35); + background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #ee5f5b), color-stop(100%, #c43c35)); + background-image: -webkit-linear-gradient(top, #ee5f5b, #c43c35); + background-image: -o-linear-gradient(top, #ee5f5b, #c43c35); + background-image: linear-gradient(top, #ee5f5b, #c43c35); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ee5f5b', endColorstr='#c43c35', GradientType=0); + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); + border-color: #c43c35 #c43c35 #882a25; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); +} +div.flash-success { + background-color: #57a957; + background-repeat: repeat-x; + background-image: -khtml-gradient(linear, left top, left bottom, from(#62c462), to(#57a957)); + background-image: -moz-linear-gradient(top, #62c462, #57a957); + background-image: -ms-linear-gradient(top, #62c462, #57a957); + background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #62c462), color-stop(100%, #57a957)); + background-image: -webkit-linear-gradient(top, #62c462, #57a957); + background-image: -o-linear-gradient(top, #62c462, #57a957); + background-image: linear-gradient(top, #62c462, #57a957); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#62c462', endColorstr='#57a957', GradientType=0); + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); + border-color: #57a957 #57a957 #3d773d; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); +} +div.flash-notice { + background-color: #339bb9; + background-repeat: repeat-x; + background-image: -khtml-gradient(linear, left top, left bottom, from(#5bc0de), to(#339bb9)); + background-image: -moz-linear-gradient(top, #5bc0de, #339bb9); + background-image: -ms-linear-gradient(top, #5bc0de, #339bb9); + background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #5bc0de), color-stop(100%, #339bb9)); + background-image: -webkit-linear-gradient(top, #5bc0de, #339bb9); + background-image: -o-linear-gradient(top, #5bc0de, #339bb9); + background-image: linear-gradient(top, #5bc0de, #339bb9); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#5bc0de', endColorstr='#339bb9', GradientType=0); + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); + border-color: #339bb9 #339bb9 #22697d; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); +} + +div.errorExplanation h2 { + font-size: 1.25em; + margin: 0; +} + +div#flasher { + position: fixed; + top: -40px; + right: 0px; + left: 325px; + text-align: center; + z-index: 15; + height: 1px; +} +div#flasher div.flash-error, +div#flasher div.flash-notice, +div#flasher div.flash-success { + line-height: 1.5em; + display: inline-block; + padding-top: 25px; + margin-left: auto; + margin-right: auto; +} +div#flasher span#flashcontent { +} + + +/* for error_messages_for() */ +div.fieldWithErrors { + display: inline; +} + + +/* modal confirm() */ +div.modal_confirm { + background-color: black; + opacity: 0.6; + filter: alpha(opacity=60); + position: fixed; + left: 0px; + right: 0px; + bottom: 0px; + top: 0px; + z-index: 20; +} +div.modal_confirm_wrapper { + position: fixed; + left: 0px; + right: 0px; + bottom: 0px; + top: 0px; + z-index: 21; +} +div.modal_confirm_content { + background-color: white; + color: #333; + + padding: 15px; + + margin-left: auto; + margin-right: auto; + opacity: 1; + filter: alpha(opacity=100); + text-align: center; + margin-top: 150px; + width: 35%; + z-index: 22; + + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + border-radius: 5px; + -webkit-box-shadow: 0px 0px 10px rgba(0,0,0,0.75); + -moz-box-shadow: 0px 0px 10px rgba(0,0,0,0.75); + box-shadow: 0px 0px 10px rgba(0,0,0,0.75); +} +div.modal_confirm_content div.modal_confirm_buttonbar { + margin-top: 1em; +} +div.modal_confirm_content div.modal_confirm_buttonbar input.default_button { + font-weight: bold; +} + + +/* select2 deuglification */ + +.select2-container-multi.select2-container-active .select2-choices { + border: 1px solid rgba(160,160,160,.8); +} +.select2-container .select2-results .select2-highlighted { + background: darkred; + color: #fff; +} + +.select2-container-multi .select2-choices { + border-color: #ccc; + background-image: none; + padding-top: 2px; +} + +.select2-container-multi.select2-container-active .select2-choices { + -webkit-box-shadow: none; + -moz-box-shadow : none; + -o-box-shadow : none; + box-shadow : none; +} + +.select2-container-active .select2-choice, +.select2-container-active .select2-choices { + -webkit-box-shadow: none; + -moz-box-shadow : none; + -o-box-shadow : none; + box-shadow : none; +} + +.select2-dropdown-open .select2-choice { + border: 1px solid #aaa; + border-bottom-color: transparent; + -webkit-box-shadow: none; + -moz-box-shadow : none; + -o-box-shadow : none; + box-shadow : none; +} diff --git a/app/assets/stylesheets/select2.css b/app/assets/stylesheets/select2.css new file mode 100644 index 0000000..244dc03 --- /dev/null +++ b/app/assets/stylesheets/select2.css @@ -0,0 +1,456 @@ +/* +Version: @@ver@@ Timestamp: @@timestamp@@ +*/ +.select2-container { + position: relative; + display: inline-block; + /* inline-block for ie7 */ + zoom: 1; + *display: inline; + +} + +.select2-container, +.select2-drop, +.select2-search, +.select2-container .select2-search input{ + /* + Force border-box so that % widths fit the parent + container without overlap because of margin/padding. + + More Info : http://www.quirksmode.org/css/box.html + */ + -moz-box-sizing: border-box; /* firefox */ + -ms-box-sizing: border-box; /* ie */ + -webkit-box-sizing: border-box; /* webkit */ + -khtml-box-sizing: border-box; /* konqueror */ + box-sizing: border-box; /* css3 */ +} + +.select2-container .select2-choice { + background-color: #fff; + background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #eeeeee), color-stop(0.5, white)); + background-image: -webkit-linear-gradient(center bottom, #eeeeee 0%, white 50%); + background-image: -moz-linear-gradient(center bottom, #eeeeee 0%, white 50%); + background-image: -o-linear-gradient(bottom, #eeeeee 0%, #ffffff 50%); + background-image: -ms-linear-gradient(top, #eeeeee 0%, #ffffff 50%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#eeeeee', endColorstr = '#ffffff', GradientType = 0); + background-image: linear-gradient(top, #eeeeee 0%, #ffffff 50%); + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + -moz-background-clip: padding; + -webkit-background-clip: padding-box; + background-clip: padding-box; + border: 1px solid #aaa; + display: block; + overflow: hidden; + white-space: nowrap; + position: relative; + height: 26px; + line-height: 26px; + padding: 0 0 0 8px; + color: #444; + text-decoration: none; +} + +.select2-container .select2-choice span { + margin-right: 26px; + display: block; + overflow: hidden; + white-space: nowrap; + -o-text-overflow: ellipsis; + -ms-text-overflow: ellipsis; + text-overflow: ellipsis; +} + +.select2-container .select2-choice abbr { + display: block; + position: absolute; + right: 26px; + top: 8px; + width: 12px; + height: 12px; + font-size: 1px; + background: url(select2.png) right top no-repeat; + cursor: pointer; + text-decoration: none; + border:0; + outline: 0; +} +.select2-container .select2-choice abbr:hover { + background-position: right -11px; + cursor: pointer; +} + +.select2-container .select2-drop { + background: #fff; + border: 1px solid #aaa; + border-top: 0; + position: absolute; + top: 100%; + -webkit-box-shadow: 0 4px 5px rgba(0, 0, 0, .15); + -moz-box-shadow: 0 4px 5px rgba(0, 0, 0, .15); + -o-box-shadow: 0 4px 5px rgba(0, 0, 0, .15); + box-shadow: 0 4px 5px rgba(0, 0, 0, .15); + z-index: 999; + width:100%; + margin-top:-1px; + + -webkit-border-radius: 0 0 4px 4px; + -moz-border-radius: 0 0 4px 4px; + border-radius: 0 0 4px 4px; +} + +.select2-container .select2-choice div { + -webkit-border-radius: 0 4px 4px 0; + -moz-border-radius: 0 4px 4px 0; + border-radius: 0 4px 4px 0; + -moz-background-clip: padding; + -webkit-background-clip: padding-box; + background-clip: padding-box; + background: #ccc; + background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #ccc), color-stop(0.6, #eee)); + background-image: -webkit-linear-gradient(center bottom, #ccc 0%, #eee 60%); + background-image: -moz-linear-gradient(center bottom, #ccc 0%, #eee 60%); + background-image: -o-linear-gradient(bottom, #ccc 0%, #eee 60%); + background-image: -ms-linear-gradient(top, #cccccc 0%, #eeeeee 60%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#cccccc', endColorstr = '#eeeeee', GradientType = 0); + background-image: linear-gradient(top, #cccccc 0%, #eeeeee 60%); + border-left: 1px solid #aaa; + position: absolute; + right: 0; + top: 0; + display: block; + height: 100%; + width: 18px; +} + +.select2-container .select2-choice div b { + background: url('select2.png') no-repeat 0 1px; + display: block; + width: 100%; + height: 100%; +} + +.select2-container .select2-search { + display: inline-block; + white-space: nowrap; + z-index: 1010; + min-height: 26px; + width: 100%; + margin: 0; + padding-left: 4px; + padding-right: 4px; +} + +.select2-container .select2-search-hidden { + display: block; + position: absolute; + left: -10000px; +} + +.select2-container .select2-search input { + background: #fff url('select2.png') no-repeat 100% -22px; + background: url('select2.png') no-repeat 100% -22px, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee)); + background: url('select2.png') no-repeat 100% -22px, -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%); + background: url('select2.png') no-repeat 100% -22px, -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%); + background: url('select2.png') no-repeat 100% -22px, -o-linear-gradient(bottom, white 85%, #eeeeee 99%); + background: url('select2.png') no-repeat 100% -22px, -ms-linear-gradient(top, #ffffff 85%, #eeeeee 99%); + background: url('select2.png') no-repeat 100% -22px, linear-gradient(top, #ffffff 85%, #eeeeee 99%); + padding: 4px 20px 4px 5px; + outline: 0; + border: 1px solid #aaa; + font-family: sans-serif; + font-size: 1em; + width:100%; + margin:0; + height:auto !important; + min-height: 26px; + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; + border-radius: 0; + -moz-border-radius: 0; + -webkit-border-radius: 0; +} + +.select2-container .select2-search input.select2-active { + background: #fff url('spinner.gif') no-repeat 100%; + background: url('spinner.gif') no-repeat 100%, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee)); + background: url('spinner.gif') no-repeat 100%, -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%); + background: url('spinner.gif') no-repeat 100%, -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%); + background: url('spinner.gif') no-repeat 100%, -o-linear-gradient(bottom, white 85%, #eeeeee 99%); + background: url('spinner.gif') no-repeat 100%, -ms-linear-gradient(top, #ffffff 85%, #eeeeee 99%); + background: url('spinner.gif') no-repeat 100%, linear-gradient(top, #ffffff 85%, #eeeeee 99%); +} + + +.select2-container-active .select2-choice, +.select2-container-active .select2-choices { + -webkit-box-shadow: 0 0 5px rgba(0,0,0,.3); + -moz-box-shadow : 0 0 5px rgba(0,0,0,.3); + -o-box-shadow : 0 0 5px rgba(0,0,0,.3); + box-shadow : 0 0 5px rgba(0,0,0,.3); + border: 1px solid #5897fb; + outline: none; +} + +.select2-dropdown-open .select2-choice { + border: 1px solid #aaa; + border-bottom-color: transparent; + -webkit-box-shadow: 0 1px 0 #fff inset; + -moz-box-shadow : 0 1px 0 #fff inset; + -o-box-shadow : 0 1px 0 #fff inset; + box-shadow : 0 1px 0 #fff inset; + background-color: #eee; + background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, white), color-stop(0.5, #eeeeee)); + background-image: -webkit-linear-gradient(center bottom, white 0%, #eeeeee 50%); + background-image: -moz-linear-gradient(center bottom, white 0%, #eeeeee 50%); + background-image: -o-linear-gradient(bottom, white 0%, #eeeeee 50%); + background-image: -ms-linear-gradient(top, #ffffff 0%,#eeeeee 50%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffff', endColorstr='#eeeeee',GradientType=0 ); + background-image: linear-gradient(top, #ffffff 0%,#eeeeee 50%); + -webkit-border-bottom-left-radius : 0; + -webkit-border-bottom-right-radius: 0; + -moz-border-radius-bottomleft : 0; + -moz-border-radius-bottomright: 0; + border-bottom-left-radius : 0; + border-bottom-right-radius: 0; +} + +.select2-dropdown-open .select2-choice div { + background: transparent; + border-left: none; +} +.select2-dropdown-open .select2-choice div b { + background-position: -18px 1px; +} + +/* results */ +.select2-container .select2-results { + margin: 4px 4px 4px 0; + padding: 0 0 0 4px; + position: relative; + overflow-x: hidden; + overflow-y: auto; + max-height: 200px; +} +.select2-container .select2-results li { + line-height: 80%; + padding: 7px 7px 8px; + margin: 0; + list-style: none; + cursor: pointer; + display: list-item; +} + +.select2-container .select2-results .select2-highlighted { + background: #3875d7; + color: #fff; +} +.select2-container .select2-results li em { + background: #feffde; + font-style: normal; +} +.select2-container .select2-results .select2-highlighted em { + background: transparent; +} +.select2-container .select2-results .select2-no-results { + background: #f4f4f4; + display: list-item; +} + +/* +disabled look for already selected choices in the results dropdown +.select2-container .select2-results .select2-disabled.select2-highlighted { + color: #666; + background: #f4f4f4; + display: list-item; + cursor: default; +} +.select2-container .select2-results .select2-disabled { + background: #f4f4f4; + display: list-item; + cursor: default; +} +*/ +.select2-container .select2-results .select2-disabled { + display: none; +} + +.select2-more-results.select2-active { + background: #f4f4f4 url('spinner.gif') no-repeat 100%; +} + +.select2-more-results { + background: #f4f4f4; + display: list-item; +} + +/* disabled styles */ + +.select2-container.select2-container-disabled .select2-choice { + background-color: #f4f4f4; + background-image: none; + border: 1px solid #ddd; + cursor: default; +} + +.select2-container.select2-container-disabled .select2-choice div { + background-color: #f4f4f4; + background-image: none; + border-left: 0; +} + + +/* multiselect */ + +.select2-container-multi .select2-choices { + background-color: #fff; + background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(1%, #eeeeee), color-stop(15%, #ffffff)); + background-image: -webkit-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background-image: -moz-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background-image: -o-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background-image: -ms-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background-image: linear-gradient(top, #eeeeee 1%, #ffffff 15%); + border: 1px solid #aaa; + margin: 0; + padding: 0; + cursor: text; + overflow: hidden; + height: auto !important; + height: 1%; + position: relative; +} + +.select2-container-multi .select2-drop { + margin-top:0; +} + +.select2-container-multi .select2-choices { + min-height: 26px; +} + +.select2-container-multi.select2-container-active .select2-choices { + -webkit-box-shadow: 0 0 5px rgba(0,0,0,.3); + -moz-box-shadow : 0 0 5px rgba(0,0,0,.3); + -o-box-shadow : 0 0 5px rgba(0,0,0,.3); + box-shadow : 0 0 5px rgba(0,0,0,.3); + border: 1px solid #5897fb; + outline: none; +} +.select2-container-multi .select2-choices li { + float: left; + list-style: none; +} +.select2-container-multi .select2-choices .select2-search-field { + white-space: nowrap; + margin: 0; + padding: 0; +} + +.select2-container-multi .select2-choices .select2-search-field input { + color: #666; + background: transparent !important; + font-family: sans-serif; + font-size: 100%; + height: 15px; + padding: 5px; + margin: 1px 0; + outline: 0; + border: 0; + -webkit-box-shadow: none; + -moz-box-shadow : none; + -o-box-shadow : none; + box-shadow : none; +} + + +.select2-default { + color: #999 !important; +} + +.select2-container-multi .select2-choices .select2-search-choice { + -webkit-border-radius: 3px; + -moz-border-radius : 3px; + border-radius : 3px; + -moz-background-clip : padding; + -webkit-background-clip: padding-box; + background-clip : padding-box; + background-color: #e4e4e4; + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#f4f4f4', endColorstr='#eeeeee', GradientType=0 ); + background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(20%, #f4f4f4), color-stop(50%, #f0f0f0), color-stop(52%, #e8e8e8), color-stop(100%, #eeeeee)); + background-image: -webkit-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); + background-image: -moz-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); + background-image: -o-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); + background-image: -ms-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); + background-image: linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); + -webkit-box-shadow: 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05); + -moz-box-shadow : 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05); + box-shadow : 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05); + color: #333; + border: 1px solid #aaaaaa; + line-height: 13px; + padding: 3px 5px 3px 18px; + margin: 3px 0 3px 5px; + position: relative; + cursor: default; +} +.select2-container-multi .select2-choices .select2-search-choice span { + cursor: default; +} +.select2-container-multi .select2-choices .select2-search-choice-focus { + background: #d4d4d4; +} + +.select2-search-choice-close { + display: block; + position: absolute; + right: 3px; + top: 4px; + width: 12px; + height: 13px; + font-size: 1px; + background: url(select2.png) right top no-repeat; + outline: none; +} + +.select2-container-multi .select2-search-choice-close { + left: 3px; +} + + +.select2-container-multi .select2-choices .select2-search-choice .select2-search-choice-close:hover { + background-position: right -11px; +} +.select2-container-multi .select2-choices .select2-search-choice-focus .select2-search-choice-close { + background-position: right -11px; +} + + +.select2-container-multi .select2-results { + margin: -1px 0 0; + padding: 0; +} + +/* disabled styles */ + +.select2-container-multi.select2-container-disabled .select2-choices{ + background-color: #f4f4f4; + background-image: none; + border: 1px solid #ddd; + cursor: default; +} + +.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice { + background-image: none; + background-color: #f4f4f4; + border: 1px solid #ddd; + padding: 3px 5px 3px 5px; +} + +.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice .select2-search-choice-close { + display: none; +} +/* end multiselect */ diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..ef32901 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,29 @@ +class ApplicationController < ActionController::Base + protect_from_forgery + before_filter :authenticate_user + + def authenticate_user + if session[:u] + @user = User.find_by_session_token(session[:u]) + end + + true + end + + def require_logged_in_user + if @user + true + else + redirect_to "/login" + end + end + + def require_logged_in_user_or_400 + if @user + true + else + render :text => "not logged in", :status => 400 + return false + end + end +end diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb new file mode 100644 index 0000000..dc88a7f --- /dev/null +++ b/app/controllers/comments_controller.rb @@ -0,0 +1,75 @@ +class CommentsController < ApplicationController + before_filter :require_logged_in_user_or_400, + :only => [ :create, :preview, :upvote, :downvote, :unvote ] + + def create + if !(story = Story.find_by_short_id(params[:story_id])) + return render :text => "can't find story", :status => 400 + end + + comment = Comment.new + comment.comment = params[:comment].to_s + comment.story_id = story.id + comment.user_id = @user.id + comment.upvotes = 1 + + if params[:parent_comment_short_id] + if pc = Comment.find_by_story_id_and_short_id(story.id, + params[:parent_comment_short_id]) + comment.parent_comment_id = pc.id + else + return render :json => { :error => "invalid parent comment", + :status => 400 } + end + end + + if comment.valid? && params[:preview].blank? + comment.save + end + + render :partial => "stories/commentbox", :layout => false, + :locals => { :story => story, :comment => comment } + end + + def preview + params[:preview] = true + return create + end + + def unvote + if !(comment = Comment.find_by_short_id(params[:comment_id])) + return render :text => "can't find comment", :status => 400 + end + + Vote.vote_thusly_on_story_or_comment_for_user_because(0, comment.story_id, + comment.id, @user.id, nil) + + render :text => "ok" + end + + def upvote + if !(comment = Comment.find_by_short_id(params[:comment_id])) + return render :text => "can't find comment", :status => 400 + end + + Vote.vote_thusly_on_story_or_comment_for_user_because(1, comment.story_id, + comment.id, @user.id, params[:reason]) + + render :text => "ok" + end + + def downvote + if !(comment = Comment.find_by_short_id(params[:comment_id])) + return render :text => "can't find comment", :status => 400 + end + + if !Vote::COMMENT_REASONS[params[:reason]] + return render :text => "invalid reason", :status => 400 + end + + Vote.vote_thusly_on_story_or_comment_for_user_because(-1, comment.story_id, + comment.id, @user.id, params[:reason]) + + render :text => "ok" + end +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb new file mode 100644 index 0000000..da37d50 --- /dev/null +++ b/app/controllers/home_controller.rb @@ -0,0 +1,50 @@ +class HomeController < ApplicationController + def index + conds = [ "is_expired = 0 " ] + + if @user + # exclude downvoted items + conds[0] << "AND stories.id NOT IN (SELECT story_id FROM votes " << + "WHERE user_id = ? AND vote < 0) " + conds.push @user.id + end + + if @tag + conds[0] << "AND taggings.tag_id = ?" + conds.push @tag.id + @stories = Story.find(:all, :conditions => conds, + :include => [ :user, :taggings ], :joins => [ :user, :taggings ], + :limit => 30) + + @title = @tag.description.blank?? @tag.tag : @tag.description + @title_url = tag_url(@tag.tag) + else + @stories = Story.find(:all, :conditions => conds, + :include => [ :user, :taggings ], :joins => [ :user ], + :limit => 30) + end + + if @user + votes = Vote.votes_by_user_for_stories_hash(@user.id, + @stories.map{|s| s.id }) + + @stories.each do |s| + if votes[s.id] + s.vote = votes[s.id] + end + end + end + + @stories.sort_by!{|s| s.hotness } + + render :action => "index" + end + + def tagged + if !(@tag = Tag.find_by_tag(params[:tag])) + raise ActionController::RoutingError.new("tag not found") + end + + index + end +end diff --git a/app/controllers/login_controller.rb b/app/controllers/login_controller.rb new file mode 100644 index 0000000..e4a40ab --- /dev/null +++ b/app/controllers/login_controller.rb @@ -0,0 +1,68 @@ +class LoginController < ApplicationController + before_filter :authenticate_user + + def logout + if @user + reset_session + end + + redirect_to "/" + end + + def index + @page_title = "Login" + render :action => "index" + end + + def login + if (user = User.where("email = ? OR username = ?", params[:email], + params[:email]).first) && user.try(:authenticate, params[:password]) + session[:u] = user.session_token + return redirect_to "/" + end + + flash[:error] = "Invalid e-mail address and/or password." + index + end + + def forgot_password + @page_title = "Reset Password" + render :action => "forgot_password" + end + + def reset_password + @found_user = User.where("email = ? OR username = ?", params[:email], + params[:email]).first + + if !@found_user + flash[:error] = "Invalid e-mail address or username." + return forgot_password + end + + @found_user.initiate_password_reset_for_ip(request.remote_ip) + + flash[:success] = "Password reset instructions have been e-mailed to you." + return index + end + + def set_new_password + if params[:token].blank? || + !(@reset_user = User.find_by_password_reset_token(params[:token])) + flash[:error] = "Invalid reset token. It may have already been " << + "used or you may have copied it incorrectly." + return redirect_to forgot_password_url + end + + if !params[:password].blank? + @reset_user.password = params[:password] + @reset_user.password_confirmation = params[:password_confirmation] + @reset_user.session_token = nil + @reset_user.password_reset_token = nil + + if @reset_user.save + session[:u] = @reset_user.session_token + return redirect_to "/" + end + end + end +end diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb new file mode 100644 index 0000000..ae6ff87 --- /dev/null +++ b/app/controllers/messages_controller.rb @@ -0,0 +1,107 @@ +class MessagesController < ApplicationController +# static $verify = array( +# array("method" => "post", +# "only" => array("reply", "send"), +# "redirect_to" => "/", +# ), +# ); +# +# public function index() { +# if (!$this->user) { +# $this->add_flash_error("You must be logged in to read messages."); +# return $this->redirect_to("/login"); +# } +# +# $this->page_title = "Your Messages"; +# +# $this->incoming_messages = +# Message::find_all_by_recipient_user_id($this->user->id, +# array("order" => "created_at DESC")); +# +# $this->sent_messages = +# Message::find_all_by_author_user_id($this->user->id, +# array("order" => "created_at DESC")); +# } +# +# public function show() { +# if (!$this->user) { +# $this->add_flash_error("You must be logged in to read messages."); +# return $this->redirect_to("/login"); +# } +# +# if (!($this->message = Message::find_by_random_hash($this->params["id"]))) { +# $this->add_flash_error("Could not find message."); +# return $this->redirect_to(array("controller" => "messages")); +# } +# +# if (!($this->message->recipient_user_id == $this->user->id || +# $this->message->author_user_id == $this->user->id)) { +# $this->add_flash_error("Could not find message."); +# return $this->redirect_to(array("controller" => "messages")); +# } +# +# if ($this->message->recipient_user_id == $this->user->id && +# !$this->message->has_been_read) { +# $this->message->has_been_read = true; +# $this->message->save(); +# } +# +# $this->page_title = "Message From " +# . $this->message->author->username . " To " +# . $this->message->recipient->username; +# +# $this->reply = new Message; +# $this->reply->author_user_id = $this->user->id; +# $this->reply->recipient_user_id = $this->message->author_user_id; +# $this->reply->subject = preg_match("/^re[: ]/i", +# $this->message->subject) ? "" : "Re: " . $this->message->subject; +# } +# +# /* id is a message id */ +# public function reply() { +# $this->show(); +# +# $this->page_title = "Message From " +# . $this->message->author->username . " To " +# . $this->message->recipient->username; +# +# if ($this->reply->update_attributes($this->params["message"])) { +# $this->add_flash_notice("Your reply has been sent."); +# return $this->redirect_to(array("controller" => "messages")); +# } else { +# return $this->render(array("action" => "show")); +# } +# } +# +# /* id is a username */ +# public function compose() { +# if (!$this->user) { +# $this->add_flash_error("You must be logged in to send messages."); +# return $this->redirect_to("/login"); +# } +# +# if (!($this->recipient_user = +# User::find_by_username($this->params["id"]))) { +# $this->add_flash_error("Could not find recipient user."); +# return $this->redirect_to("/messages"); +# } +# +# $this->page_title = "Compose Message To " +# . $this->recipient_user->username; +# +# $this->message = new Message; +# $this->message->recipient_user_id = $this->recipient_user->id; +# $this->message->author_user_id = $this->user->id; +# } +# +# public function send() { +# $this->compose(); +# +# if ($this->message->update_attributes($this->params["message"])) { +# $this->add_flash_notice("Your message has been sent."); +# return $this->redirect_to(array("controller" => "messages")); +# } else { +# return $this->render(array("action" => "compose")); +# } +# } +end diff --git a/app/controllers/signup_controller.rb b/app/controllers/signup_controller.rb new file mode 100644 index 0000000..e726db4 --- /dev/null +++ b/app/controllers/signup_controller.rb @@ -0,0 +1,59 @@ +class SignupController < ApplicationController + def index + @title = "Signup" + @new_user = User.new + end + + def signup + @new_user = User.new(params[:user]) + + if @new_user.save + session[:u] = @new_user.session_hash + return redirect_to "/" + else + render :action => "index" + end + end + +# public function verify() { +# if ($_SESSION["random_hash"] == "") +# return $this->redirect_to("/signup?nocookies=1"); +# +# $this->page_title = "Signup"; +# +# $this->new_user = new User($this->params["user"]); +# $this->new_user->username = $this->new_user->username; +# if ($this->new_user->is_valid()) { +# $error = false; +# try { +# $html = Utils::fetch_url("http://news.ycombinator.com/user?id=" +# . $this->new_user->username); +# } catch (Exception $e) { +# $error = true; +# error_log("error fetching profile for " +# . $this->new_user->username . ": " . $e->getMessage()); +# } +# +# if ($error) { +# $this->add_flash_error("Your Hacker News profile could " +# . "not be fetched at this time. Please try again " +# . "later."); +# return $this->render(array("action" => "index")); +# } elseif (strpos($html, $_SESSION["random_hash"])) { +# $this->new_user->save(); +# +# $this->add_flash_notice("Account created and verified. " +# . "Welcome!"); +# $_SESSION["user_id"] = $this->new_user->id; +# return $this->redirect_to("/"); +# } else { +# $this->add_flash_error("Your Hacker News profile did not " +# . "contain the string provided below. Verify that " +# . "you have cookies enabled and that your Hacker News " +# . "profile has been saved after adding the string."); +# return $this->render(array("action" => "index")); +# } +# } else +# return $this->render(array("action" => "index")); +# } +end diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb new file mode 100644 index 0000000..be3137d --- /dev/null +++ b/app/controllers/stories_controller.rb @@ -0,0 +1,225 @@ +class StoriesController < ApplicationController + before_filter :require_logged_in_user_or_400, + :only => [ :upvote, :downvote, :unvote ] + + before_filter :require_logged_in_user, :only => [ :delete, :create, :edit, + :fetch_url_title, :new ] + + def new + @page_title = "Submit a New Story" + + @story = Story.new + @story.story_type = "link" + + if !params[:url].blank? + @story.url = params[:url] + + if !params[:title].blank? + @story.title = params[:title] + end + end + end + + def create + @page_title = "Submit a New Story" + + @story = Story.new(params[:story]) + @story.user_id = @user.id + + if @story.save + Vote.vote_thusly_on_story_or_comment_for_user_because(1, @story.id, + nil, @user.id, nil) + + return redirect_to @story.comments_url + + else + if @story.already_posted_story? + # consider it an upvote + Vote.vote_thusly_on_story_or_comment_for_user_because(1, + @story.already_posted_story.id, nil, @user.id, nil) + + return redirect_to @story.already_posted_story.comments_url + end + + return render :action => "new" + end + end + + def delete + if @user.is_admin? + @story = Story.find_by_short_id(params[:id]) + else + @story = Story.find_by_user_id_and_short_id(@user.id, params[:id]) + end + + if !@story + flash[:error] = "Could not find story or you are not authorized to " << + "delete it." + return redirect_to "/" + end + + @story.is_expired = true + @story.save + + redirect_to @story.comments_url + end + +# public function edit() { +# if (!$this->user) { +# $this->add_flash_error("You must be logged in to edit a story."); +# return $this->redirect_to("/login"); +# } +# +# $this->story = Story::find_by_user_id_and_short_id($this->user->id, +# $this->params["id"]); +# +# if (!$this->story) { +# $this->add_flash_error("Could not find story or you are not " +# . "authorized to edit it."); +# return $this->redirect_to("/"); +# } +# +# $this->page_title = "Editing " . $this->story->title; +# } +# + def fetch_url_title + begin + s = Sponge.new + s.timeout = 3 + text = s.fetch(params[:fetch_url], :get, nil, nil, + { "User-agent" => "lobste.rs! via #{request.remote_ip}" }, 3) + + if m = text.match(/<\s*title\s*>([^<]+)<\/\s*title\s*>/i) + return render :json => { :title => m[1] } + else + raise "no title found" + end + + rescue => e + return render :json => "error" + end + end + +# public function index() { +# $this->items = Item::find("all"); +# } +# +# public function manage() { +# if (!$this->user) { +# $this->add_flash_error("You must be logged in to manage your " +# . "items."); +# return $this->redirect_to("/login"); +# } +# +# $this->page_title = "Manage Your Items"; +# +# $this->items = Item::column_sorter($this->params["_s"]); +# $this->items->find("all", +# array("conditions" => array("user_id = ?", $this->user->id), +# "include" => array("user", "item_kind"), +# "joins" => array("user"))); +# } +# +# public function message() { +# if (!$this->user) { +# $this->add_flash_error("You must be logged in to edit an item."); +# return $this->redirect_to("/login"); +# } +# +# $this->show(); +# +# if ($this->new_message->update_attributes($this->params["message"])) { +# $this->add_flash_notice("Your message has been sent."); +# return $this->redirect_to(array("controller" => "items", +# "action" => "show", "id" => $this->item->id)); +# } else { +# return $this->render(array("action" => "items/show")); +# } +# } +# + def show + @story = Story.find_by_short_id!(params[:id]) + + @page_title = @story.title + + @comments = @story.comments_in_order_for_user(@user ? @user.id : nil) + @comment = Comment.new + + if @user + if v = Vote.find_by_user_id_and_story_id(@user.id, @story.id) + @story.vote = v.vote + end + + @votes = Vote.comment_votes_by_user_for_story_hash(@user.id, @story.id) + @comments.each do |c| + if @votes[c.id] + c.vote = @votes[c.id] + end + end + end + end + +# public function update() { +# if (!$this->user) { +# $this->add_flash_error("You must be logged in to edit an item."); +# return $this->redirect_to("/login"); +# } +# +# if ($this->user->is_admin) +# $this->item = Item::find_by_id($this->params["id"]); +# else +# $this->item = Item::find_by_user_id_and_id($this->user->id, +# $this->params["id"]); +# +# if (!$this->item) { +# $this->add_flash_error("Could not find item or you are not " +# . "authorized to edit it."); +# return $this->redirect_to("/"); +# } +# +# $this->item->is_expired = false; +# if ($this->item->update_attributes($this->params["item"])) { +# $this->add_flash_notice("Successfully saved item changes."); +# return $this->redirect_to(array("controller" => "items", +# "action" => "show", "id" => $this->item->id)); +# } else +# return $this->render(array("action" => "edit")); +# } +# + def unvote + if !(story = Story.find_by_short_id(params[:story_id])) + return render :text => "can't find story", :status => 400 + end + + Vote.vote_thusly_on_story_or_comment_for_user_because(0, story.id, + nil, @user.id, nil) + + render :text => "ok" + end + + def upvote + if !(story = Story.find_by_short_id(params[:story_id])) + return render :text => "can't find story", :status => 400 + end + + Vote.vote_thusly_on_story_or_comment_for_user_because(1, story.id, + nil, @user.id, nil) + + render :text => "ok" + end + + def downvote + if !(story = Story.find_by_short_id(params[:story_id])) + return render :text => "can't find story", :status => 400 + end + + if !Vote::STORY_REASONS[params[:reason]] + return render :text => "invalid reason", :status => 400 + end + + Vote.vote_thusly_on_story_or_comment_for_user_because(-1, story.id, + nil, @user.id, params[:reason]) + + render :text => "ok" + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 0000000..a6f77df --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,51 @@ +class UsersController < ApplicationController +# function settings() { +# if (!$this->user) { +# $this->add_flash_error("You must be logged in to edit your " +# . "settings."); +# return $this->redirect_to("/login"); +# } +# +# $this->page_title = "Edit Settings"; +# +# $this->showing_user = clone $this->user; +# } +# +# function show() { +# if (!($this->showing_user = User::find_by_username($this->params["id"]))) { +# $this->add_flash_error("Could not find user."); +# return $this->redirect_to("/"); +# } +# +# $this->page_title = "User " . $this->showing_user->username; +# +# if (!$this->params["_s"]) +# $this->params["_s"] = NULL; +# +# $this->items = Item::column_sorter($this->params["_s"]); +# $this->items->find("all", +# array("conditions" => array("user_id = ? AND is_expired = 0", +# $this->showing_user->id), +# "include" => array("user", "item_kind"), +# "joins" => array("user"))); +# } +# +# function update() { +# if (!$this->user) { +# $this->add_flash_error("You must be logged in to edit your " +# . "settings."); +# return $this->redirect_to("/login"); +# } +# +# $this->page_title = "Edit Settings"; +# +# $this->showing_user = clone $this->user; +# +# if ($this->showing_user->update_attributes($this->params["user"])) { +# $this->add_flash_notice("Your settings have been updated."); +# return $this->redirect_to(array("controller" => "users", +# "action" => "settings")); +# } else +# return $this->render(array("action" => "settings")); +# } +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 0000000..de6be79 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/app/mailers/.gitkeep b/app/mailers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/mailers/password_reset.rb b/app/mailers/password_reset.rb new file mode 100644 index 0000000..250d6c3 --- /dev/null +++ b/app/mailers/password_reset.rb @@ -0,0 +1,11 @@ +class PasswordReset < ActionMailer::Base + default from: "nobody@lobste.rs" + + def password_reset_link(user, ip) + @user = user + @ip = ip + + mail(to: user.email, from: "Lobsters amount + end + + def flag! + Story.update_counters self.id, :flaggings => 1 + end +end diff --git a/app/models/keystore.rb b/app/models/keystore.rb new file mode 100644 index 0000000..72b500b --- /dev/null +++ b/app/models/keystore.rb @@ -0,0 +1,43 @@ +class Keystore < ActiveRecord::Base + validates_presence_of :key + + attr_accessible nil + + def self.get(key) + Keystore.find_by_key(key) + end + + def self.put(key, value) + Keystore.connection.query([ "INSERT INTO #{Keystore.table_name} (" + + "`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `count` = ?", + key, amount ]) + + true + end + + def self.increment_value_for(key, amount = 1) + self.incremented_value_for(key, amount) + end + + def self.incremented_value_for(key, amount = 1) + new_value = nil + + Keystore.connection.query([ "INSERT INTO #{Keystore.table_name} (" + + "`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `count` = " + + "`count` + ?", key, amount, amount ]) + + return self.value_for(key) + end + + def self.decrement_value_for(key, amount = -1) + self.increment_value_for(key, amount) + end + + def self.decremented_value_for(key, amount = -1) + self.incremented_value_for(key, amount) + end + + def self.value_for(key) + self.get(key).try(:value) + end +end diff --git a/app/models/story.rb b/app/models/story.rb new file mode 100644 index 0000000..ae5dbdb --- /dev/null +++ b/app/models/story.rb @@ -0,0 +1,186 @@ +class Story < ActiveRecord::Base + belongs_to :user + has_many :taggings + has_many :comments + has_many :tags, :through => :taggings + + attr_accessible :url, :title, :description, :story_type, :tags_a + + # after this many minutes old, a story cannot be edited + MAX_EDIT_MINS = 9999 # XXX 15 + + attr_accessor :vote, :story_type, :already_posted_story + attr_accessor :tags_to_add, :tags_to_delete + + after_save :deal_with_tags + before_create :assign_short_id + + def assign_short_id + (1...10).each do |tries| + if tries == 10 + raise "too many hash collisions" + end + + self.short_id = Utils.random_str(6) + if !Story.find_by_short_id(self.short_id) + break + end + end + end + + def deal_with_tags + self.tags_to_delete.each do |t| + if t.is_a?(Tagging) + t.destroy + elsif t.is_a?(Tag) + self.taggings.find_by_tag_id(t.id).try(:destroy) + end + end + + self.tags_to_add.each do |t| + if t.is_a?(Tag) + tg = Tagging.new + tg.tag_id = t.id + tg.story_id = self.id + tg.save + end + end + + self.tags_to_delete = [] + self.tags_to_add = [] + end + + def comments_in_order_for_user(user_id) + Comment.find_all_by_story_id(self.id) + # TODO + end + + def comments_url + "/p/#{self.short_id}/#{self.title_as_url}" + end + + @_comment_count = nil + def comment_count + @_comment_count ||= + Keystore.value_for("story:#{self.id}:comment_count").to_i + end + + def domain + if self.url.blank? + nil + else + pu = URI.parse(self.url) + pu.host.gsub(/^www\d*\./, "") + end + end + + UP_RANGE = 400 + DOWN_RANGE = 100 + + def hotness + score = upvotes - downvotes + order = Math.log([ score.abs, 1 ].max, 10) + if score > 0 + sign = 1 + elsif score < 0 + sign = -1 + else + sign = 0 + end + + seconds = self.created_at.to_i - 398995200 + return -(order + (sign * (seconds.to_f / 45000))).round(7) + end + + def linkified_text + Markdowner.markdown(self.description) + end + + def tags_a + tags.map{|t| t.tag } + end + def tags_a=(new_tags) + self.tags_to_delete = [] + self.tags_to_add = [] + + self.tags.each do |tag| + if !new_tags.include?(tag.tag) + self.tags_to_delete.push tag + end + end + + new_tags.each do |tag| + if tag.to_s != "" && !self.tags.map{|t| t.tag }.include?(tag) + if t = Tag.find_by_tag(tag) + self.tags_to_add.push t + end + end + end + end + + def title_as_url + u = self.title.downcase.gsub(/[^a-z0-9_-]/, "_") + while self.title.match(/__/) + self.title.gsub!("__", "_") + end + + u + end + + def url_or_comments_url + self.url.blank? ? self.comments_url : self.url + end + + def is_editable_by_user?(user) + if !user || user.id != self.user_id + return false + end + + true #(Time.now.to_i - self.created_at.to_i < (60 * Story::MAX_EDIT_MINS)) + end + + def update_comment_count! + Keystore.put("story:#{self.id}:comment_count", + Comment.count_by_story_id(self.id)) + end + + def validate + if self.title.blank? + self.errors.add(:title, "cannot be blank.") + end + +# if (strlen($this->title) > 100) +# $this->errors->add("title", "cannot be longer than 100 " +# . "characters."); +# +# if ($this->story_type == "text") { +# $this->url = null; +# +# if (trim($this->description) == "") +# $this->errors->add("description", "cannot be blank."); +# elseif (strlen($this->description) > (64 * 1024)) +# $this->errors->add("description", "is too long."); +# } +# else { +# $this->description = null; +# +# if (!preg_match("/^https?:\/\//i", $this->url)) +# $this->errors->add("url", "does not look valid."); +# +# $now = new DateTime("now"); +# if (($old = Story::find_by_url($this->url)) && +# ($old->created_at->diff($now)->format("%s") < (60 * 60 * 30))) { +# $this->errors->add("url", "has already been posted in the " +# . "last 30 days."); +# $this->already_posted_story = $old; +# } +# } +# +# if (empty($this->user_id)) +# $this->errors->add("user_id", "cannot be blank."); + end + + def flag! + Story.update_counters self.id, :flaggings => 1 + end +end diff --git a/app/models/tag.rb b/app/models/tag.rb new file mode 100644 index 0000000..972262c --- /dev/null +++ b/app/models/tag.rb @@ -0,0 +1,2 @@ +class Tag < ActiveRecord::Base +end diff --git a/app/models/tagging.rb b/app/models/tagging.rb new file mode 100644 index 0000000..8149823 --- /dev/null +++ b/app/models/tagging.rb @@ -0,0 +1,4 @@ +class Tagging < ActiveRecord::Base + belongs_to :tag + belongs_to :story +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..4c8da27 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,40 @@ +class User < ActiveRecord::Base + has_many :stories, + :include => :user + has_secure_password + + validates_format_of :username, :with => /\A[A-Za-z0-9][A-Za-z0-9_-]*\Z/ + validates_uniqueness_of :username, :case_sensitive => false + + validates_format_of :email, :with => /\A[^@]+@[^@]+\.[^@]+\Z/ + validates_uniqueness_of :email, :case_sensitive => false + + validates_presence_of :password, :on => :create + + attr_accessible :username, :email, :password, :password_confirmation, + :email_notifications + + before_save :check_session_token + + def check_session_token + if self.session_token.blank? + self.session_token = Utils.random_key(60) + end + end + + def unread_message_count + 0 + #Message.where(:recipient_user_id => self.id, :has_been_read => 0).count + end + + def karma + Keystore.value_for("user:#{self.id}:karma").to_i + end + + def initiate_password_reset_for_ip(ip) + self.password_reset_token = Utils.random_key(60) + self.save! + + PasswordReset.password_reset_link(self, ip).deliver + end +end diff --git a/app/models/vote.rb b/app/models/vote.rb new file mode 100644 index 0000000..0bbc4e0 --- /dev/null +++ b/app/models/vote.rb @@ -0,0 +1,102 @@ +class Vote < ActiveRecord::Base + belongs_to :user + belongs_to :story + + STORY_REASONS = { + "S" => "Spam", + "T" => "Poorly Tagged", + "O" => "Off-topic", + "" => "Cancel", + } + + COMMENT_REASONS = { + "O" => "Off-topic", + "I" => "Incorrect", + "T" => "Troll", + "S" => "Spam", + "" => "Cancel", + } + + def self.votes_by_user_for_stories_hash(user, stories) + votes = [] + Vote.where(:user_id => user, :story_id => stories).each do |v| + votes[v.story_id] = v.vote + end + + votes + end + + def self.comment_votes_by_user_for_story_hash(user_id, story_id) + votes = {} + + Vote.find(:all, :conditions => [ "user_id = ? AND story_id = ? AND " + + "comment_id IS NOT NULL", user_id, story_id ]).each do |v| + votes[v.comment_id] = cv.vote + end + + votes + end + + def self.vote_thusly_on_story_or_comment_for_user_because(vote, story_id, + comment_id, user_id, reason) + v = if story_id + Vote.find_or_initialize_by_user_id_and_story_id(user_id, story_id) + elsif comment_id + Vote.find_or_initialize_by_user_id_and_comment_id(user_id, comment_id) + end + + if !v.new_record? && v.vote == vote + return + end + + upvote = 0 + downvote = 0 + + Vote.transaction do + # unvote + if vote == 0 + if !v.new_record? + if v.vote == -1 + downvote = -1 + else + upvote = -1 + end + end + + v.destroy + + # new vote or change vote + else + if v.new_record? + if v.vote == -1 + downvote = 1 + else + upvote = 1 + end + elsif v.vote == -1 + # changing downvote to upvote + downvote = -1 + upvote = 1 + elsif v.vote == 1 + # changing upvote to downvote + upvote = -1 + downvote = 1 + end + + v.vote = vote + v.reason = reason + v.save! + end + + if downvote != 0 || upvote != 0 + if v.story_id + Story.update_counters v.story_id, :downvotes => downvote, + :upvotes => upvote + elsif v.comment_id + Comment.update_counters v.comment_id, :downvotes => downvote, + :upvotes => upvote + end + end + end + end +end diff --git a/app/views/global/_footer.html.erb b/app/views/global/_footer.html.erb new file mode 100644 index 0000000..1b00330 --- /dev/null +++ b/app/views/global/_footer.html.erb @@ -0,0 +1,2 @@ + diff --git a/app/views/global/_header.html.erb b/app/views/global/_header.html.erb new file mode 100644 index 0000000..d92bb45 --- /dev/null +++ b/app/views/global/_header.html.erb @@ -0,0 +1,43 @@ + diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb new file mode 100644 index 0000000..35bc3a2 --- /dev/null +++ b/app/views/home/index.html.erb @@ -0,0 +1,10 @@ +<% if flash[:error] %> +
    <%= flash[:error] %>
    +<% elsif flash[:success] %> +
    <%= flash[:success] %>
    +<% end %> + +
      + <%= render :partial => "stories/listdetail", :collection => @stories, + :as => :story %> +
    diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 0000000..b1504f0 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,28 @@ + + + + + + <%= @page_title ? "#{@page_title} | Lobsters" : "Lobsters" %> + <%= stylesheet_link_tag "application", :media => "all" %> + <%= javascript_include_tag "application" %> + <%= csrf_meta_tags %> + + + +
    + <%= render :partial => "global/header" %> + +
    + <%= yield %> +
    + + <%= render :partial => "global/footer" %> +
    + + diff --git a/app/views/login/forgot_password.html.erb b/app/views/login/forgot_password.html.erb new file mode 100644 index 0000000..52b5241 --- /dev/null +++ b/app/views/login/forgot_password.html.erb @@ -0,0 +1,24 @@ +
    +
    + Reset Password +
    + +

    + If you've forgotten your password, enter your e-mail address or username + below and instructions will be e-mailed to you. +

    + + <%= form_tag reset_password_url do %> + <% if flash[:error] %> +
    <%= flash[:error] %>
    + <% end %> + + <%= label_tag :email, "E-mail or Username:" %> + <%= text_field_tag :email, "", :size => 30 %> +
    + +

    + <%= submit_tag "Reset Password" %> +

    + <% end %> +
    diff --git a/app/views/login/index.html.erb b/app/views/login/index.html.erb new file mode 100644 index 0000000..eecad3f --- /dev/null +++ b/app/views/login/index.html.erb @@ -0,0 +1,36 @@ +
    +
    + Login +
    + + <%= form_tag login_url do %> + <% if flash[:error] %> +
    <%= flash[:error] %>
    + <% elsif flash[:success] %> +
    <%= flash[:success] %>
    + <% end %> + +

    + <%= label_tag :email, "E-mail or Username:" %> + <%= text_field_tag :email, "", :size => 30 %> +
    + + <%= label_tag :password, "Password:" %> + <%= password_field_tag :password, "", :size => 30 %> +
    +

    + +

    + <%= submit_tag "Login" %> +

    + +

    + Forgot your password? <%= link_to "Reset your password", + forgot_password_url %>. +

    + +

    + Not signed up yet? <%= link_to "Signup", signup_url %>. +

    + <% end %> +
    diff --git a/app/views/login/set_new_password.html.erb b/app/views/login/set_new_password.html.erb new file mode 100644 index 0000000..2c9b4de --- /dev/null +++ b/app/views/login/set_new_password.html.erb @@ -0,0 +1,32 @@ +
    +
    + Set New Password +
    + + <%= form_tag set_new_password_url, { :autocomplete => "off" } do %> + <% if flash[:error] %> +
    <%= flash[:error] %>
    + <% end %> + + <%= error_messages_for(@reset_user) %> + + <%= hidden_field_tag "token", params[:token] %> + +

    + <%= label_tag :username, "Username:" %> + <%= @reset_user.username %> +
    + + <%= label_tag :password, "New Password:" %> + <%= password_field_tag :password, "", :size => 30 %> +
    + + <%= label_tag :password_confirmation, "(Again):" %> + <%= password_field_tag :password_confirmation, "", :size => 30 %> +
    + +

    + <%= submit_tag "Set New Password" %> +

    + <% end %> +
    diff --git a/app/views/messages/_list.phtml b/app/views/messages/_list.phtml new file mode 100644 index 0000000..153ee99 --- /dev/null +++ b/app/views/messages/_list.phtml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + has_been_read ? "" : "class=\"unread\"" ?>> + + + + + + + + + + +
    FromToSubjectDate
    link_to(h($message->author->username), + array("controller" => "users", "action" => "show", + "id" => $message->author->username)); ?>link_to(h($message->recipient->username), + array("controller" => "users", "action" => "show", + "id" => $message->recipient->username)); ?>link_to(h($message->subject), + array("controller" => "messages", "action" => "show", + "id" => $message->random_hash)); ?>created_at->format("Y-m-d H:i:s")); ?>
    diff --git a/app/views/messages/compose.phtml b/app/views/messages/compose.phtml new file mode 100644 index 0000000..a1463cd --- /dev/null +++ b/app/views/messages/compose.phtml @@ -0,0 +1,23 @@ +
    +error_messages_for($message); ?> +form_for($message, array("controller" => "messages", +"action" => "send", "id" => $controller->recipient_user->username), array(), function($f) use +($html, $controller) { ?> + label("recipient_user_id", "To:", array("class" => "required")); ?> + link_to(h($controller->message->recipient->username), + array("controller" => "users", "action" => "show", "id" => + $controller->message->recipient->username)); ?> +
    + + label("subject", "Subject:", array("class" => "required")); ?> + text_field("subject", array("size" => 51)); ?> +
    + + text_area("body", array("size" => "70x5")); ?> +
    + +

    + submit_tag("Send Message"); ?> +

    + +
    diff --git a/app/views/messages/index.phtml b/app/views/messages/index.phtml new file mode 100644 index 0000000..d9f1c00 --- /dev/null +++ b/app/views/messages/index.phtml @@ -0,0 +1,13 @@ +

    +Inbox +render(array("partial" => "messages/list"), + array("messages" => $controller->incoming_messages, + "show_from" => true)); ?> +

    + +

    +Sent Messages +render(array("partial" => "messages/list"), + array("messages" => $controller->sent_messages, + "show_to" => true)); ?> +

    diff --git a/app/views/messages/show.phtml b/app/views/messages/show.phtml new file mode 100644 index 0000000..b6b10f0 --- /dev/null +++ b/app/views/messages/show.phtml @@ -0,0 +1,69 @@ +
    + link_to("« Back to Messages", + array("controller" => "messages")); ?> +
    + +

    +

    + + link_to(h($message->author->username), + array("controller" => "users", "action" => "show", + "id" => $message->author->username)); ?> +
    + + + link_to(h($message->recipient->username), + array("controller" => "users", "action" => "show", + "id" => $message->recipient->username)); ?> +
    + + + created_at->format("D, j F Y \\a\\t H:i:s"); ?> +
    + + + item) { ?> + link_to(h($message->subject), + array("controller" => "items", "action" => "show", + "id" => $message->item_id)); ?> + + subject); ?> + +
    + +
    + sanitized_body(); ?> +
    +
    + +
    +

    + + Send a + Reply +

    +
    diff --git a/app/views/password_reset/password_reset_link.text.erb b/app/views/password_reset/password_reset_link.text.erb new file mode 100644 index 0000000..42aaea8 --- /dev/null +++ b/app/views/password_reset/password_reset_link.text.erb @@ -0,0 +1,7 @@ +Hello <%= @user.email %>, + +Someone at <%= @ip %> requested to reset your account password. +If you submitted this request, visit the link below to set a new password. +If not, you can disregard this e-mail. + +http://lobste.rs/login/set_new_password?token=<%= @user.password_reset_token %> diff --git a/app/views/signup/index.html.erb b/app/views/signup/index.html.erb new file mode 100644 index 0000000..f38776b --- /dev/null +++ b/app/views/signup/index.html.erb @@ -0,0 +1,42 @@ +
    +
    + Create an Account +
    + + <%= form_for @new_user, { :url => signup_url, + :autocomplete => "off" } do |f| %> +

    + To create a new account, enter your e-mail address and a password. + Your e-mail address will never be shown to users and will only be used + if you need to reset your password, or to receive optional new-message + alerts. +

    + + <%= error_messages_for(@new_user) %> + +

    + <%= f.label :username, "Username:" %> + <%= f.text_field :username, :size => 30 %> + + [A-Za-z0-9][A-Za-z0-9_-]* + +
    + + <%= f.label :email, "E-mail Address:" %> + <%= f.email_field :email, :size => 30 %> +
    + + <%= f.label :password, "Password:" %> + <%= f.password_field :password, :size => 30 %> +
    + + <%= f.label :password_confirmation, "Password (again):" %> + <%= f.password_field :password_confirmation, :size => 30 %> +
    +

    + +

    + <%= submit_tag "Signup" %> +

    + <% end %> + diff --git a/app/views/stories/_comment.html.erb b/app/views/stories/_comment.html.erb new file mode 100644 index 0000000..7de7d68 --- /dev/null +++ b/app/views/stories/_comment.html.erb @@ -0,0 +1,41 @@ +
  • + <%= comment.score <= 0 ? "negative" : "" %> + <%= comment.score <= -3 ? "negative_3" : "" %> + <%= comment.score <= -5 ? "negative_5" : "" %> + <%= comment.score <= -7 ? "negative_7" : "" %> + "> +
    + return false;" + > +
    + <%= comment.score %> +
    + return false;" + > +
    +
    + +
    + <%= raw comment.linkified_comment %> + +
    + link +   + <%= link_to("reply") %> +
    +
    +
    +
  • diff --git a/app/views/stories/_commentbox.html.erb b/app/views/stories/_commentbox.html.erb new file mode 100644 index 0000000..70c7a7c --- /dev/null +++ b/app/views/stories/_commentbox.html.erb @@ -0,0 +1,23 @@ +<%= form_tag({ :controller => "comments", :action => "create", +:story_id => story.short_id }, :method => :post) do |f| %> + <%= text_area_tag "comment", comment.comment, :size => "100x5" %> + +

    + + <%= button_to_function "Post Comment", + "Lobsters.postComment($(this).parents('form').first()); return false;", + :type => "submit" %> + +   + + <%= button_to_function "Preview Comment", + "Lobsters.previewComment($(this).parents('form').first()); return false;" + %> + + <% if params[:preview].present? %> +
      + <%= render :partial => "stories/comment", + :locals => { :comment => comment, :story => story } %> +
    + <% end %> +<% end %> diff --git a/app/views/stories/_form.html.erb b/app/views/stories/_form.html.erb new file mode 100644 index 0000000..9cef5fa --- /dev/null +++ b/app/views/stories/_form.html.erb @@ -0,0 +1,84 @@ +<%= error_messages_for f.object %> + +
    +
    + <% if !f.object.new_record? && !f.object.url.blank? %> + + + <%= f.object.url %> + + <% elsif !f.object.id %> + <%= f.label :url, "URL:", :class => "required" %> + <%= f.text_field :url, :style => "width: 475px;" %> + <%= button_to_function "Fetch Title", + "Lobsters.fetchURLTitle($(this), $('#story_url'), $('#story_title'));" %> + <% end %> +
    + +
    + <%= f.label :title, "Title:", :class => "required" %> + <%= f.text_field :title, :maxlength => 100, :style => "width: 475px;" %> +
    + +
    + <%= f.label :tags_a, "Tags:", :class => "required" %> + <%= f.select "tags_a", options_for_select(Tag.order(:tag).map{|t| + [ "#{t.tag} - #{t.description}", t.tag ] }), {}, + { :multiple => true, :style => "width: 487px;" } %> +
    + +
    + <%= f.label :description, "Text:", :class => "required" %> + <%= f.text_area :description, :size => "100x10", + :placeholder => "optional, not recommended when submitting a link" %> +
    + +
    + + Limited Markdown formatting available + + +
    +
    + + diff --git a/app/views/stories/_listdetail.html.erb b/app/views/stories/_listdetail.html.erb new file mode 100644 index 0000000..d9cc1cb --- /dev/null +++ b/app/views/stories/_listdetail.html.erb @@ -0,0 +1,55 @@ +
  • + <%= story.is_expired? ? "expired" : "" %>"> +
    + <% if @user %> + + <% else %> + <%= link_to "", login_url, :class => "upvoter" %> + <% end %> +
    + <%= story.upvotes %> +
    + <% if @user %> + + <% else %> + <%= link_to "", login_url, :class => "downvoter" %> + <% end %> +
    +
    + + <%= link_to story.title, story.url_or_comments_url %> + + + <% story.taggings.each do |tagging| %> + <%= link_to tagging.tag.tag, tag_url(tagging.tag.tag), + :class => "tag tag_#{tagging.tag.tag}" %> + <% end %> + + + <%= story.domain %> + + +
    +
  • diff --git a/app/views/stories/edit.phtml b/app/views/stories/edit.phtml new file mode 100644 index 0000000..30cdaa6 --- /dev/null +++ b/app/views/stories/edit.phtml @@ -0,0 +1,13 @@ +

    Edit a Story

    + +form_for($story, array("controller" => "stories", +"action" => "update", "id" => $item->short_id), array(), +function($f) use ($C) { ?> + render(array("partial" => "stories/form"), + array("f" => $f)); ?> + +
    + submit_tag("Save Changes"); ?> +
    + +
    diff --git a/app/views/stories/manage.phtml b/app/views/stories/manage.phtml new file mode 100644 index 0000000..411ffc1 --- /dev/null +++ b/app/views/stories/manage.phtml @@ -0,0 +1,2 @@ +render(array("partial" => "items/list"), + array("items" => $controller->items, "show_caption" => true)); ?> diff --git a/app/views/stories/new.html.erb b/app/views/stories/new.html.erb new file mode 100644 index 0000000..022bd4d --- /dev/null +++ b/app/views/stories/new.html.erb @@ -0,0 +1,15 @@ +
    +
    + Submit a Story +
    + + <%= form_for @story do |f| %> + <%= render :partial => "stories/form", :locals => { :story => @story, + :f => f } %> + +

    +
    + <%= submit_tag "Submit" %> +
    + <% end %> +
    diff --git a/app/views/stories/show.html.erb b/app/views/stories/show.html.erb new file mode 100644 index 0000000..be3464d --- /dev/null +++ b/app/views/stories/show.html.erb @@ -0,0 +1,23 @@ +
      + <%= render :partial => "stories/listdetail", + :locals => { :story => @story } %> +
    + +
    + <% if @story.url.blank? %> +
    + <%= raw @story.linkified_text %> +
    + <% end %> + +

    + <% if @user %> + <%= render :partial => "stories/commentbox", + :locals => { :story => @story, :comment => @comment } %> + <% end %> +
    + +
      + <%= render :partial => "stories/comment", :locals => { :story => @story }, + :collection => @story.comments %> +
    diff --git a/app/views/users/settings.phtml b/app/views/users/settings.phtml new file mode 100644 index 0000000..aa62bf7 --- /dev/null +++ b/app/views/users/settings.phtml @@ -0,0 +1,51 @@ +
    + + link_to("Manage Your Items", + array("controller" => "items", "action" => "manage")); ?> + | + link_to("List a New Item", + array("controller" => "items", "action" => "build")); ?> + +
    + +

    username); ?>

    +
    + a user for time_ago_in_words($showing_user->created_at); ?> +
    + +

    +

    +form_for($showing_user, array("controller" => "users", +"action" => "update"), array("autocomplete" => "off"), function($f) +use ($html) { ?> + error_messages_for($f->form_object); ?> + + label("username", "Username:", array("class" => "required")); ?> + form_object->username); ?> +
    + + label("new_password", "New Password:"); ?> + password_field("new_password", array("size" => 20)); ?> +
    + + label("new_password_confirmation", "Password (Again):"); ?> + password_field("new_password_confirmation", array("size" => 20)); ?> +
    + + label("email", "E-Mail Address:", array("class" => "required")); ?> + text_field("email", array("size" => 20)); ?> +
    + + + + check_box("email_notifications"); ?> + label("email_notifications", "Receive E-Mail Notifications For " + . "New Messages", array("class" => "norm")); ?> +
    + +

    + submit_tag("Save Settings"); ?> +

    + +
    +

    diff --git a/app/views/users/show.phtml b/app/views/users/show.phtml new file mode 100644 index 0000000..3154716 --- /dev/null +++ b/app/views/users/show.phtml @@ -0,0 +1,24 @@ +

    username); ?>

    +
    + a user for time_ago_in_words($showing_user->created_at); ?> +
    + +

    +collection)) { ?> + render(array("partial" => "items/list"), + array("items" => $items)); ?> + + No Items Listed + +

    + +

    +Contact
    +

      +
    • link_to("Send Message", + array("controller" => "messages", "action" => "compose", "id" => + $showing_user->username)); ?> +
    • link_to("View Hacker News Profile", + "http://news.ycombinator.com/user?id=" . h($showing_user->username)); ?> +
    +

    diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..2d27577 --- /dev/null +++ b/config.ru @@ -0,0 +1,4 @@ +# This file is used by Rack-based servers to start the application. + +require ::File.expand_path('../config/environment', __FILE__) +run Lobsters::Application diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 0000000..a00b355 --- /dev/null +++ b/config/application.rb @@ -0,0 +1,59 @@ +require File.expand_path('../boot', __FILE__) + +require 'rails/all' + +if defined?(Bundler) + # If you precompile assets before deploying to production, use this line + Bundler.require(*Rails.groups(:assets => %w(development test))) + # If you want your assets lazily compiled in production, use this line + # Bundler.require(:default, :assets, Rails.env) +end + +module Lobsters + class Application < Rails::Application + # Settings in config/environments/* take precedence over those specified here. + # Application configuration should go into files in config/initializers + # -- all .rb files in that directory are automatically loaded. + + # Custom directories with classes and modules you want to be autoloadable. + config.autoload_paths += %W(#{config.root}/extras) + + # Only load the plugins named here, in the order given (default is alphabetical). + # :all can be used as a placeholder for all plugins not explicitly named. + # config.plugins = [ :exception_notification, :ssl_requirement, :all ] + + # Activate observers that should always be running. + # config.active_record.observers = :cacher, :garbage_collector, :forum_observer + + # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. + # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. + # config.time_zone = 'Central Time (US & Canada)' + + # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. + # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] + # config.i18n.default_locale = :de + + # Configure the default encoding used in templates for Ruby 1.9. + config.encoding = "utf-8" + + # Configure sensitive parameters which will be filtered from the log file. + config.filter_parameters += [:password] + + # Use SQL instead of Active Record's schema dumper when creating the database. + # This is necessary if your schema can't be completely dumped by the schema dumper, + # like if you have constraints or database-specific column types + # config.active_record.schema_format = :sql + + # Enforce whitelist mode for mass assignment. + # This will create an empty whitelist of attributes available for mass-assignment for all models + # in your app. As such, your models will need to explicitly whitelist or blacklist accessible + # parameters by using an attr_accessible or attr_protected declaration. + # config.active_record.whitelist_attributes = true + + # Enable the asset pipeline + config.assets.enabled = true + + # Version of your assets, change this if you want to expire all your assets + config.assets.version = '1.0' + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 0000000..4489e58 --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,6 @@ +require 'rubygems' + +# Set up gems listed in the Gemfile. +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) + +require 'bundler/setup' if File.exists?(ENV['BUNDLE_GEMFILE']) diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 0000000..975c02b --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the rails application +require File.expand_path('../application', __FILE__) + +# Initialize the rails application +Lobsters::Application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 0000000..430e967 --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,37 @@ +Lobsters::Application.configure do + # Settings specified here will take precedence over those in config/application.rb + + # In the development environment your application's code is reloaded on + # every request. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.cache_classes = false + + # Log error messages when you accidentally call methods on nil. + config.whiny_nils = true + + # Show full error reports and disable caching + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + + # Don't care if the mailer can't send + config.action_mailer.raise_delivery_errors = false + + # Print deprecation notices to the Rails logger + config.active_support.deprecation = :log + + # Only use best-standards-support built into browsers + config.action_dispatch.best_standards_support = :builtin + + # Raise exception on mass assignment protection for Active Record models + config.active_record.mass_assignment_sanitizer = :strict + + # Log the query plan for queries taking more than this (works + # with SQLite, MySQL, and PostgreSQL) + config.active_record.auto_explain_threshold_in_seconds = 0.5 + + # Do not compress assets + config.assets.compress = false + + # Expands the lines which load the assets + config.assets.debug = true +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 0000000..7bf51ac --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,67 @@ +Lobsters::Application.configure do + # Settings specified here will take precedence over those in config/application.rb + + # Code is not reloaded between requests + config.cache_classes = true + + # Full error reports are disabled and caching is turned on + config.consider_all_requests_local = false + config.action_controller.perform_caching = true + + # Disable Rails's static asset server (Apache or nginx will already do this) + config.serve_static_assets = false + + # Compress JavaScripts and CSS + config.assets.compress = true + + # Don't fallback to assets pipeline if a precompiled asset is missed + config.assets.compile = false + + # Generate digests for assets URLs + config.assets.digest = true + + # Defaults to Rails.root.join("public/assets") + # config.assets.manifest = YOUR_PATH + + # Specifies the header that your server uses for sending files + # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache + # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # See everything in the log (default is :info) + # config.log_level = :debug + + # Prepend all log lines with the following tags + # config.log_tags = [ :subdomain, :uuid ] + + # Use a different logger for distributed setups + # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) + + # Use a different cache store in production + # config.cache_store = :mem_cache_store + + # Enable serving of images, stylesheets, and JavaScripts from an asset server + # config.action_controller.asset_host = "http://assets.example.com" + + # Precompile additional assets (application.js, application.css, and all non-JS/CSS are already added) + # config.assets.precompile += %w( search.js ) + + # Disable delivery errors, bad email addresses will be ignored + # config.action_mailer.raise_delivery_errors = false + + # Enable threaded mode + # config.threadsafe! + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation can not be found) + config.i18n.fallbacks = true + + # Send deprecation notices to registered listeners + config.active_support.deprecation = :notify + + # Log the query plan for queries taking more than this (works + # with SQLite, MySQL, and PostgreSQL) + # config.active_record.auto_explain_threshold_in_seconds = 0.5 +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 0000000..05105ca --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,37 @@ +Lobsters::Application.configure do + # Settings specified here will take precedence over those in config/application.rb + + # The test environment is used exclusively to run your application's + # test suite. You never need to work with it otherwise. Remember that + # your test database is "scratch space" for the test suite and is wiped + # and recreated between test runs. Don't rely on the data there! + config.cache_classes = true + + # Configure static asset server for tests with Cache-Control for performance + config.serve_static_assets = true + config.static_cache_control = "public, max-age=3600" + + # Log error messages when you accidentally call methods on nil + config.whiny_nils = true + + # Show full error reports and disable caching + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + + # Raise exceptions instead of rendering exception templates + config.action_dispatch.show_exceptions = false + + # Disable request forgery protection in test environment + config.action_controller.allow_forgery_protection = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Raise exception on mass assignment protection for Active Record models + config.active_record.mass_assignment_sanitizer = :strict + + # Print deprecation notices to the stderr + config.active_support.deprecation = :stderr +end diff --git a/config/initializers/backtrace_silencers.rb b/config/initializers/backtrace_silencers.rb new file mode 100644 index 0000000..59385cd --- /dev/null +++ b/config/initializers/backtrace_silencers.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. +# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } + +# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. +# Rails.backtrace_cleaner.remove_silencers! diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 0000000..5d8d9be --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,15 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format +# (all these examples are active by default): +# ActiveSupport::Inflector.inflections do |inflect| +# inflect.plural /^(ox)$/i, '\1en' +# inflect.singular /^(ox)en/i, '\1' +# inflect.irregular 'person', 'people' +# inflect.uncountable %w( fish sheep ) +# end +# +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections do |inflect| +# inflect.acronym 'RESTful' +# end diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb new file mode 100644 index 0000000..72aca7e --- /dev/null +++ b/config/initializers/mime_types.rb @@ -0,0 +1,5 @@ +# Be sure to restart your server when you modify this file. + +# Add new mime types for use in respond_to blocks: +# Mime::Type.register "text/richtext", :rtf +# Mime::Type.register_alias "text/html", :iphone diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb new file mode 100644 index 0000000..adb3434 --- /dev/null +++ b/config/initializers/session_store.rb @@ -0,0 +1,9 @@ +# Be sure to restart your server when you modify this file. + +Lobsters::Application.config.session_store :cookie_store, + key: 'lobster_trap', expire_after: 1.month + +# Use the database for sessions instead of the cookie-based default, +# which shouldn't be used to store highly confidential information +# (create the session table with "rails generate session_migration") +# Lobsters::Application.config.session_store :active_record_store diff --git a/config/initializers/wrap_parameters.rb b/config/initializers/wrap_parameters.rb new file mode 100644 index 0000000..999df20 --- /dev/null +++ b/config/initializers/wrap_parameters.rb @@ -0,0 +1,14 @@ +# Be sure to restart your server when you modify this file. +# +# This file contains settings for ActionController::ParamsWrapper which +# is enabled by default. + +# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. +ActiveSupport.on_load(:action_controller) do + wrap_parameters format: [:json] +end + +# Disable root element in JSON by default. +ActiveSupport.on_load(:active_record) do + self.include_root_in_json = false +end diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000..179c14c --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,5 @@ +# Sample localization file for English. Add more files in this directory for other locales. +# See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points. + +en: + hello: "Hello world" diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..27a6d44 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,35 @@ +Lobsters::Application.routes.draw do + root :to => "home#index" + + get "login" => "login#index" + post "login" => "login#login" + post "logout" => "login#logout" + + get "signup" => "signup#index" + post "signup" => "signup#signup" + + match "/login/forgot_password" => "login#forgot_password", + :as => "forgot_password" + post "/login/reset_password" => "login#reset_password", + :as => "reset_password" + match "/login/set_new_password" => "login#set_new_password", + :as => "set_new_password" + + match "/t/:tag" => "home#tagged", :as => "tag" + + resources :stories do + post "upvote" + post "downvote" + post "unvote" + end + post "/stories/fetch_url_title" => "stories#fetch_url_title" + + resources :comments do + post "upvote" + post "downvote" + post "unvote" + end + post "/comments/preview" + + get "/p/:id/:title" => "stories#show" +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..d5acb63 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,100 @@ +# encoding: UTF-8 +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# Note that this schema.rb definition is the authoritative source for your +# database schema. If you need to create the application database on another +# system, you should be using db:schema:load, not running all the migrations +# from scratch. The latter is a flawed and unsustainable approach (the more migrations +# you'll amass, the slower it'll run and the greater likelihood for issues). +# +# It's strongly recommended to check this file into your version control system. + +ActiveRecord::Schema.define(:version => 0) do + + create_table "comments", :force => true do |t| + t.datetime "created_at", :null => false + t.datetime "updated_at" + t.string "short_id", :limit => 10, :default => "", :null => false + t.integer "story_id", :null => false + t.integer "user_id", :null => false + t.integer "parent_comment_id" + t.text "comment", :null => false + t.integer "upvotes", :default => 0, :null => false + t.integer "downvotes", :default => 0, :null => false + end + + add_index "comments", ["story_id", "short_id"], :name => "story_id" + + create_table "keystores", :primary_key => "key", :force => true do |t| + t.integer "value", :null => false + end + + create_table "messages", :force => true do |t| + t.datetime "created_at" + t.integer "author_user_id" + t.integer "recipient_user_id" + t.integer "has_been_read", :limit => 1, :default => 0 + t.string "subject", :limit => 100 + t.text "body" + t.integer "item_id" + t.string "random_hash", :limit => 30 + end + + add_index "messages", ["random_hash"], :name => "random_hash", :unique => true + + create_table "stories", :force => true do |t| + t.datetime "created_at" + t.integer "user_id" + t.string "url", :limit => 250, :default => "" + t.string "title", :limit => 100, :default => "", :null => false + t.text "description" + t.string "short_id", :limit => 6, :default => "", :null => false + t.integer "is_expired", :limit => 1, :default => 0, :null => false + t.integer "upvotes", :default => 0, :null => false + t.integer "downvotes", :default => 0, :null => false + end + + add_index "stories", ["url"], :name => "url" + + create_table "taggings", :force => true do |t| + t.integer "story_id", :null => false + t.integer "tag_id", :null => false + end + + add_index "taggings", ["story_id", "tag_id"], :name => "story_id2", :unique => true + + create_table "tags", :force => true do |t| + t.string "tag", :limit => 25, :default => "", :null => false + t.string "description", :limit => 100 + end + + add_index "tags", ["tag"], :name => "tag", :unique => true + + create_table "users", :force => true do |t| + t.string "username", :limit => 50 + t.string "email", :limit => 100 + t.string "password_digest", :limit => 75 + t.datetime "created_at" + t.integer "email_notifications", :limit => 1, :default => 1 + t.integer "is_admin", :limit => 1, :default => 0, :null => false + t.string "password_reset_token", :limit => 75 + t.string "session_token", :limit => 75, :default => "", :null => false + end + + add_index "users", ["session_token"], :name => "session_hash", :unique => true + add_index "users", ["username"], :name => "username", :unique => true + + create_table "votes", :force => true do |t| + t.integer "user_id", :null => false + t.integer "story_id", :null => false + t.integer "comment_id" + t.integer "vote", :limit => 1, :null => false + t.string "reason", :limit => 1 + end + + add_index "votes", ["user_id", "comment_id"], :name => "user_id_2" + add_index "votes", ["user_id", "story_id"], :name => "user_id" + +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 0000000..4edb1e8 --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,7 @@ +# This file should contain all the record creation needed to seed the database with its default values. +# The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). +# +# Examples: +# +# cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }]) +# Mayor.create(name: 'Emanuel', city: cities.first) diff --git a/extras/markdowner.rb b/extras/markdowner.rb new file mode 100644 index 0000000..2cd7a58 --- /dev/null +++ b/extras/markdowner.rb @@ -0,0 +1,144 @@ +class Markdowner + MAX_BARE_LINK = 50 + + def self.h(str) + # .to_str is needed because this becomes a SafeBuffer, which breaks gsub + # https://github.com/rails/rails/issues/1555 + ERB::Util.h(str).to_str.gsub(/<(\/?)(em|u|strike)>/, '<\1\2>') + end + + def self.markdown(string) + lines = string.rstrip.split(/\r?\n/) + + out = "

    " + inpre = false + lines.each do |line| + # [ ][ ]blah ->

      blah
    + if line.match(/^( |\t)/) + if !inpre + out << "

    "
    +        end
    +
    +				out << ERB::Util.h(line) << "\n"
    +				inpre = true
    +				next
    +      elsif inpre
    +				out << "

    \n

    " + inpre = false + end + + line = self.h(line) + + # lines starting with > are quoted + if m = line.match(/^>(.*)/) + line = "

    " << m[1] << "
    " + end + + lead = '\A|\s|[><]' + trail = '[<>]|\z|\s' + + # *text* -> text + line.gsub!(/(#{lead}|_|~)\*([^\* \t][^\*]*)\*(#{trail}|_|~)/) do |m| + "#{$1}" << self.h($2) << "#{$3}" + end + + # _text_ -> text + line.gsub!(/(#{lead}|~)_([^_ \t][^_]*)_(#{trail}|~)/) do |m| + "#{$1}" << self.h($2) << "#{$3}" + end + + # ~~text~~ -> text (from reddit) + line.gsub!(/(#{lead})\~\~([^~ \t][^~]*)\~\~(#{trail})/) do |m| + "#{$1}" << self.h($2) << "#{$3}" + end + + # [link text](http://url) -> link text + line.gsub!(/(#{lead})\[([^\]]+)\]\((http(s?):\/\/[^\)]+)\)(#{trail})/i) do |m| + "#{$1}" << + self.h($2) << "#{$5}" + end + + # find bare links that are not inside tags + + # http://blah -> + chunk = "" + intag = false + outline = "" + line.each_byte do |n| + c = n.chr + + if intag + outline << c + + if c == ">" + intag = false + next + end + else + if c == "<" + if chunk != "" + outline << Markdowner._linkify_text(chunk) + end + + chunk = "" + intag = true + outline << c + else + chunk << c + end + end + end + + if chunk != "" + outline << Markdowner._linkify_text(chunk) + end + + out << outline << "
    \n" + end + + if inpre + out << "" + end + + out << "

    " + + # multiple br's into a p + out.gsub!(/
    \n?
    \n?/, "

    ") + + # collapse things + out.gsub!(/
    \n?<\/p>/, "

    \n") + out.gsub!(/

    \n?
    \n?/, "

    ") + out.gsub!(/

    \n?<\/p>/, "\n") + out.gsub!(/

    \n?

    /, "\n

    ") + out.gsub!(/<\/p>

    /, "

    \n

    ") + + out.strip + end + + def self._linkify_text(chunk) + chunk.gsub(/ + (\A|\s|[:,]) + (http(s?):\/\/|www\.) + ([^\s]+)/ix) do |m| + pre = $1 + host_and_path = "#{$2 == "www." ? $2 : ""}#{$4}" + post = $5 + + # remove some chars that might be with a url at the end but aren't + # actually part of the url + if m = host_and_path.match(/(.*)([,\?;\!\.]+)\z/) + host_and_path = m[1] + post = "#{m[2]}#{post}" + end + + url = "http#{$3}://#{host_and_path}" + url_text = host_and_path + + if url_text.length > 50 + url_text = url_text[0 ... 50] << "..." + end + + "#{pre}#{url_text}#{post}" + end + end +end diff --git a/extras/sponge.rb b/extras/sponge.rb new file mode 100644 index 0000000..0b890a5 --- /dev/null +++ b/extras/sponge.rb @@ -0,0 +1,217 @@ +require "uri" +require "net/https" +require "resolv" +require "ipaddr" + +class Sponge + MAX_TIME = 60 + MAX_DNS_TIME = 5 + + attr_accessor :debug, :last_res, :timeout + + # rfc3330 + BAD_NETS = [ + "0.0.0.0/8", + "10.0.0.0/8", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.2.0/24", + "192.88.99.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "224.0.0.0/4", + "240.0.0.0/4" + ] + + # old api + def self.fetch(url, headers = {}, limit = 10) + s = Sponge.new + s.fetch(url, "get", nil, nil, headers, limit) + end + + def initialize + @cookies = {} + @timeout = MAX_TIME + end + + def set_cookie(host, name, val) + dputs "setting cookie #{name} on domain #{host} to #{val}" + + if !@cookies[host] + @cookies[host] = {} + end + + if val.to_s == "" + @cookies[host][name] ? @cookies[host][name].delete : nil + else + @cookies[host][name] = val + end + end + + def cookies(host) + cooks = @cookies[host] || {} + + # check for domain cookies + @cookies.keys.each do |dom| + if dom.length < host.length && + dom == host[host.length - dom.length .. host.length - 1] + dputs "adding domain keys from #{dom}" + cooks = cooks.merge @cookies[dom] + end + end + + if cooks + return cooks.map{|k,v| "#{k}=#{v};" }.join(" ") + else + return "" + end + end + + def fetch(url, method = :get, fields = nil, raw_post_data = nil, + headers = {}, limit = 10) + raise ArgumentError, "http redirection too deep" if limit <= 0 + + uri = URI.parse(url) + + # we'll manually resolve the ip so we can verify it's not local + ip = nil + tip = nil + ips = [] + retried = false + begin + Timeout.timeout(MAX_DNS_TIME) do + ips = Resolv.getaddresses(uri.host) + + if !ips.any? + raise + end + + # pick a random one + tip = ips[rand(ips.length)] + ip = IPAddr.new(tip) + end + rescue Timeout::Error => e + if retried + raise "couldn't resolve #{uri.host} (DNS timeout)" + else + retried = true + retry + end + rescue StandardError => e + raise "couldn't resolve #{uri.host} (#{e.inspect})" + end + + if !ip + raise "couldn't resolve #{uri.host}" + end + + if BAD_NETS.select{|n| IPAddr.new(n).include?(ip) }.any? + raise "refusing to talk to IP #{ip.to_s}" + end + + host = Net::HTTP.new(ip.to_s, uri.port) + if self.debug + host.set_debug_output $stdout + end + + if uri.scheme == "https" + host.use_ssl = true + host.verify_mode = OpenSSL::SSL::VERIFY_NONE + end + + path = (uri.path == "" ? "/" : uri.path) + if uri.query + path += "?" + uri.query + elsif method == :get && raw_post_data + path += "?" + URI.encode(raw_post_data) + headers["Content-type"] = "application/x-www-form-urlencoded" + end + + if method == :post + if raw_post_data + post_data = raw_post_data + headers["Content-type"] = "application/x-www-form-urlencoded" + else + post_data = fields.map{|k,v| "#{k}=#{v}" }.join("&") + end + + headers["Content-Length"] = post_data.length.to_s + end + + path.gsub!(/^\/\//, "/") + + dputs "fetching #{url} (#{ip.to_s}) " + (uri.user ? "with http auth " + + uri.user + "/" + ("*" * uri.password.length) + " " : "") + + "by #{method} with cookies #{cookies(uri.host)}" + + headers = { + "Host" => uri.host, + "Cookie" => cookies(uri.host), + "Referer" => url.to_s, + "User-Agent" => "Mozilla/5.0 (compatible)", + }.merge(headers || {}) + + if uri.user + headers["Authorization"] = "Basic " + + ["#{uri.user}:#{uri.password}"].pack('m').delete("\r\n") + end + + res = nil + Timeout.timeout(self.timeout) do + if method == :post + res = host.post(path, post_data, headers) + else + res = host.get(path, headers) + end + end + + if res.get_fields("Set-Cookie") + res.get_fields("Set-Cookie").each do |cook| + if p = Regexp.new(/^([^=]+)=([^;]*)/).match(cook) + set_cookie(uri.host, p[1], p[2]) + else + dputs "unable to match cookie line #{cook}" + end + end + end + + last_res = res + + case res + when Net::HTTPSuccess + return res.body + when Net::HTTPRedirection + # follow + newuri = URI.parse(res["location"]) + if newuri.host + dputs "following redirection to " + res["location"] + else + # relative path + newuri.host = uri.host + newuri.scheme = uri.scheme + newuri.port = uri.port + newuri.path = "/#{newuri.path}" + + dputs "following relative redirection to " + newuri.to_s + end + + fetch(newuri.to_s, "get", nil, nil, headers, limit - 1) + end + end + + def get(url) + fetch(url, "get") + end + + def post(url, fields) + fetch(url, "post", fields) + end + +private + def dputs(string) + if self.debug + puts string + end + end +end diff --git a/extras/utils.rb b/extras/utils.rb new file mode 100644 index 0000000..feb7fb4 --- /dev/null +++ b/extras/utils.rb @@ -0,0 +1,16 @@ +class Utils + def self.random_str(len) + str = "" + while str.length < len + chr = OpenSSL::Random.random_bytes(1) + ord = chr.unpack('C')[0] + + # 0 9 A Z a z + if (ord >= 48 && ord <= 57) || (ord >= 65 && ord <= 90) || (ord >= 97 && ord <= 122) + str += chr + end + end + + return str + end +end diff --git a/log/.gitkeep b/log/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..9a48320 --- /dev/null +++ b/public/404.html @@ -0,0 +1,26 @@ + + + + The page you were looking for doesn't exist (404) + + + + + +

    +

    The page you were looking for doesn't exist.

    +

    You may have mistyped the address or the page may have moved.

    +
    + + diff --git a/public/422.html b/public/422.html new file mode 100644 index 0000000..83660ab --- /dev/null +++ b/public/422.html @@ -0,0 +1,26 @@ + + + + The change you wanted was rejected (422) + + + + + +
    +

    The change you wanted was rejected.

    +

    Maybe you tried to change something you didn't have access to.

    +
    + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 0000000..f3648a0 --- /dev/null +++ b/public/500.html @@ -0,0 +1,25 @@ + + + + We're sorry, but something went wrong (500) + + + + + +
    +

    We're sorry, but something went wrong.

    +
    + + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e80e606a9790eee6f08abde15b7e09b4fa187b46 GIT binary patch literal 1406 zcmZQzU<5(|0R|w+!H~hqz#zuJz@P!dKp_SNAO?x!0=&X&82CiiFo;O5V~~*Bz@V

    Ezss-J|1d-lFyIDGUK!;xdJ z87^JRz77)a)eiE|<8W?&Z)7i2*a-~l=ZSnR<86Eib2Bb-ND F1^}rdHC6xs literal 0 HcmV?d00001 diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..085187f --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,5 @@ +# See http://www.robotstxt.org/wc/norobots.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-Agent: * +# Disallow: / diff --git a/script/rails b/script/rails new file mode 100755 index 0000000..f8da2cf --- /dev/null +++ b/script/rails @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. + +APP_PATH = File.expand_path('../../config/application', __FILE__) +require File.expand_path('../../config/boot', __FILE__) +require 'rails/commands' diff --git a/spec/models/markdowner_spec.rb b/spec/models/markdowner_spec.rb new file mode 100644 index 0000000..efdede9 --- /dev/null +++ b/spec/models/markdowner_spec.rb @@ -0,0 +1,104 @@ +require "spec_helper" + +def m(inp, out) + Markdowner::markdown(inp).should == out +end + +describe Markdowner do + it "converts indented text into

    " do
    +    m "  This is some\n  text.\n",
    +      "

      This is some\n  text.\n

    " + + m " blah ", + "

      blah <script>alert('hi');</script>\n

    " + end + + it "converts text surrounded by * to " do + m "oh hullo *there*", + "

    oh hullo there

    " + + m "*hi*", + "

    hi

    " + + m "* hi hello*zap zap*", + "

    * hi hello*zap zap*

    " + + m "oh hullo * there*", + "

    oh hullo * there*

    " + + m " oh hullo *there*", + "

      oh hullo *there*\n

    " + + m "oh hullo*there*", + "

    oh hullo*there*

    " + end + + it "converts text surrounded by _ to " do + m "oh hullo _there_", + "

    oh hullo there

    " + + m "oh hullo _ there_", + "

    oh hullo _ there_

    " + + m "oh hullo _there_ and *yes* i see", + "

    oh hullo there and yes i see

    " + end + + it "combines conversions" do + m "oh _*hullo*_ there_", + "

    oh hullo there_

    " + + m "oh *_hullo_* there_", + "

    oh hullo there_

    " + + m "oh *[hello](http://jcs.org/)* there_", + "

    oh hello" << + " there_

    " + end + + it "converts domain names to links" do + m "oh hullo www.google.com", + "

    oh hullo " << + "www.google.com

    " + end + + it "converts urls to links" do + # no trailing question mark + m "do you mean http://jcs.org? or", + "

    do you mean " << + "jcs.org? or

    " + + m "do you mean http://jcs.org?a", + "

    do you mean " << + "jcs.org?a

    " + + # no trailing dot in url + m "i like http://jcs.org.", + "

    i like " << + "jcs.org.

    " + + m "i like http://jcs.org/goose_blah_here", + "

    i like jcs.org/goose_blah_here

    " + end + + it "truncates long url titles" do + m "a long http://www.example.com/goes/here/and/this/is/a/long/" << + "url/which/should.get.shortened.html?because=this+will+cause+" << + "the+page+to+wrap&such+ok", + "

    a long " << + "www.example.com/goes/here/and/this/is/a/long/url/w...

    " + end + + it "converts markdown url format to links" do + m "this is a *[link](http://example.com/)*", + "

    this is a " << + "link

    " + + m "this is a [link](http://example.com/)", + "

    this is a " << + "link

    " + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..6953e45 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,32 @@ +# This file is copied to spec/ when you run 'rails generate rspec:install' +ENV["RAILS_ENV"] ||= 'test' +require File.expand_path("../../config/environment", __FILE__) +require 'rspec/rails' +require 'rspec/autorun' + +# Requires supporting ruby files with custom matchers and macros, etc, +# in spec/support/ and its subdirectories. +Dir[Rails.root.join("spec/support/**/*.rb")].each {|f| require f} + +RSpec.configure do |config| + # ## Mock Framework + # + # If you prefer to use mocha, flexmock or RR, uncomment the appropriate line: + # + # config.mock_with :mocha + # config.mock_with :flexmock + # config.mock_with :rr + + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures + #config.fixture_path = "#{::Rails.root}/spec/fixtures" + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, remove the following line or assign false + # instead of true. + config.use_transactional_fixtures = true + + # If true, the base class of anonymous controllers will be inferred + # automatically. This will be the default behavior in future versions of + # rspec-rails. + config.infer_base_class_for_anonymous_controllers = false +end diff --git a/spec/support/blueprints.rb b/spec/support/blueprints.rb new file mode 100644 index 0000000..2c5efe3 --- /dev/null +++ b/spec/support/blueprints.rb @@ -0,0 +1,7 @@ +require 'machinist/active_record' + +User.blueprint do + email { "user-#{sn}@example.com" } + password { "blah blah" } + password_confirmation { object.password } +end diff --git a/vendor/assets/javascripts/.gitkeep b/vendor/assets/javascripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/assets/stylesheets/.gitkeep b/vendor/assets/stylesheets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/plugins/.gitkeep b/vendor/plugins/.gitkeep new file mode 100644 index 0000000..e69de29