first commit
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
*.tmproj
|
||||
log/
|
||||
pid
|
||||
public/photos/*
|
||||
public/videos/*
|
||||
public/js-min
|
||||
public/css-min
|
||||
coverage
|
||||
19
Gemfile
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
source :rubygems
|
||||
|
||||
gem 'rack'
|
||||
gem 'rack-test'
|
||||
gem 'sinatra-flash'
|
||||
gem 'sinatra'
|
||||
gem 'redis', '>= 2.2.0', :require => ['redis', 'redis/connection/hiredis']
|
||||
gem 'thin'
|
||||
gem 'rmagick'
|
||||
gem 'bcrypt-ruby'
|
||||
gem 'pony'
|
||||
gem 'erubis'
|
||||
gem 'minitest'
|
||||
gem 'activesupport'
|
||||
gem 'uuid'
|
||||
gem 'sinatra-cookie_thief'
|
||||
gem 'rdiscount'
|
||||
gem 'redis-store', :git => 'https://github.com/betastreet/redis-store.git', :branch => '1.0.x'
|
||||
gem 'simplecov', :require => false
|
||||
83
Gemfile.lock
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
GIT
|
||||
remote: https://github.com/betastreet/redis-store.git
|
||||
revision: 1d4b475fb975a8ad361744346edc7172acf8869f
|
||||
branch: 1.0.x
|
||||
specs:
|
||||
redis-store (1.0.0.1)
|
||||
redis (~> 2.2.1)
|
||||
|
||||
GEM
|
||||
remote: http://rubygems.org/
|
||||
specs:
|
||||
activesupport (3.1.3)
|
||||
multi_json (~> 1.0)
|
||||
bcrypt-ruby (3.0.1)
|
||||
daemons (1.1.6)
|
||||
erubis (2.7.0)
|
||||
eventmachine (0.12.10)
|
||||
i18n (0.6.0)
|
||||
macaddr (1.5.0)
|
||||
systemu (>= 2.4.0)
|
||||
mail (2.4.1)
|
||||
i18n (>= 0.4.0)
|
||||
mime-types (~> 1.16)
|
||||
treetop (~> 1.4.8)
|
||||
mime-types (1.17.2)
|
||||
minitest (2.11.1)
|
||||
multi_json (1.0.4)
|
||||
polyglot (0.3.3)
|
||||
pony (1.4)
|
||||
mail (> 2.0)
|
||||
rack (1.4.1)
|
||||
rack-protection (1.2.0)
|
||||
rack
|
||||
rack-test (0.6.1)
|
||||
rack (>= 1.0)
|
||||
rdiscount (1.6.8)
|
||||
redis (2.2.2)
|
||||
rmagick (2.13.1)
|
||||
simplecov (0.5.4)
|
||||
multi_json (~> 1.0.3)
|
||||
simplecov-html (~> 0.5.3)
|
||||
simplecov-html (0.5.3)
|
||||
sinatra (1.3.2)
|
||||
rack (~> 1.3, >= 1.3.6)
|
||||
rack-protection (~> 1.2)
|
||||
tilt (~> 1.3, >= 1.3.3)
|
||||
sinatra-cookie_thief (0.1.1)
|
||||
sinatra (>= 1.0)
|
||||
sinatra-flash (0.3.0)
|
||||
sinatra (>= 1.0.0)
|
||||
systemu (2.4.2)
|
||||
thin (1.3.1)
|
||||
daemons (>= 1.0.9)
|
||||
eventmachine (>= 0.12.6)
|
||||
rack (>= 1.0.0)
|
||||
tilt (1.3.3)
|
||||
treetop (1.4.10)
|
||||
polyglot
|
||||
polyglot (>= 0.3.1)
|
||||
uuid (2.3.5)
|
||||
macaddr (~> 1.0)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
activesupport
|
||||
bcrypt-ruby
|
||||
erubis
|
||||
minitest
|
||||
pony
|
||||
rack
|
||||
rack-test
|
||||
rdiscount
|
||||
redis (>= 2.2.0)
|
||||
redis-store!
|
||||
rmagick
|
||||
simplecov
|
||||
sinatra
|
||||
sinatra-cookie_thief
|
||||
sinatra-flash
|
||||
thin
|
||||
uuid
|
||||
49
Rakefile
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'rake/testtask'
|
||||
|
||||
Rake::TestTask.new do |t|
|
||||
t.libs << 'test'
|
||||
t.test_files = FileList['test/**/test-*.rb']
|
||||
end
|
||||
|
||||
task 'nuke-test-data' do
|
||||
require 'rubygems'
|
||||
require 'bundler/setup'
|
||||
require 'redis'
|
||||
|
||||
redis = Redis.new
|
||||
redis.keys('TEST:stormy:*').each do |key|
|
||||
redis.del key
|
||||
end
|
||||
end
|
||||
|
||||
MinifiedCSSDir = 'public/css-min'
|
||||
MinifiedJSDir = 'public/js-min'
|
||||
|
||||
desc "Minifies JS and CSS"
|
||||
task :minify do
|
||||
puts "Minifying JavaScript..."
|
||||
Dir.mkdir(MinifiedJSDir) unless File.exists?(MinifiedJSDir)
|
||||
Dir['public/js/*.js'].each do |path|
|
||||
filename = File.basename(path)
|
||||
out = File.join(MinifiedJSDir, filename)
|
||||
if !File.exists?(out) || File.mtime(path) > File.mtime(out)
|
||||
puts " * #{filename}"
|
||||
`bin/closure <"#{path}" >"#{out}"`
|
||||
end
|
||||
end
|
||||
|
||||
puts "Minifying CSS..."
|
||||
Dir.mkdir(MinifiedCSSDir) unless File.exists?(MinifiedCSSDir)
|
||||
Dir['public/css/*.css'].each do |path|
|
||||
filename = File.basename(path)
|
||||
out = File.join(MinifiedCSSDir, filename)
|
||||
if !File.exists?(out) || File.mtime(path) > File.mtime(out)
|
||||
puts " * #{filename}"
|
||||
`bin/yui-compressor "#{path}" "#{out}"`
|
||||
end
|
||||
end
|
||||
|
||||
puts "Done."
|
||||
end
|
||||
68
Readme.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Stormy Weather
|
||||
|
||||
A web server blueprint using Sinatra and Redis.
|
||||
|
||||
Storm Weather is only a vague idea. Trying to nail down a lightweight blueprint for
|
||||
projects that have user accounts and admin accounts.
|
||||
|
||||
|
||||
## Models
|
||||
|
||||
### Account Model
|
||||
|
||||
### Admin Model
|
||||
|
||||
|
||||
## Controllers
|
||||
|
||||
### Account Controller
|
||||
|
||||
### Admin Controller
|
||||
|
||||
### Public Controller
|
||||
|
||||
|
||||
## Helpers
|
||||
|
||||
### Accounts
|
||||
|
||||
### Admin
|
||||
|
||||
### Authorization
|
||||
|
||||
### FAQ
|
||||
|
||||
### Utils
|
||||
|
||||
### Views
|
||||
|
||||
|
||||
## Tests
|
||||
|
||||
% rake test
|
||||
|
||||
The tests are pretty slow. Mocking out Redis didn't improve the speed of tests. Advice on speeding them up is greatly appreciated.
|
||||
|
||||
|
||||
## Thanks
|
||||
|
||||
These truly are gems. Thanks to all the maintainers.
|
||||
|
||||
- ActiveSupport
|
||||
- Bcrypt
|
||||
- Erubis
|
||||
- Pony
|
||||
- Rack and Rack::Test
|
||||
- RDiscount
|
||||
- Redis
|
||||
- Rmagick
|
||||
- SimpleCov
|
||||
- Sinatra
|
||||
- Thin
|
||||
|
||||
|
||||
## License
|
||||
|
||||
[Licensed under the terms of the MIT license](http://sjs.mit-license.org).
|
||||
|
||||
© 2012 Sami Samhuri <[sami@samhuri.net](mailto:sami@samhuri.net)>
|
||||
3
bin/closure
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/bash
|
||||
|
||||
java -jar $(dirname $0)/compiler.jar "$@"
|
||||
BIN
bin/compiler.jar
Normal file
16
bin/console
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env ruby
|
||||
#
|
||||
# Bash version:
|
||||
# exec irb --readline --simple-prompt -r irb/completion -I $(dirname "$0")/../lib -r stormy
|
||||
|
||||
$LOAD_PATH << File.expand_path('../lib', File.dirname(__FILE__))
|
||||
require 'stormy'
|
||||
require 'stormy/models'
|
||||
|
||||
include Stormy::Models
|
||||
|
||||
require 'irb'
|
||||
require 'irb/completion'
|
||||
|
||||
ARGV << '--simple-prompt'
|
||||
IRB.start
|
||||
7
bin/mkpass.rb
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env ruby
|
||||
|
||||
require 'rubygems'
|
||||
require 'bundler/setup'
|
||||
require 'bcrypt'
|
||||
|
||||
puts BCrypt::Password.create(ARGV.first)
|
||||
5
bin/restart.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/zsh
|
||||
|
||||
[[ -d /web/stormy ]] && cd /web/stormy
|
||||
bin/stop.sh
|
||||
bin/start.sh
|
||||
9
bin/start.rb
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env ruby
|
||||
#
|
||||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
$LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib')
|
||||
require 'stormy'
|
||||
require 'stormy/server'
|
||||
|
||||
Stormy::Server.run!
|
||||
17
bin/start.sh
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
#!/bin/zsh
|
||||
|
||||
[[ -d "$HOME/.rbenv" ]] && export PATH="$HOME/.rbenv/shims:$PATH"
|
||||
|
||||
if [[ "$RACK_ENV" = "development" ]]; then
|
||||
exec shotgun -s thin -o 0.0.0.0 -p 5000 config.ru
|
||||
else
|
||||
[[ -d /web/stormy ]] && cd /web/stormy
|
||||
[[ -d log ]] || mkdir log
|
||||
RACK_ENV=production bin/start.rb >>|log/access.log 2>>|log/access.log &!
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo $! >|./pid
|
||||
else
|
||||
echo "!! Failed to start. Last bit of the log:"
|
||||
tail -n 20 log/access.log
|
||||
fi
|
||||
fi
|
||||
22
bin/stop.sh
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
#!/bin/zsh
|
||||
|
||||
[[ -d /web/stormy ]] && cd /web/stormy
|
||||
|
||||
if [[ -r pid ]]; then
|
||||
|
||||
PID=$(cat pid)
|
||||
RETRIES=3
|
||||
|
||||
while [[ $RETRIES -gt 0 ]] && ps ax | grep "${PID}[ ]" >/dev/null; do
|
||||
kill $PID
|
||||
sleep 1
|
||||
RETRIES=$((RETRIES - 1))
|
||||
done
|
||||
|
||||
if ps ax | grep "${PID}[ ]" >/dev/null; then
|
||||
kill -9 $PID
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
rm pid
|
||||
fi
|
||||
3
bin/yui-compressor
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/bash
|
||||
|
||||
java -jar $(dirname $0)/yuicompressor-2.4.7.jar "$1" -o "$2"
|
||||
BIN
bin/yuicompressor-2.4.7.jar
Normal file
9
config.ru
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env ruby
|
||||
#
|
||||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
$LOAD_PATH.unshift('lib')
|
||||
require 'stormy'
|
||||
require 'stormy/server'
|
||||
|
||||
run Stormy::Server
|
||||
12
lib/hash-ext.rb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
class Hash
|
||||
|
||||
def slice(*keys)
|
||||
keys.inject({}) do |h, k|
|
||||
h[k] = self[k] if has_key?(k)
|
||||
h
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
37
lib/stormy.rb
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'date'
|
||||
|
||||
require 'rubygems'
|
||||
require 'bundler/setup'
|
||||
require 'active_support/core_ext'
|
||||
|
||||
this_dir = File.dirname(__FILE__)
|
||||
$LOAD_PATH.unshift(this_dir) unless $LOAD_PATH.include?(this_dir)
|
||||
|
||||
module Stormy
|
||||
|
||||
# key prefix for data stored in Redis (used for testing)
|
||||
unless const_defined? :KeyPrefix
|
||||
KeyPrefix = ''
|
||||
end
|
||||
|
||||
# public directory for project photos
|
||||
unless const_defined? :PhotoDir
|
||||
PhotoDir = File.expand_path('../public/photos', File.dirname(__FILE__))
|
||||
end
|
||||
|
||||
# public directory for project videos
|
||||
unless const_defined? :VideoDir
|
||||
VideoDir = File.expand_path('../public/videos', File.dirname(__FILE__))
|
||||
end
|
||||
|
||||
def self.key_prefix
|
||||
@key_prefix ||= "#{KeyPrefix}stormy:"
|
||||
end
|
||||
|
||||
def self.key(*components)
|
||||
key_prefix + components.compact.join(':')
|
||||
end
|
||||
|
||||
end
|
||||
75
lib/stormy/config.rb
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'redis'
|
||||
|
||||
module Stormy
|
||||
|
||||
class Config
|
||||
|
||||
DefaultConfig = {
|
||||
}
|
||||
|
||||
ConfigTypes = {
|
||||
}
|
||||
|
||||
# shared instance
|
||||
def self.instance
|
||||
@@instance ||= new
|
||||
end
|
||||
|
||||
def initialize
|
||||
reload!
|
||||
if config.size == 0 && DefaultConfig.size > 0
|
||||
redis.hmset(config_key, *DefaultConfig.to_a.flatten)
|
||||
reload!
|
||||
end
|
||||
end
|
||||
|
||||
def config_key
|
||||
@config_key ||= Stormy.key('config')
|
||||
end
|
||||
|
||||
def config
|
||||
@config ||= redis.hgetall(config_key)
|
||||
end
|
||||
|
||||
def redis
|
||||
@redis ||= Redis.new
|
||||
end
|
||||
|
||||
def reload!
|
||||
@config = nil
|
||||
config
|
||||
if config.size > 0
|
||||
ConfigTypes.each do |name, type|
|
||||
if type == :integer
|
||||
config[name] = config[name].to_i
|
||||
elsif type == :boolean
|
||||
config[name] = config[name] == 'true'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def method_missing(name, *args)
|
||||
name = name.to_s
|
||||
# TODO: decide if we should call super for unknown names
|
||||
if name.ends_with?('=')
|
||||
name = name.sub(/=$/, '')
|
||||
value = args.first
|
||||
redis.hset(config_key, name, value)
|
||||
config[name] = value
|
||||
elsif config.has_key?(name)
|
||||
config[name]
|
||||
elsif DefaultConfig.has_key?(name)
|
||||
value = DefaultConfig[name]
|
||||
redis.hset(config_key, name, value)
|
||||
config[name] = value
|
||||
else
|
||||
super(name.to_sym, *args)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
5
lib/stormy/controllers.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
Dir[File.dirname(__FILE__) + '/controllers/*.rb'].each do |f|
|
||||
require 'stormy/controllers/' + File.basename(f)
|
||||
end
|
||||
221
lib/stormy/controllers/accounts_controller.rb
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
module Stormy
|
||||
class Server < Sinatra::Base
|
||||
|
||||
get '/sign-up' do
|
||||
redirect '/projects' if authorized? && production?
|
||||
|
||||
title 'Sign Up'
|
||||
stylesheet 'sign-up'
|
||||
script 'sign-up'
|
||||
@errors = session.delete(:errors) if session[:errors]
|
||||
@fields = session.delete(:fields) || {}
|
||||
erb :'sign-up'
|
||||
end
|
||||
|
||||
post '/sign-up' do
|
||||
session.delete('source') if session['source']
|
||||
|
||||
fields = params.slice(*Account.fields.map { |name, options| name.to_s if options[:updatable] }.compact)
|
||||
%w[email password].each do |name|
|
||||
fields[name] = params[name]
|
||||
end
|
||||
|
||||
begin
|
||||
@account = Account.create(fields)
|
||||
authorize_account(@account.id)
|
||||
send_verification_mail(@account, 'Welcome to Stormy Weather!')
|
||||
redirect '/projects'
|
||||
|
||||
rescue Account::EmailTakenError => e
|
||||
flash[:warning] = "That email address is already taken."
|
||||
session[:fields] = fields
|
||||
session[:fields]['terms'] = params['terms']
|
||||
session[:errors] = { 'email' => 'taken' }
|
||||
redirect '/sign-up'
|
||||
|
||||
rescue Account::InvalidDataError => e
|
||||
flash[:warning] = "There's a small problem with your info."
|
||||
session[:fields] = fields
|
||||
session[:fields]['terms'] = params['terms']
|
||||
session[:errors] = e.fields
|
||||
if session[:errors].has_key?('hashed_password')
|
||||
session[:errors]['password'] = session[:errors].delete('hashed_password')
|
||||
end
|
||||
redirect '/sign-up'
|
||||
end
|
||||
end
|
||||
|
||||
get '/sign-in' do
|
||||
redirect '/projects' if authorized? && production?
|
||||
|
||||
title 'Sign In'
|
||||
stylesheet 'sign-in'
|
||||
script 'sign-in'
|
||||
@email = session.delete(:email)
|
||||
erb :'sign-in'
|
||||
end
|
||||
|
||||
post '/sign-in' do
|
||||
if id = Account.check_password(params['email'], params['password'])
|
||||
authorize_account(id)
|
||||
if params['remember'] == 'on'
|
||||
response.set_cookie('remembered', {
|
||||
:value => current_account.id,
|
||||
:path => '/',
|
||||
:expires => Time.now + 2.weeks,
|
||||
:httponly => true
|
||||
})
|
||||
else
|
||||
response.delete_cookie('remembered')
|
||||
end
|
||||
url = session.delete(:original_url) || '/projects'
|
||||
redirect url
|
||||
else
|
||||
flash[:warning] = "Incorrect email address or password."
|
||||
redirect '/sign-in'
|
||||
end
|
||||
end
|
||||
|
||||
post '/sign-out' do
|
||||
deauthorize
|
||||
redirect '/'
|
||||
end
|
||||
|
||||
get '/forgot-password/?:email?' do |email|
|
||||
title 'Forgot Password'
|
||||
script 'forgot-password'
|
||||
@email = email
|
||||
erb :'forgot-password'
|
||||
end
|
||||
|
||||
post '/forgot-password' do
|
||||
if params['email'].blank?
|
||||
flash[:warning] = "Enter your email address so we can send you a link to reset your password."
|
||||
redirect '/forgot-password'
|
||||
elsif send_reset_password_mail(params['email'])
|
||||
flash[:notice] = "A link to reset your password was sent to #{escape_html(params['email'])}."
|
||||
redirect '/sign-in'
|
||||
else
|
||||
flash[:warning] = "We don't have an account for #{escape_html(params['email'])}."
|
||||
redirect '/forgot-password'
|
||||
end
|
||||
end
|
||||
|
||||
# reset password
|
||||
get '/sign-in/:email/:token' do |email, token|
|
||||
if id = Account.use_password_reset_token(email, token)
|
||||
authorize_account(id)
|
||||
title 'Reset My Password'
|
||||
stylesheet 'reset-password'
|
||||
script 'reset-password'
|
||||
erb :'reset-password'
|
||||
else
|
||||
flash[:warning] = "Unknown or expired link to reset password."
|
||||
redirect '/forgot-password/' + email
|
||||
end
|
||||
end
|
||||
|
||||
post '/account/reset-password' do
|
||||
authorize!
|
||||
current_account.password = params['password']
|
||||
current_account.save!
|
||||
redirect '/projects'
|
||||
end
|
||||
|
||||
get '/account' do
|
||||
authorize!
|
||||
title 'Account'
|
||||
stylesheet 'account'
|
||||
script 'jquery.jeditable'
|
||||
script 'account'
|
||||
script 'account-editable'
|
||||
@account = current_account
|
||||
erb :account
|
||||
end
|
||||
|
||||
post '/account/password' do
|
||||
content_type :json
|
||||
authorize_api!
|
||||
|
||||
begin
|
||||
raise Account::InvalidDataError unless params['new-password'] == params['password-confirmation']
|
||||
current_account.update_password(params['old-password'], params['new-password'])
|
||||
ok
|
||||
rescue Account::IncorrectPasswordError => e
|
||||
fail('incorrect')
|
||||
rescue Account::InvalidDataError => e
|
||||
fail('invalid')
|
||||
end
|
||||
end
|
||||
|
||||
post '/account/update' do
|
||||
authorize_api!
|
||||
|
||||
begin
|
||||
current_account.update({ params['id'] => params['value'] })
|
||||
params['value']
|
||||
rescue Account::InvalidDataError => e
|
||||
# This is lame but gives the desired result with jEditable
|
||||
bad_request
|
||||
end
|
||||
end
|
||||
|
||||
post '/account/update.json' do
|
||||
content_type :json
|
||||
authorize_api!
|
||||
|
||||
begin
|
||||
if params['id'] == 'email'
|
||||
old_email = current_account.email
|
||||
new_email = params['value']
|
||||
current_account.update_email(new_email)
|
||||
if old_email.downcase != new_email.downcase
|
||||
send_verification_mail unless current_account.email_verified?
|
||||
end
|
||||
else
|
||||
# decode booleans
|
||||
if params['id'].match(/_notifications$/)
|
||||
if params['value'] == 'true'
|
||||
params['value'] = true
|
||||
elsif params['value'] == 'false'
|
||||
params['value'] = false
|
||||
end
|
||||
end
|
||||
current_account.update({ params['id'] => params['value'] })
|
||||
end
|
||||
ok
|
||||
rescue Account::EmailTakenError => e
|
||||
fail('taken')
|
||||
rescue Account::InvalidDataError => e
|
||||
fail('invalid')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
####################
|
||||
### Verification ###
|
||||
####################
|
||||
|
||||
get '/account/verify/:email/:token' do |email, token|
|
||||
if Account.verify_email(email, token)
|
||||
authorize_account(Account.id_from_email(email)) unless authorized?
|
||||
flash[:notice] = "Your email address has been verified."
|
||||
redirect '/account'
|
||||
elsif authorized?
|
||||
redirect '/account'
|
||||
else
|
||||
erb :'verification-failed'
|
||||
end
|
||||
end
|
||||
|
||||
post '/account/send-email-verification' do
|
||||
content_type :json
|
||||
authorize_api!
|
||||
send_verification_mail
|
||||
ok
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
217
lib/stormy/controllers/admin_controller.rb
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
module Stormy
|
||||
class Server < Sinatra::Base
|
||||
|
||||
get '/admin' do
|
||||
admin_authorize!
|
||||
title "Dashboard"
|
||||
erb :'admin/dashboard', :layout => :'admin/layout'
|
||||
end
|
||||
|
||||
get '/admin/sign-in' do
|
||||
title "Sign In"
|
||||
script 'sign-in'
|
||||
stylesheet 'sign-in'
|
||||
erb :'admin/sign-in', :layout => :'admin/layout'
|
||||
end
|
||||
|
||||
post '/admin/sign-in' do
|
||||
if id = Admin.check_password(params['email'], params['password'])
|
||||
authorize_admin(id)
|
||||
redirect session.delete(:original_url) || '/admin'
|
||||
else
|
||||
flash[:notice] = "Incorrect email address or password."
|
||||
redirect '/admin/sign-in'
|
||||
end
|
||||
end
|
||||
|
||||
post '/admin/sign-out' do
|
||||
session.delete(:admin_id)
|
||||
redirect '/admin'
|
||||
end
|
||||
|
||||
get '/admin/password' do
|
||||
admin_authorize!
|
||||
title 'Change password'
|
||||
erb :'admin/password', :layout => :'admin/layout'
|
||||
end
|
||||
|
||||
post '/admin/password' do
|
||||
admin_authorize!
|
||||
if params['password'] == params['password_confirmation']
|
||||
current_admin.password = params['password']
|
||||
current_admin.save
|
||||
flash[:notice] = "Password changed."
|
||||
redirect '/admin'
|
||||
else
|
||||
flash[:warning] = "Passwords do not match."
|
||||
redirect '/admin/password'
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
################
|
||||
### Accounts ###
|
||||
################
|
||||
|
||||
get '/admin/accounts' do
|
||||
admin_authorize!
|
||||
mark_last_listing
|
||||
title "Accounts"
|
||||
@accounts = Account.fetch_all.sort { |a,b| a.name <=> b.name }
|
||||
erb :'admin/accounts', :layout => :'admin/layout'
|
||||
end
|
||||
|
||||
get '/admin/account/:email' do |email|
|
||||
admin_authorize!
|
||||
if @account = Account.fetch_by_email(email)
|
||||
mark_last_listing
|
||||
title "#{@account.name}'s Account"
|
||||
script 'admin-account'
|
||||
erb :'admin/account', :layout => :'admin/layout'
|
||||
else
|
||||
flash[:notice] = "No account with email #{email}"
|
||||
redirect last_listing
|
||||
end
|
||||
end
|
||||
|
||||
get '/admin/sign-in-as/:email' do |email|
|
||||
admin_authorize!
|
||||
authorize_account(Account.id_from_email(email))
|
||||
redirect '/projects'
|
||||
end
|
||||
|
||||
get '/admin/account/:email/delete' do |email|
|
||||
admin_authorize!
|
||||
if @account = Account.fetch_by_email(email)
|
||||
@account.delete!
|
||||
end
|
||||
redirect last_listing
|
||||
end
|
||||
|
||||
post '/admin/account/:email' do |email|
|
||||
admin_authorize!
|
||||
if @account = Account.fetch_by_email(email)
|
||||
email_changed = params['new_email'].present? && params['new_email'] != @account.email
|
||||
fields = params.merge({
|
||||
'email_verified' => email_changed ? true : @account.email_verified
|
||||
})
|
||||
fields.delete('splat')
|
||||
fields.delete('captures')
|
||||
fields.delete('email')
|
||||
new_email = fields.delete('new_email')
|
||||
new_email = @account.email if new_email.blank?
|
||||
if new_email != @account.email
|
||||
begin
|
||||
@account.update_email(new_email)
|
||||
rescue Account::EmailTakenError => e
|
||||
flash[:warning] = "That email address is already taken."
|
||||
redirect '/admin/account/' + email
|
||||
end
|
||||
end
|
||||
begin
|
||||
@account.update!(fields, :validate => true)
|
||||
flash[:notice] = "Account updated."
|
||||
rescue Account::InvalidDataError => e
|
||||
flash[:warning] = "Invalid fields: #{e.fields.inspect}"
|
||||
end
|
||||
redirect '/admin/account/' + new_email
|
||||
else
|
||||
flash[:notice] = "No account with email #{email}"
|
||||
redirect last_listing
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
################
|
||||
### Projects ###
|
||||
################
|
||||
|
||||
get '/admin/projects' do
|
||||
admin_authorize!
|
||||
mark_last_listing
|
||||
title "Projects"
|
||||
@projects = Project.fetch_all.sort { |a,b| a.id <=> b.id }
|
||||
erb :'admin/projects', :layout => :'admin/layout'
|
||||
end
|
||||
|
||||
get '/admin/project/:id' do |id|
|
||||
admin_authorize!
|
||||
if @project = Project.fetch(id)
|
||||
title "Project ##{id}"
|
||||
title "#{title} (#{@project.name})" if @project.name
|
||||
script 'admin-project'
|
||||
erb :'admin/project', :layout => :'admin/layout'
|
||||
else
|
||||
flash[:notice] = "No such project (ID #{id})."
|
||||
redirect last_listing
|
||||
end
|
||||
end
|
||||
|
||||
get '/admin/project/:id/delete' do |id|
|
||||
admin_authorize!
|
||||
Project.delete!(id)
|
||||
redirect last_listing
|
||||
end
|
||||
|
||||
|
||||
###########
|
||||
### FAQ ###
|
||||
###########
|
||||
|
||||
get '/admin/faq' do
|
||||
admin_authorize!
|
||||
title 'FAQ'
|
||||
@faq = faq
|
||||
if @faq.blank?
|
||||
@faq = <<-EOT
|
||||
<p class="question">1. Are you my mother?</p>
|
||||
<p class="answer">Yes my son.</p>
|
||||
EOT
|
||||
end
|
||||
erb :'admin/faq', :layout => :'admin/layout'
|
||||
end
|
||||
|
||||
post '/admin/faq' do
|
||||
admin_authorize!
|
||||
self.faq = params['faq']
|
||||
flash[:notice] = "FAQ saved."
|
||||
redirect '/admin/faq'
|
||||
end
|
||||
|
||||
|
||||
######################
|
||||
### Admin Accounts ###
|
||||
######################
|
||||
|
||||
get '/admin/admins' do
|
||||
admin_authorize!
|
||||
@admins = Admin.fetch_all.sort { |a,b| a.name <=> b.name }
|
||||
@fields = session.delete(:fields) || {}
|
||||
title 'Admin Accounts'
|
||||
stylesheet 'admins'
|
||||
erb :'admin/admins', :layout => :'admin/layout'
|
||||
end
|
||||
|
||||
post '/admin/admins' do
|
||||
admin_authorize!
|
||||
if params['password'] == params['password_confirmation']
|
||||
admin = Admin.create(params)
|
||||
flash[:notice] = "Added #{params['name']} (#{params['email']}) as an admin."
|
||||
else
|
||||
session[:fields] = params.slice('name', 'email')
|
||||
flash[:warning] = "Passwords do not match."
|
||||
end
|
||||
redirect '/admin/admins'
|
||||
end
|
||||
|
||||
get '/admin/admins/:id/delete' do |id|
|
||||
admin_authorize!
|
||||
Admin.delete!(id)
|
||||
flash[:notice] = "Deleted."
|
||||
redirect '/admin/admins'
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
123
lib/stormy/controllers/projects_controller.rb
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# Copyright 2011 Beta Street Media
|
||||
|
||||
module Stormy
|
||||
class Server < Sinatra::Base
|
||||
|
||||
get '/projects' do
|
||||
authorize!
|
||||
@projects = current_account.sorted_projects
|
||||
title 'Projects'
|
||||
stylesheet 'projects'
|
||||
script 'projects'
|
||||
erb :projects
|
||||
end
|
||||
|
||||
get '/project/:id' do |id|
|
||||
authorize_project!(id)
|
||||
if current_project.name.blank?
|
||||
title "Project ID #{id}"
|
||||
else
|
||||
title current_project.name
|
||||
end
|
||||
stylesheet 'jquery.lightbox-0.5'
|
||||
script 'jquery.lightbox-0.5'
|
||||
script 'jquery.dragsort'
|
||||
stylesheet 'edit-project'
|
||||
script 'edit-project'
|
||||
|
||||
# fuck IE
|
||||
if request.user_agent.match(/msie/i)
|
||||
stylesheet 'uploadify'
|
||||
script 'swfobject'
|
||||
script 'jquery.uploadify.v2.1.4'
|
||||
end
|
||||
|
||||
@errors = session.delete('errors')
|
||||
@project = current_project
|
||||
erb :'edit-project'
|
||||
end
|
||||
|
||||
post '/project/update' do
|
||||
id = params['id']
|
||||
if admin_authorized?
|
||||
current_project(id)
|
||||
else
|
||||
authorize_project!(id)
|
||||
end
|
||||
|
||||
begin
|
||||
current_project.update(params)
|
||||
|
||||
flash[:notice] = "Project saved."
|
||||
rescue Project::InvalidDataError => e
|
||||
flash[:warning] = "There are some errors with your project."
|
||||
session['errors'] = e.fields
|
||||
end
|
||||
|
||||
redirect '/project/' + params['id']
|
||||
end
|
||||
|
||||
post '/project/add-photo' do
|
||||
content_type :json
|
||||
id = params['id']
|
||||
if admin_authorized?
|
||||
current_project(id)
|
||||
else
|
||||
authorize_project_api!(id)
|
||||
end
|
||||
|
||||
if photo = current_project.add_photo(params['photo'][:tempfile].path)
|
||||
ok({
|
||||
'n' => current_project.count_photos,
|
||||
'photo' => photo
|
||||
})
|
||||
else
|
||||
fail('limit')
|
||||
end
|
||||
end
|
||||
|
||||
# fuck IE
|
||||
post '/uploadify' do
|
||||
content_type :json
|
||||
authorize_project_api!(params['id'])
|
||||
if photo = current_project.add_photo(params['Filedata'][:tempfile].path)
|
||||
ok({
|
||||
'n' => current_project.count_photos,
|
||||
'photo' => photo
|
||||
})
|
||||
else
|
||||
content_type 'text/plain'
|
||||
bad_request
|
||||
end
|
||||
end
|
||||
|
||||
post '/project/remove-photo' do
|
||||
content_type :json
|
||||
if admin_authorized?
|
||||
current_project(params['id'])
|
||||
else
|
||||
authorize_project_api!(params['id'])
|
||||
end
|
||||
|
||||
current_project.remove_photo(params['photo_id'])
|
||||
ok({
|
||||
'photos' => current_project.photos
|
||||
})
|
||||
end
|
||||
|
||||
post '/project/photo-order' do
|
||||
content_type :json
|
||||
id = params['id']
|
||||
if admin_authorized?
|
||||
current_project(id)
|
||||
else
|
||||
authorize_project_api!(id)
|
||||
end
|
||||
|
||||
current_project.photo_ids = params['order']
|
||||
current_project.save!
|
||||
ok
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
49
lib/stormy/controllers/public_controller.rb
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
|
||||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
module Stormy
|
||||
class Server < Sinatra::Base
|
||||
|
||||
get '/' do
|
||||
cache_control :public, :must_revalidate, :max_age => 60
|
||||
stylesheet 'index'
|
||||
stylesheet 'jquery.lightbox-0.5'
|
||||
script 'jquery.lightbox-0.5'
|
||||
script 'index'
|
||||
erb :index
|
||||
end
|
||||
|
||||
get '/contact' do
|
||||
cache_control :public, :must_revalidate, :max_age => 60
|
||||
title 'Contact'
|
||||
stylesheet 'contact'
|
||||
script 'contact'
|
||||
erb :contact
|
||||
end
|
||||
|
||||
post '/contact' do
|
||||
Pony.mail({
|
||||
:to => 'info@example.com',
|
||||
:from => params['email'],
|
||||
:subject => 'Stormy Weather Contact Form',
|
||||
:body => params['message']
|
||||
})
|
||||
flash[:notice] = "Thanks for contacting us!"
|
||||
redirect '/contact'
|
||||
end
|
||||
|
||||
get '/terms' do
|
||||
cache_control :public, :must_revalidate, :max_age => 60
|
||||
title 'Terms of Service'
|
||||
erb :terms
|
||||
end
|
||||
|
||||
get '/faq' do
|
||||
@faq = faq
|
||||
title 'Frequently Asked Questions'
|
||||
stylesheet 'faq'
|
||||
erb :faq
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
5
lib/stormy/helpers.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
Dir[File.dirname(__FILE__) + '/helpers/*.rb'].each do |f|
|
||||
require 'stormy/helpers/' + File.basename(f)
|
||||
end
|
||||
45
lib/stormy/helpers/accounts.rb
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
module Stormy
|
||||
module Helpers
|
||||
module Accounts
|
||||
|
||||
include Stormy::Models
|
||||
|
||||
def send_reset_password_mail(email)
|
||||
if data = Account.reset_password(email)
|
||||
body = erb(:'email/reset-password', :layout => :'email/layout', :locals => {
|
||||
:name => data['name'],
|
||||
:email => email,
|
||||
:sign_in_url => url_for('sign-in', email, data['token'])
|
||||
})
|
||||
Pony.mail({
|
||||
:to => email,
|
||||
:from => 'support@example.com',
|
||||
:subject => 'Reset your Stormy Weather password',
|
||||
:headers => { 'Content-Type' => 'text/html' },
|
||||
:body => body
|
||||
})
|
||||
data
|
||||
end
|
||||
end
|
||||
|
||||
def send_verification_mail(account = current_account, subject = nil)
|
||||
account.create_email_verification_token
|
||||
body = erb(:'email/email-verification', :layout => :'email/layout', :locals => {
|
||||
:name => account.first_name,
|
||||
:email => account.email,
|
||||
:url => url_for('account/verify', account.email, account.email_verification_token)
|
||||
})
|
||||
Pony.mail({
|
||||
:to => account.email,
|
||||
:from => 'support@example.com',
|
||||
:subject => subject || 'Verify your Stormy Weather account',
|
||||
:headers => { 'Content-Type' => 'text/html' },
|
||||
:body => body
|
||||
})
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
38
lib/stormy/helpers/admin.rb
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
module Stormy
|
||||
module Helpers
|
||||
module Admin
|
||||
|
||||
include Stormy::Models
|
||||
|
||||
def num_accounts
|
||||
Account.count
|
||||
end
|
||||
|
||||
def num_admins
|
||||
Models::Admin.count
|
||||
end
|
||||
|
||||
def num_projects
|
||||
Project.count
|
||||
end
|
||||
|
||||
# Used to redirect back to the most recent list of things.
|
||||
#
|
||||
# i.e. someone goes to /admin -> /admin/account/foo -> /admin/project/007
|
||||
# if they delete that project they should go back to /admin/account/foo
|
||||
#
|
||||
# however if they go /admin -> /admin/projects -> /admin/project/007
|
||||
# and then delete that project they should go back to /admin/projects
|
||||
def last_listing
|
||||
session.delete(:last_listing) || '/admin'
|
||||
end
|
||||
|
||||
def mark_last_listing(path = request.path_info)
|
||||
session[:last_listing] = path
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
108
lib/stormy/helpers/authorization.rb
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
module Stormy
|
||||
module Helpers
|
||||
module Authorization
|
||||
|
||||
include Stormy::Models
|
||||
|
||||
def authorize_account(id)
|
||||
session[:id] = id
|
||||
end
|
||||
|
||||
def authorized?
|
||||
if !session[:id] && id = request.cookies['remembered']
|
||||
authorize_account(id)
|
||||
end
|
||||
session[:id] && Account.exists?(session[:id])
|
||||
end
|
||||
|
||||
def authorize!
|
||||
unless authorized?
|
||||
session[:original_url] = request.url
|
||||
redirect '/sign-in'
|
||||
end
|
||||
end
|
||||
|
||||
def authorize_api!
|
||||
unless authorized?
|
||||
content_type 'text/plain'
|
||||
throw(:halt, not_authorized)
|
||||
end
|
||||
end
|
||||
|
||||
def deauthorize
|
||||
session.delete(:id)
|
||||
response.delete_cookie('remembered')
|
||||
end
|
||||
|
||||
def current_account
|
||||
if session[:id]
|
||||
@current_account ||= Account.fetch(session[:id])
|
||||
end
|
||||
end
|
||||
|
||||
def current_project(id = nil)
|
||||
if id
|
||||
@current_project = Project.fetch(id)
|
||||
else
|
||||
@current_project
|
||||
end
|
||||
end
|
||||
|
||||
def project_authorized?
|
||||
current_project && current_account && current_project.account_id == current_account.id
|
||||
end
|
||||
|
||||
def authorize_project_api!(id)
|
||||
authorize_api!
|
||||
current_project(id)
|
||||
throw(:halt, fail('no such project')) unless current_project
|
||||
unless project_authorized?
|
||||
content_type 'text/plain'
|
||||
throw(:halt, not_authorized)
|
||||
end
|
||||
end
|
||||
|
||||
def authorize_project!(id)
|
||||
authorize!
|
||||
current_project(id)
|
||||
unless current_project && project_authorized?
|
||||
flash[:warning] = 'No such project.'
|
||||
redirect '/projects'
|
||||
end
|
||||
end
|
||||
|
||||
def authorize_admin(id)
|
||||
session[:admin_id] = id
|
||||
end
|
||||
|
||||
def deauthorize_admin
|
||||
session.delete(:admin_id)
|
||||
end
|
||||
|
||||
def admin_authorized?
|
||||
session[:admin_id] && Models::Admin.exists?(session[:admin_id])
|
||||
end
|
||||
|
||||
def admin_authorize!
|
||||
unless admin_authorized?
|
||||
session[:original_url] = request.url
|
||||
redirect '/admin'
|
||||
end
|
||||
end
|
||||
|
||||
def admin_authorize_api!
|
||||
unless admin_authorized?
|
||||
content_type 'text/plain'
|
||||
throw(:halt, not_authorized)
|
||||
end
|
||||
end
|
||||
|
||||
def current_admin
|
||||
@current_admin ||= Models::Admin.fetch(session[:admin_id])
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
23
lib/stormy/helpers/faq.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
module Stormy
|
||||
module Helpers
|
||||
module FAQ
|
||||
|
||||
def faq
|
||||
redis.get(faq_key)
|
||||
end
|
||||
|
||||
def faq=(new_faq)
|
||||
redis.set(faq_key, new_faq)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def faq_key
|
||||
@faq_key ||= Stormy.key('faq')
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
65
lib/stormy/helpers/utils.rb
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'stormy/config'
|
||||
|
||||
module Stormy
|
||||
module Helpers
|
||||
module Utils
|
||||
|
||||
def config
|
||||
@config ||= Stormy::Config.instance
|
||||
end
|
||||
|
||||
def redis
|
||||
@redis ||= Redis.new
|
||||
end
|
||||
|
||||
def testing?
|
||||
ENV['RACK_ENV'] == 'test'
|
||||
end
|
||||
|
||||
def production?
|
||||
ENV['RACK_ENV'] == 'production'
|
||||
end
|
||||
|
||||
def development?
|
||||
ENV['RACK_ENV'] == 'development'
|
||||
end
|
||||
|
||||
# JSON responses for API endpoints
|
||||
|
||||
def ok(data = nil)
|
||||
if data.nil?
|
||||
{ 'status' => 'ok' }
|
||||
else
|
||||
{ 'status' => 'ok', 'data' => data }
|
||||
end.to_json
|
||||
end
|
||||
|
||||
def fail(reason = nil)
|
||||
if reason.nil?
|
||||
{ 'status' => 'fail' }
|
||||
else
|
||||
{ 'status' => 'fail', 'reason' => reason }
|
||||
end.to_json
|
||||
end
|
||||
|
||||
def bad_request
|
||||
[400, "Bad request\n"]
|
||||
end
|
||||
|
||||
def not_authorized
|
||||
[403, "Not authorized\n"]
|
||||
end
|
||||
|
||||
def base_url
|
||||
@base_url ||= production? ? "http://dev.example.com:#{settings.port}/" : "http://localhost:#{settings.port}/"
|
||||
end
|
||||
|
||||
def url_for(*args)
|
||||
"#{base_url}#{args.join('/')}"
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
121
lib/stormy/helpers/views.rb
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'rdiscount'
|
||||
|
||||
module Stormy
|
||||
module Helpers
|
||||
module Views
|
||||
|
||||
def escape_html(s)
|
||||
Rack::Utils::escape_html(s)
|
||||
end
|
||||
|
||||
def script(name)
|
||||
if name.match(/^(https?:)?\/\//)
|
||||
scripts << name
|
||||
elsif production?
|
||||
scripts << "/js-min/#{name}.js"
|
||||
else
|
||||
scripts << "/js/#{name}.js"
|
||||
end
|
||||
end
|
||||
|
||||
def scripts
|
||||
@page_scripts ||= [
|
||||
'//ajax.googleapis.com/ajax/libs/jquery/1.7/jquery.min.js',
|
||||
"/js#{production? ? '-min' : ''}/jquery.placeholder.js",
|
||||
"/js#{production? ? '-min' : ''}/common.js"
|
||||
]
|
||||
end
|
||||
|
||||
def stylesheet(name)
|
||||
if production?
|
||||
stylesheets << "/css-min/#{name}.css"
|
||||
else
|
||||
stylesheets << "/css/#{name}.css"
|
||||
end
|
||||
end
|
||||
|
||||
def stylesheets
|
||||
@page_styles ||= ["/css#{production? ? '-min' : ''}/common.css"]
|
||||
end
|
||||
|
||||
def title(title = nil)
|
||||
@page_title = title if title
|
||||
@page_title
|
||||
end
|
||||
|
||||
def flash_message
|
||||
if flash[:notice]
|
||||
klass = 'notice'
|
||||
message = flash[:notice]
|
||||
elsif flash[:warning]
|
||||
klass = 'warning'
|
||||
message = flash[:warning]
|
||||
elsif flash[:error]
|
||||
klass = 'error'
|
||||
message = flash[:error]
|
||||
else
|
||||
klass = flash.keys.first
|
||||
message = flash[klass] if klass
|
||||
end
|
||||
if message
|
||||
"<div id=\"flash\" class=\"#{klass}\">#{message}</div>"
|
||||
end
|
||||
end
|
||||
|
||||
def format_dollars(amount, currency = 'CAD')
|
||||
'%s $%.2f' % [currency, amount / 100.0]
|
||||
end
|
||||
|
||||
def format_date(date)
|
||||
date.strftime("%B %e, %Y")
|
||||
end
|
||||
|
||||
def format_time(time)
|
||||
time.strftime('%B %e, %Y %l:%M %p')
|
||||
end
|
||||
|
||||
def pad(n)
|
||||
n < 10 ? "0#{n}" : "#{n}"
|
||||
end
|
||||
|
||||
def format_duration(duration)
|
||||
mins = duration / 60
|
||||
secs = duration % 60
|
||||
"#{pad(mins)}:#{pad(secs)}"
|
||||
end
|
||||
|
||||
def ordinal_day(day)
|
||||
th = case day
|
||||
when 1
|
||||
'st'
|
||||
when 2
|
||||
'nd'
|
||||
when 3
|
||||
'rd'
|
||||
when 21
|
||||
'st'
|
||||
when 22
|
||||
'nd'
|
||||
when 23
|
||||
'rd'
|
||||
when 31
|
||||
'st'
|
||||
else
|
||||
'th'
|
||||
end
|
||||
"#{day}#{th}"
|
||||
end
|
||||
|
||||
def format_percent(percent)
|
||||
"#{(100 * percent).to_i}%"
|
||||
end
|
||||
|
||||
def markdown(s)
|
||||
RDiscount.new(s.to_s).to_html
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
7
lib/stormy/models.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'stormy/models/base'
|
||||
|
||||
Dir[File.dirname(__FILE__) + '/models/*.rb'].each do |f|
|
||||
require 'stormy/models/' + File.basename(f)
|
||||
end
|
||||
226
lib/stormy/models/account.rb
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'bcrypt'
|
||||
require 'uuid'
|
||||
|
||||
module Stormy
|
||||
module Models
|
||||
class Account < Base
|
||||
|
||||
class EmailTakenError < RuntimeError; end
|
||||
class IncorrectPasswordError < RuntimeError; end
|
||||
|
||||
name 'account'
|
||||
|
||||
field :id, :required => true
|
||||
|
||||
field :email, :type => :email, :required => true
|
||||
field :first_name, :required => true, :updatable => true
|
||||
field :last_name, :required => true, :updatable => true
|
||||
field :phone, :type => :phone, :updatable => true
|
||||
|
||||
field :hashed_password, :required => true
|
||||
field :password
|
||||
|
||||
field :created_timestamp, :type => :integer
|
||||
field :email_verification_token, :nullify_if_blank => true
|
||||
field :email_verified?
|
||||
field :password_reset_token, :nullify_if_blank => true
|
||||
|
||||
@@account_email_index_key = Stormy.key('index:account-email')
|
||||
|
||||
|
||||
### Class Methods
|
||||
|
||||
def self.check_password(email, password)
|
||||
id = id_from_email(email)
|
||||
key = self.key(id)
|
||||
if key
|
||||
hashed_password = BCrypt::Password.new(redis.hget(key, 'hashed_password'))
|
||||
id if hashed_password == password
|
||||
end
|
||||
end
|
||||
|
||||
def self.email_taken?(email)
|
||||
!! redis.hget(@@account_email_index_key, email.to_s.strip.downcase)
|
||||
end
|
||||
|
||||
def self.fetch_by_email(email)
|
||||
if id = id_from_email(email)
|
||||
fetch(id)
|
||||
end
|
||||
end
|
||||
|
||||
def self.reset_password(email)
|
||||
if key = key_from_email(email)
|
||||
token = redis.hget(key, 'password_reset_token')
|
||||
if token.blank?
|
||||
token = UUID.generate
|
||||
redis.hset(key, 'password_reset_token', token)
|
||||
end
|
||||
{ 'name' => redis.hget(key, 'first_name'),
|
||||
'token' => token
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def self.use_password_reset_token(email, token)
|
||||
if id = id_from_email(email)
|
||||
key = key(id)
|
||||
expected_token = redis.hget(key, 'password_reset_token')
|
||||
if token == expected_token
|
||||
redis.hdel(key, 'password_reset_token')
|
||||
id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.id_from_email(email)
|
||||
redis.hget(@@account_email_index_key, email.strip.downcase)
|
||||
end
|
||||
|
||||
def self.verify_email(email, token)
|
||||
if key = key_from_email(email)
|
||||
expected_token = redis.hget(key, 'email_verification_token')
|
||||
verified = token == expected_token
|
||||
if verified
|
||||
redis.hdel(key, 'email_verification_token')
|
||||
redis.hset(key, 'email_verified', true)
|
||||
end
|
||||
verified
|
||||
end
|
||||
end
|
||||
|
||||
def self.email_verified?(email)
|
||||
if key = key_from_email(email)
|
||||
redis.hget(key, 'email_verified') == 'true'
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
### Private Class Methods
|
||||
|
||||
def self.key_from_email(email)
|
||||
key(id_from_email(email))
|
||||
end
|
||||
|
||||
private_class_method :key_from_email
|
||||
|
||||
|
||||
### Instance Methods
|
||||
|
||||
def initialize(fields = {}, options = {})
|
||||
super(fields, options)
|
||||
|
||||
if fields['hashed_password']
|
||||
self.hashed_password = BCrypt::Password.new(fields['hashed_password'])
|
||||
else
|
||||
self.password = fields['password']
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
raise EmailTakenError if email_taken?
|
||||
|
||||
# new accounts get an id and timestamp
|
||||
self.id = UUID.generate unless id.present?
|
||||
self.created_timestamp = Time.now.to_i
|
||||
|
||||
super
|
||||
|
||||
create_email_verification_token
|
||||
|
||||
# add to index
|
||||
redis.hset(@@account_email_index_key, email.downcase, id)
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def delete!
|
||||
project_ids.each { |id| Project.delete!(id) }
|
||||
super
|
||||
redis.hdel(@@account_email_index_key, email.strip.downcase)
|
||||
end
|
||||
|
||||
def email_taken?(email = @email)
|
||||
self.class.email_taken?(email)
|
||||
end
|
||||
|
||||
def create_email_verification_token
|
||||
self.email_verification_token ||= UUID.generate
|
||||
redis.hset(key, 'email_verification_token', email_verification_token)
|
||||
email_verification_token
|
||||
end
|
||||
|
||||
def password
|
||||
@password ||= BCrypt::Password.new(hashed_password)
|
||||
end
|
||||
|
||||
def password=(new_password)
|
||||
if new_password.present?
|
||||
self.hashed_password = BCrypt::Password.create(new_password)
|
||||
@password = nil
|
||||
end
|
||||
end
|
||||
|
||||
def name
|
||||
"#{first_name} #{last_name}"
|
||||
end
|
||||
|
||||
def count_projects
|
||||
redis.scard(project_ids_key)
|
||||
end
|
||||
|
||||
def project_ids
|
||||
redis.smembers(project_ids_key)
|
||||
end
|
||||
|
||||
def projects
|
||||
project_ids.map { |pid| Project.fetch(pid) }
|
||||
end
|
||||
|
||||
def sorted_projects
|
||||
@sorted_projects ||= projects.sort { |a,b| a.created_timestamp <=> b.created_timestamp }
|
||||
end
|
||||
|
||||
def add_project_id(id)
|
||||
redis.sadd(project_ids_key, id)
|
||||
end
|
||||
|
||||
def remove_project_id(id)
|
||||
redis.srem(project_ids_key, id)
|
||||
end
|
||||
|
||||
def update_email(new_email)
|
||||
new_email = new_email.strip
|
||||
if email != new_email
|
||||
raise EmailTakenError if new_email.downcase != email.downcase && email_taken?(new_email)
|
||||
raise InvalidDataError.new({ 'email' => 'invalid' }) unless field_valid?('email', new_email)
|
||||
if email.downcase != new_email.downcase
|
||||
self.email_verified = false
|
||||
redis.hdel(@@account_email_index_key, email.downcase)
|
||||
redis.hset(@@account_email_index_key, new_email.downcase, id)
|
||||
end
|
||||
self.email = new_email
|
||||
save!
|
||||
end
|
||||
end
|
||||
|
||||
def update_password(old_password, new_password)
|
||||
hashed_password = BCrypt::Password.new(redis.hget(key, 'hashed_password'))
|
||||
raise IncorrectPasswordError unless hashed_password == old_password
|
||||
raise InvalidDataError.new({ 'password' => 'missing' }) if new_password.blank?
|
||||
redis.hset(key, 'hashed_password', BCrypt::Password.create(new_password))
|
||||
self.password = new_password
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def project_ids_key
|
||||
@project_ids_key ||= "#{key}:project-ids"
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
124
lib/stormy/models/admin.rb
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'bcrypt'
|
||||
require 'uuid'
|
||||
|
||||
module Stormy
|
||||
module Models
|
||||
class Admin < Base
|
||||
|
||||
class EmailTakenError < RuntimeError; end
|
||||
class IncorrectPasswordError < RuntimeError; end
|
||||
|
||||
name 'admin'
|
||||
|
||||
field :id, :required => true
|
||||
|
||||
field :name, :required => true, :updatable => true
|
||||
field :email, :type => :email, :required => true
|
||||
field :hashed_password, :required => true
|
||||
field :password
|
||||
|
||||
@@admin_email_index_key = Stormy.key('index:admin-email')
|
||||
|
||||
|
||||
### Class Methods
|
||||
|
||||
def self.key_from_email(email)
|
||||
key(id_from_email(email))
|
||||
end
|
||||
|
||||
def self.check_password(email, password)
|
||||
id = id_from_email(email)
|
||||
key = self.key(id)
|
||||
if key
|
||||
hashed_password = BCrypt::Password.new(redis.hget(key, 'hashed_password'))
|
||||
id if hashed_password == password
|
||||
end
|
||||
end
|
||||
|
||||
def self.email_taken?(email)
|
||||
!! redis.hget(@@admin_email_index_key, email.to_s.strip.downcase)
|
||||
end
|
||||
|
||||
def self.fetch_by_email(email)
|
||||
key = key_from_email(email)
|
||||
new(redis.hgetall(key)) if key
|
||||
end
|
||||
|
||||
def self.id_from_email(email)
|
||||
redis.hget(@@admin_email_index_key, email.strip.downcase)
|
||||
end
|
||||
|
||||
|
||||
### Instance Methods
|
||||
|
||||
def initialize(fields = {}, options = {})
|
||||
super(fields, options)
|
||||
|
||||
if fields['hashed_password']
|
||||
self.hashed_password = BCrypt::Password.new(fields['hashed_password'])
|
||||
else
|
||||
self.password = fields['password']
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
raise EmailTakenError if email_taken?
|
||||
|
||||
self.id = UUID.generate unless id.present?
|
||||
|
||||
super
|
||||
|
||||
# add to index
|
||||
redis.hset(@@admin_email_index_key, @email.downcase, @id)
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def delete!
|
||||
if super
|
||||
redis.hdel(@@admin_email_index_key, @email.strip.downcase)
|
||||
end
|
||||
end
|
||||
|
||||
def email_taken?(email = @email)
|
||||
self.class.email_taken?(email)
|
||||
end
|
||||
|
||||
def password
|
||||
@password ||= BCrypt::Password.new(hashed_password)
|
||||
end
|
||||
|
||||
def password=(new_password)
|
||||
if new_password.present?
|
||||
self.hashed_password = BCrypt::Password.create(new_password)
|
||||
@password = nil
|
||||
end
|
||||
end
|
||||
|
||||
def update_email(new_email)
|
||||
new_email = new_email.strip
|
||||
if email != new_email
|
||||
raise EmailTakenError if new_email.downcase != email.downcase && email_taken?(new_email)
|
||||
raise InvalidDataError.new({ 'email' => 'invalid' }) unless field_valid?('email', new_email)
|
||||
if email.downcase != new_email.downcase
|
||||
redis.hdel(@@admin_email_index_key, email.downcase)
|
||||
redis.hset(@@admin_email_index_key, new_email.downcase, id)
|
||||
end
|
||||
self.email = new_email
|
||||
save!
|
||||
end
|
||||
end
|
||||
|
||||
def update_password(old_password, new_password)
|
||||
hashed_password = BCrypt::Password.new(redis.hget(key, 'hashed_password'))
|
||||
raise IncorrectPasswordError unless hashed_password == old_password
|
||||
raise InvalidDataError.new({ 'password' => 'missing' }) if new_password.blank?
|
||||
redis.hset(key, 'hashed_password', BCrypt::Password.create(new_password))
|
||||
self.password = new_password
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
404
lib/stormy/models/base.rb
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'json'
|
||||
require 'redis'
|
||||
require 'uuid'
|
||||
|
||||
module Stormy
|
||||
module Models
|
||||
class Base
|
||||
|
||||
class InvalidDataError < RuntimeError
|
||||
attr_reader :fields
|
||||
def initialize(invalid_fields = {})
|
||||
@fields = invalid_fields
|
||||
end
|
||||
end
|
||||
|
||||
def self.clean_number(number)
|
||||
number.gsub(/[^\d]/, '').sub(/^1/, '')
|
||||
end
|
||||
|
||||
# Allows any 10 digit number in North America, or an empty field (for account creation).
|
||||
PhoneNumberValidator = proc do |number|
|
||||
if number.present?
|
||||
clean_number(number).length == 10
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
# Liberal email address regex
|
||||
EmailAddressValidator = proc { |email| email =~ /^[^@]+@[^.@]+(\.[^.@]+)+$/ }
|
||||
|
||||
|
||||
# Only changed fields are persisted on save
|
||||
attr_reader :changed_fields
|
||||
|
||||
|
||||
#####################
|
||||
### Class Methods ###
|
||||
#####################
|
||||
|
||||
@@redis = Redis.new
|
||||
|
||||
def self.redis
|
||||
@@redis
|
||||
end
|
||||
|
||||
|
||||
# Define or retrieve the name of this model.
|
||||
def self.name(name = nil)
|
||||
if name
|
||||
@model_name = name
|
||||
end
|
||||
@model_name
|
||||
end
|
||||
|
||||
|
||||
# Hash of all fields.
|
||||
def self.fields
|
||||
@fields ||= {}
|
||||
end
|
||||
|
||||
|
||||
# Define fields like so:
|
||||
#
|
||||
# field :id, :type => :integer, :required => true
|
||||
# field :name, :required => true, :updatable => true
|
||||
# field :verified?
|
||||
#
|
||||
# Defaults: {
|
||||
# :type => :string,
|
||||
# :required => false,
|
||||
# :updatable => false,
|
||||
# :validator => nil, # with some exceptions
|
||||
# :default => {},
|
||||
# :nullify_if_blank => false
|
||||
# }
|
||||
#
|
||||
# Types: :string, :integer, :boolean, :json, as well as
|
||||
# :email and :phone which are string aliases with the
|
||||
# appropriate validations. String fields have an option
|
||||
# :nullify_if_blank that will initialize and set fields
|
||||
# to `nil` if they are empty.
|
||||
#
|
||||
# If an `integer` is required it must be greater than zero.
|
||||
# The required option has no effect on boolean fields.
|
||||
#
|
||||
# Fields with names ending with question mark are boolean.
|
||||
#
|
||||
# JSON fields accept a :default option used to initialize
|
||||
# a JSON field, and also when a parse fails.
|
||||
#
|
||||
# Attribute accessors are defined for each field and boolean
|
||||
# fields get a predicate method as well, e.g. verified?
|
||||
#
|
||||
# Changed fields are tracked and only changed fields are
|
||||
# persisted on a `save`.
|
||||
#
|
||||
def self.field(name, options = {})
|
||||
if name.to_s.ends_with?('?')
|
||||
options[:type] = :boolean
|
||||
name = name.to_s[0..-2]
|
||||
end
|
||||
|
||||
name = name.to_sym
|
||||
options[:type] ||= :string
|
||||
|
||||
case options[:type]
|
||||
when :email
|
||||
options[:validator] ||= EmailAddressValidator
|
||||
options[:type] = :string
|
||||
when :phone
|
||||
options[:validator] ||= PhoneNumberValidator
|
||||
options[:type] = :string
|
||||
when :json
|
||||
options[:default] ||= {}
|
||||
end
|
||||
|
||||
fields[name] = options
|
||||
define_method(name) do
|
||||
instance_variable_get("@#{name}")
|
||||
end
|
||||
|
||||
case options[:type]
|
||||
when :string
|
||||
define_method("#{name}=") do |value|
|
||||
s =
|
||||
if options[:nullify_if_blank] && value.blank?
|
||||
nil
|
||||
else
|
||||
value.to_s.strip
|
||||
end
|
||||
instance_variable_set("@#{name}", s)
|
||||
changed_fields[name] = s
|
||||
end
|
||||
|
||||
when :integer
|
||||
define_method("#{name}=") do |value|
|
||||
i = value.to_i
|
||||
instance_variable_set("@#{name}", i)
|
||||
changed_fields[name] = i
|
||||
end
|
||||
|
||||
when :boolean
|
||||
define_method("#{name}=") do |value|
|
||||
b = value == 'true' || value == true
|
||||
instance_variable_set("@#{name}", b)
|
||||
changed_fields[name] = b
|
||||
end
|
||||
define_method("#{name}?") do
|
||||
instance_variable_get("@#{name}")
|
||||
end
|
||||
|
||||
when :json
|
||||
define_method(name) do
|
||||
unless value = instance_variable_get("@#{name}")
|
||||
value = options[:default].dup
|
||||
send("#{name}=", value)
|
||||
end
|
||||
value
|
||||
end
|
||||
define_method("#{name}=") do |value|
|
||||
obj =
|
||||
if value.is_a?(String)
|
||||
if value.length > 0
|
||||
JSON.parse(value)
|
||||
else
|
||||
options[:default].dup
|
||||
end
|
||||
else
|
||||
value
|
||||
end
|
||||
instance_variable_set("@#{name}", obj)
|
||||
changed_fields[name] = obj
|
||||
end
|
||||
|
||||
else
|
||||
define_method("#{name}=") do |value|
|
||||
instance_variable_set("@#{name}", value)
|
||||
changed_fields[name] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
# internal
|
||||
def self.model_ids_key
|
||||
@model_ids_key ||= Stormy.key("#{@model_name}-ids")
|
||||
end
|
||||
|
||||
|
||||
def self.create(fields = {})
|
||||
new(fields).create
|
||||
end
|
||||
|
||||
def self.delete!(id)
|
||||
if obj = fetch(id)
|
||||
obj.delete!
|
||||
end
|
||||
end
|
||||
|
||||
def self.exists?(id)
|
||||
redis.sismember(model_ids_key, id)
|
||||
end
|
||||
|
||||
def self.fetch(id)
|
||||
if id && exists?(id)
|
||||
new(redis.hgetall(key(id)), :fetched => true)
|
||||
end
|
||||
end
|
||||
|
||||
def self.fetch_all
|
||||
list_ids.map { |id| fetch(id) }
|
||||
end
|
||||
|
||||
def self.key(id)
|
||||
Stormy.key(@model_name, id) if id
|
||||
end
|
||||
|
||||
def self.list_ids
|
||||
redis.smembers(model_ids_key)
|
||||
end
|
||||
|
||||
def self.count
|
||||
redis.scard(model_ids_key)
|
||||
end
|
||||
|
||||
|
||||
### Instance Methods
|
||||
|
||||
attr_accessor :redis
|
||||
|
||||
def initialize(fields = {}, options = {})
|
||||
self.redis = self.class.redis
|
||||
|
||||
fields = fields.symbolize_keys
|
||||
field_names.each do |name|
|
||||
send("#{name}=", fields[name])
|
||||
end
|
||||
|
||||
# no changed fields yet if we have been fetched
|
||||
if options[:fetched]
|
||||
@changed_fields = {}
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
# raises if invalid
|
||||
save
|
||||
add_to_index
|
||||
self
|
||||
end
|
||||
|
||||
def delete!
|
||||
if redis.srem(self.class.model_ids_key, id)
|
||||
redis.del(key)
|
||||
end
|
||||
end
|
||||
|
||||
def reload!
|
||||
initialize(redis.hgetall(key))
|
||||
self
|
||||
end
|
||||
|
||||
# Convenient defaults for performing safe updates.
|
||||
def update!(fields, options = {})
|
||||
options[:validate] = false unless options.has_key?(:validate)
|
||||
options[:all] = true unless options.has_key?(:all)
|
||||
update(fields, options)
|
||||
end
|
||||
|
||||
# The `update` method only updates fields marked updatable.
|
||||
# Unless you pass in :all => true, then all fields are
|
||||
# updated.
|
||||
#
|
||||
# There's also a :validate flag.
|
||||
#
|
||||
def update(fields, options = {})
|
||||
options[:validate] = true unless options.has_key?(:validate)
|
||||
fields.each do |name, value|
|
||||
if options[:all] || field_updatable?(name)
|
||||
send("#{name}=", value)
|
||||
end
|
||||
end
|
||||
if options[:validate]
|
||||
save
|
||||
else
|
||||
save!
|
||||
end
|
||||
end
|
||||
|
||||
def save
|
||||
validate
|
||||
save!
|
||||
end
|
||||
|
||||
def save!
|
||||
# always update JSON fields because they can be updated without our knowledge
|
||||
field_names.each do |name|
|
||||
if field_type(name) == :json
|
||||
changed_fields[name] = send(name)
|
||||
end
|
||||
end
|
||||
|
||||
fields = changed_fields.map do |name, value|
|
||||
if field_type(name) == :json && !value.is_a?(String)
|
||||
[name, JSON.fast_generate(value || field_default(name))]
|
||||
else
|
||||
[name, value]
|
||||
end
|
||||
end
|
||||
if fields.length > 0
|
||||
redis.hmset(key, *fields.flatten)
|
||||
end
|
||||
@changed_fields = {}
|
||||
end
|
||||
|
||||
def validate
|
||||
invalid_fields = field_names.inject({}) do |fields, name|
|
||||
if field_validates?(name)
|
||||
result = validate_field(name, send(name))
|
||||
fields[name] = result[:reason] unless result[:valid]
|
||||
end
|
||||
fields
|
||||
end
|
||||
if invalid_fields.length > 0
|
||||
raise InvalidDataError.new(invalid_fields.stringify_keys)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def key
|
||||
@key ||= self.class.key(self.id)
|
||||
end
|
||||
|
||||
def add_to_index
|
||||
redis.sadd(self.class.model_ids_key, self.id)
|
||||
end
|
||||
|
||||
def changed_fields
|
||||
@changed_fields ||= {}
|
||||
end
|
||||
|
||||
def clean_number(number)
|
||||
self.class.clean_number(number)
|
||||
end
|
||||
|
||||
def field_names
|
||||
self.class.fields.keys
|
||||
end
|
||||
|
||||
def field_type(name)
|
||||
self.class.fields[name.to_sym][:type]
|
||||
end
|
||||
|
||||
def field_updatable?(name)
|
||||
self.class.fields[name.to_sym][:updatable]
|
||||
end
|
||||
|
||||
def validate_field(name, value)
|
||||
valid = true
|
||||
reason = nil
|
||||
field = self.class.fields[name.to_sym]
|
||||
type = field[:type]
|
||||
if field[:required]
|
||||
case
|
||||
when type == :string && value.blank?
|
||||
valid = false
|
||||
reason = 'missing'
|
||||
when type == :integer && value.to_i <= 0
|
||||
valid = false
|
||||
reason = 'missing'
|
||||
when type == :json && value.blank?
|
||||
valid = false
|
||||
reason = 'missing'
|
||||
end
|
||||
end
|
||||
if valid && validator = field[:validator]
|
||||
valid = validator.call(value)
|
||||
reason = 'invalid'
|
||||
end
|
||||
{ :valid => valid, :reason => reason }
|
||||
end
|
||||
|
||||
def field_valid?(name, value)
|
||||
result = validate_field(name, value)
|
||||
result[:valid]
|
||||
end
|
||||
|
||||
def field_validates?(name)
|
||||
field = self.class.fields[name.to_sym]
|
||||
field[:required] || field[:validator]
|
||||
end
|
||||
|
||||
def field_default(name)
|
||||
self.class.fields[name][:default]
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
162
lib/stormy/models/project.rb
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'digest/sha1'
|
||||
require 'fileutils'
|
||||
require 'RMagick'
|
||||
|
||||
module Stormy
|
||||
module Models
|
||||
class Project < Base
|
||||
|
||||
# max width or height in pixels
|
||||
MaxPhotoSize = 1200
|
||||
MaxPhotos = 10
|
||||
|
||||
name 'project'
|
||||
|
||||
field :id, :required => true
|
||||
|
||||
field :name, :required => true, :updatable => true
|
||||
|
||||
field :account_id
|
||||
field :created_timestamp, :type => :integer, :required => true
|
||||
field :fizzled_timestamp, :type => :integer
|
||||
field :funded_timestamp, :type => :integer
|
||||
field :photo_ids, :type => :json, :default => []
|
||||
|
||||
@@project_name_index_key = Stormy.key('index:project-name')
|
||||
|
||||
def self.fetch_by_name(name)
|
||||
if id = id_from_name(name)
|
||||
fetch(id)
|
||||
end
|
||||
end
|
||||
|
||||
def self.id_from_name(name)
|
||||
redis.hget(@@project_name_index_key, name.strip.downcase)
|
||||
end
|
||||
|
||||
def create
|
||||
self.id = UUID.generate unless id.present?
|
||||
self.created_timestamp = Time.now.to_i
|
||||
|
||||
super
|
||||
|
||||
# add to index
|
||||
redis.hset(@@project_name_index_key, name.downcase, id)
|
||||
|
||||
account.add_project_id(id) if account
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def delete!
|
||||
if super
|
||||
remove_all_photos!
|
||||
account.remove_project_id(id) if account
|
||||
redis.hdel(@@project_name_index_key, name.strip.downcase)
|
||||
end
|
||||
end
|
||||
|
||||
def funded?
|
||||
funded_timestamp > 0
|
||||
end
|
||||
|
||||
def funded!
|
||||
self.funded_timestamp = Time.now.to_i
|
||||
save!
|
||||
end
|
||||
|
||||
def fizzled?
|
||||
fizzled_timestamp > 0
|
||||
end
|
||||
|
||||
def fizzled!
|
||||
self.fizzled_timestamp = Time.now.to_i
|
||||
save!
|
||||
end
|
||||
|
||||
def count_photos
|
||||
photo_ids.length
|
||||
end
|
||||
|
||||
def add_photo(path)
|
||||
unless count_photos >= MaxPhotos
|
||||
photo = Magick::Image.read(path).first
|
||||
photo.auto_orient!
|
||||
photo.change_geometry("#{MaxPhotoSize}x#{MaxPhotoSize}>") { |cols, rows, img| img.resize!(cols, rows) }
|
||||
photo.format = 'jpg'
|
||||
|
||||
FileUtils.mkdir_p(photo_dir) unless File.exists?(photo_dir)
|
||||
|
||||
photo_id = Digest::SHA1.hexdigest(photo.to_blob)
|
||||
photo.write(photo_path(photo_id)) { self.quality = 80 }
|
||||
|
||||
photo_ids << photo_id
|
||||
save!
|
||||
|
||||
photo_data(photo_id)
|
||||
end
|
||||
end
|
||||
|
||||
def remove_photo(photo_id)
|
||||
path = photo_path(photo_id)
|
||||
if i = photo_ids.index(photo_id)
|
||||
photo_ids.delete_at(i)
|
||||
end
|
||||
FileUtils.rm(path) if File.exists?(path) && !photo_ids.include?(photo_id)
|
||||
save!
|
||||
end
|
||||
|
||||
def photo_paths
|
||||
photo_ids.map { |id| photo_path(id) }
|
||||
end
|
||||
|
||||
def photo_urls
|
||||
photo_ids.map { |photo_id| "/photos/#{id}/#{photo_id}.jpg" }
|
||||
end
|
||||
|
||||
def photo_url(photo_id)
|
||||
"/photos/#{id}/#{photo_id}.jpg"
|
||||
end
|
||||
|
||||
def photo_data(photo_id)
|
||||
{
|
||||
'id' => photo_id,
|
||||
'url' => photo_url(photo_id)
|
||||
}
|
||||
end
|
||||
|
||||
def photos
|
||||
photo_ids.map { |id| photo_data(id) }
|
||||
end
|
||||
|
||||
def account
|
||||
if account_id
|
||||
@account ||= Account.fetch(account_id)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def photo_dir
|
||||
File.join(Stormy::PhotoDir, @id)
|
||||
end
|
||||
|
||||
def photo_path(id)
|
||||
File.join(photo_dir, "#{id}.jpg")
|
||||
end
|
||||
|
||||
def photos_key
|
||||
"#{key}:photos"
|
||||
end
|
||||
|
||||
def remove_all_photos!
|
||||
FileUtils.rm_rf(photo_dir) if File.exists?(photo_dir)
|
||||
self.photo_ids = []
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
93
lib/stormy/server.rb
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'time'
|
||||
|
||||
require 'sinatra'
|
||||
require 'sinatra/cookie_thief'
|
||||
require 'sinatra/flash'
|
||||
|
||||
require 'erubis'
|
||||
require 'json'
|
||||
require 'pony'
|
||||
require 'redis'
|
||||
require 'redis-store'
|
||||
require 'uuid'
|
||||
|
||||
# Ruby extensions
|
||||
require 'hash-ext'
|
||||
|
||||
require 'stormy/models'
|
||||
require 'stormy/controllers'
|
||||
require 'stormy/helpers'
|
||||
|
||||
module Stormy
|
||||
|
||||
class Server < Sinatra::Base
|
||||
|
||||
set :port, 5000
|
||||
|
||||
configure :production do
|
||||
enable :dump_errors
|
||||
|
||||
# compress responses
|
||||
use Rack::Deflater
|
||||
|
||||
# cache static files for an hour
|
||||
set :static_cache_control, [ :must_revalidate, :max_age => 60 ]
|
||||
end
|
||||
|
||||
enable :logging
|
||||
|
||||
# serve static files from /public, views from /views
|
||||
set :public_folder, File.dirname(__FILE__) + '/../../public'
|
||||
set :views, File.dirname(__FILE__) + '/../../views'
|
||||
|
||||
# Automatically escape HTML
|
||||
set :erb, :escape_html => true
|
||||
|
||||
# disable Rack::Protection, JsonCsrf breaks things
|
||||
disable :protection
|
||||
|
||||
# disable cookies for static files
|
||||
register Sinatra::CookieThief
|
||||
|
||||
register Sinatra::Flash
|
||||
|
||||
use Rack::Session::Redis, {
|
||||
:httponly => true,
|
||||
:secret => '38066be6a9d388626e045be2351d26918608d53c',
|
||||
:expire_after => 8.hours
|
||||
}
|
||||
|
||||
helpers Helpers::Accounts
|
||||
helpers Helpers::Admin
|
||||
helpers Helpers::Authorization
|
||||
helpers Helpers::FAQ
|
||||
helpers Helpers::Utils
|
||||
helpers Helpers::Views
|
||||
|
||||
not_found do
|
||||
erb :'not-found'
|
||||
end
|
||||
|
||||
error do
|
||||
if production?
|
||||
body = erb(:'email/error-notification', :layout => false, :locals => {
|
||||
:account => current_account,
|
||||
:project => current_project,
|
||||
:admin => current_admin,
|
||||
:error => env['sinatra.error']
|
||||
})
|
||||
Pony.mail({
|
||||
:to => 'admin@example.com',
|
||||
:from => 'info@example.com',
|
||||
:subject => "[stormy] #{@error.class}: #{@error.message}",
|
||||
:headers => { 'Content-Type' => 'text/html' },
|
||||
:body => body
|
||||
})
|
||||
end
|
||||
erb :error
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
53
public/css/account.css
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
table#account
|
||||
{ width: 70%
|
||||
; margin: auto
|
||||
; border-spacing: 0 0.7em
|
||||
}
|
||||
|
||||
th
|
||||
{ text-align: right
|
||||
; padding-right: 1em
|
||||
; vertical-align: top
|
||||
}
|
||||
|
||||
th, td { color: #405e83 }
|
||||
|
||||
hr
|
||||
{ width: 70%
|
||||
; margin: 2em auto
|
||||
}
|
||||
|
||||
#taken, #invalid
|
||||
{ font-weight: bold
|
||||
; color: #600
|
||||
; text-align: center
|
||||
; padding: 0.3em 0.5em
|
||||
}
|
||||
|
||||
div.indented p,
|
||||
div.indented form,
|
||||
p.indented
|
||||
{ margin-left: 10em }
|
||||
|
||||
#content a { color: #405e83 }
|
||||
|
||||
.verified { color: #4db269; float: right }
|
||||
.unverified { color: #aa0000 }
|
||||
|
||||
#sending-email-verification
|
||||
{ display: none }
|
||||
|
||||
table#change-password
|
||||
{ display: none
|
||||
; margin-left: 10em
|
||||
}
|
||||
|
||||
.spinner
|
||||
{ display: none
|
||||
; margin: 0 0.5em
|
||||
}
|
||||
|
||||
#password-changed
|
||||
{ display: none
|
||||
; margin-left: 10em
|
||||
}
|
||||
78
public/css/admin.css
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
body
|
||||
{ font-size: 1.2em
|
||||
; font-family: 'Helvetica Neue', Roboto, Helvetica, Verdana, sans-serif
|
||||
}
|
||||
|
||||
h1 { float: left }
|
||||
|
||||
#sign-in
|
||||
{ text-align: center
|
||||
; width: 50%
|
||||
; margin: auto
|
||||
; padding: 1em
|
||||
; background-color: #d0d0d0
|
||||
; clear: both
|
||||
}
|
||||
|
||||
#sign-out
|
||||
{ float: right
|
||||
; font-size: 1.0em
|
||||
}
|
||||
|
||||
#content
|
||||
{ float: right
|
||||
; text-align: left
|
||||
; width: 70%
|
||||
; margin-right: 2em
|
||||
; padding-left: 2em
|
||||
; border-left: solid 2px #ddd
|
||||
}
|
||||
|
||||
#nav
|
||||
{ width: 200px
|
||||
; list-style-type: none
|
||||
; float: none !important
|
||||
}
|
||||
|
||||
#nav li a { text-decoration: underline }
|
||||
|
||||
.section { border-bottom: solid 1px #ddd }
|
||||
.section:last-child { border-bottom: none }
|
||||
|
||||
.subtle
|
||||
{ color: #bbb
|
||||
; margin-left: 20em
|
||||
}
|
||||
|
||||
#flash
|
||||
{ border: solid 1px #bbb
|
||||
; font-weight: bold
|
||||
; border: solid 1px #bbb
|
||||
; font-weight: bold
|
||||
; padding: 0.7em 0.4em
|
||||
; margin: 1em auto
|
||||
; width: 70%
|
||||
; font-size: 1.2em
|
||||
; text-align: center
|
||||
; clear: left
|
||||
; border-radius: 5px
|
||||
}
|
||||
|
||||
.notice { background-color: #d3edc5 }
|
||||
.warning { background-color: #ffc }
|
||||
#flash.error { background-color: #fcc }
|
||||
|
||||
|
||||
/* Accounts */
|
||||
|
||||
#accounts { border-collapse: collapse }
|
||||
#accounts th { background-color: #def }
|
||||
#accounts tr:nth-child(even) { background-color: #eee }
|
||||
|
||||
#accounts td,
|
||||
#accounts th
|
||||
{ text-align: center
|
||||
; padding: 0.2em 0.3em
|
||||
}
|
||||
|
||||
td.name { text-align: left }
|
||||
567
public/css/bootstrap-responsive.css
vendored
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
/*!
|
||||
* Bootstrap Responsive v2.0.0
|
||||
*
|
||||
* Copyright 2012 Twitter, Inc
|
||||
* Licensed under the Apache License v2.0
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Designed and built with all the love in the world @twitter by @mdo and @fat.
|
||||
*/
|
||||
.hidden {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.nav-collapse {
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
}
|
||||
.page-header h1 small {
|
||||
display: block;
|
||||
line-height: 18px;
|
||||
}
|
||||
input[class*="span"],
|
||||
select[class*="span"],
|
||||
textarea[class*="span"],
|
||||
.uneditable-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
/* Make inputs at least the height of their button counterpart */
|
||||
|
||||
/* Makes inputs behave like true block-level elements */
|
||||
|
||||
-webkit-box-sizing: border-box;
|
||||
/* Older Webkit */
|
||||
|
||||
-moz-box-sizing: border-box;
|
||||
/* Older FF */
|
||||
|
||||
-ms-box-sizing: border-box;
|
||||
/* IE8 */
|
||||
|
||||
box-sizing: border-box;
|
||||
/* CSS3 spec*/
|
||||
|
||||
}
|
||||
.input-prepend input[class*="span"], .input-append input[class*="span"] {
|
||||
width: auto;
|
||||
}
|
||||
input[type="checkbox"], input[type="radio"] {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.form-horizontal .control-group > label {
|
||||
float: none;
|
||||
width: auto;
|
||||
padding-top: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.form-horizontal .controls {
|
||||
margin-left: 0;
|
||||
}
|
||||
.form-horizontal .control-list {
|
||||
padding-top: 0;
|
||||
}
|
||||
.form-horizontal .form-actions {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.modal {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
.modal.fade.in {
|
||||
top: auto;
|
||||
}
|
||||
.modal-header .close {
|
||||
padding: 10px;
|
||||
margin: -10px;
|
||||
}
|
||||
.carousel-caption {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
width: auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.row-fluid {
|
||||
width: 100%;
|
||||
}
|
||||
.row {
|
||||
margin-left: 0;
|
||||
}
|
||||
.row > [class*="span"], .row-fluid > [class*="span"] {
|
||||
float: none;
|
||||
display: block;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) and (max-width: 980px) {
|
||||
.row {
|
||||
margin-left: -20px;
|
||||
*zoom: 1;
|
||||
}
|
||||
.row:before, .row:after {
|
||||
display: table;
|
||||
content: "";
|
||||
}
|
||||
.row:after {
|
||||
clear: both;
|
||||
}
|
||||
[class*="span"] {
|
||||
float: left;
|
||||
margin-left: 20px;
|
||||
}
|
||||
.span1 {
|
||||
width: 42px;
|
||||
}
|
||||
.span2 {
|
||||
width: 104px;
|
||||
}
|
||||
.span3 {
|
||||
width: 166px;
|
||||
}
|
||||
.span4 {
|
||||
width: 228px;
|
||||
}
|
||||
.span5 {
|
||||
width: 290px;
|
||||
}
|
||||
.span6 {
|
||||
width: 352px;
|
||||
}
|
||||
.span7 {
|
||||
width: 414px;
|
||||
}
|
||||
.span8 {
|
||||
width: 476px;
|
||||
}
|
||||
.span9 {
|
||||
width: 538px;
|
||||
}
|
||||
.span10 {
|
||||
width: 600px;
|
||||
}
|
||||
.span11 {
|
||||
width: 662px;
|
||||
}
|
||||
.span12, .container {
|
||||
width: 724px;
|
||||
}
|
||||
.offset1 {
|
||||
margin-left: 82px;
|
||||
}
|
||||
.offset2 {
|
||||
margin-left: 144px;
|
||||
}
|
||||
.offset3 {
|
||||
margin-left: 206px;
|
||||
}
|
||||
.offset4 {
|
||||
margin-left: 268px;
|
||||
}
|
||||
.offset5 {
|
||||
margin-left: 330px;
|
||||
}
|
||||
.offset6 {
|
||||
margin-left: 392px;
|
||||
}
|
||||
.offset7 {
|
||||
margin-left: 454px;
|
||||
}
|
||||
.offset8 {
|
||||
margin-left: 516px;
|
||||
}
|
||||
.offset9 {
|
||||
margin-left: 578px;
|
||||
}
|
||||
.offset10 {
|
||||
margin-left: 640px;
|
||||
}
|
||||
.offset11 {
|
||||
margin-left: 702px;
|
||||
}
|
||||
.row-fluid {
|
||||
width: 100%;
|
||||
*zoom: 1;
|
||||
}
|
||||
.row-fluid:before, .row-fluid:after {
|
||||
display: table;
|
||||
content: "";
|
||||
}
|
||||
.row-fluid:after {
|
||||
clear: both;
|
||||
}
|
||||
.row-fluid > [class*="span"] {
|
||||
float: left;
|
||||
margin-left: 2.762430939%;
|
||||
}
|
||||
.row-fluid > [class*="span"]:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.row-fluid .span1 {
|
||||
width: 5.801104972%;
|
||||
}
|
||||
.row-fluid .span2 {
|
||||
width: 14.364640883%;
|
||||
}
|
||||
.row-fluid .span3 {
|
||||
width: 22.928176794%;
|
||||
}
|
||||
.row-fluid .span4 {
|
||||
width: 31.491712705%;
|
||||
}
|
||||
.row-fluid .span5 {
|
||||
width: 40.055248616%;
|
||||
}
|
||||
.row-fluid .span6 {
|
||||
width: 48.618784527%;
|
||||
}
|
||||
.row-fluid .span7 {
|
||||
width: 57.182320438000005%;
|
||||
}
|
||||
.row-fluid .span8 {
|
||||
width: 65.74585634900001%;
|
||||
}
|
||||
.row-fluid .span9 {
|
||||
width: 74.30939226%;
|
||||
}
|
||||
.row-fluid .span10 {
|
||||
width: 82.87292817100001%;
|
||||
}
|
||||
.row-fluid .span11 {
|
||||
width: 91.436464082%;
|
||||
}
|
||||
.row-fluid .span12 {
|
||||
width: 99.999999993%;
|
||||
}
|
||||
input.span1, textarea.span1, .uneditable-input.span1 {
|
||||
width: 32px;
|
||||
}
|
||||
input.span2, textarea.span2, .uneditable-input.span2 {
|
||||
width: 94px;
|
||||
}
|
||||
input.span3, textarea.span3, .uneditable-input.span3 {
|
||||
width: 156px;
|
||||
}
|
||||
input.span4, textarea.span4, .uneditable-input.span4 {
|
||||
width: 218px;
|
||||
}
|
||||
input.span5, textarea.span5, .uneditable-input.span5 {
|
||||
width: 280px;
|
||||
}
|
||||
input.span6, textarea.span6, .uneditable-input.span6 {
|
||||
width: 342px;
|
||||
}
|
||||
input.span7, textarea.span7, .uneditable-input.span7 {
|
||||
width: 404px;
|
||||
}
|
||||
input.span8, textarea.span8, .uneditable-input.span8 {
|
||||
width: 466px;
|
||||
}
|
||||
input.span9, textarea.span9, .uneditable-input.span9 {
|
||||
width: 528px;
|
||||
}
|
||||
input.span10, textarea.span10, .uneditable-input.span10 {
|
||||
width: 590px;
|
||||
}
|
||||
input.span11, textarea.span11, .uneditable-input.span11 {
|
||||
width: 652px;
|
||||
}
|
||||
input.span12, textarea.span12, .uneditable-input.span12 {
|
||||
width: 714px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
body {
|
||||
padding-top: 0;
|
||||
}
|
||||
.navbar-fixed-top {
|
||||
position: static;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.navbar-fixed-top .navbar-inner {
|
||||
padding: 5px;
|
||||
}
|
||||
.navbar .container {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.navbar .brand {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
margin: 0 0 0 -5px;
|
||||
}
|
||||
.navbar .nav-collapse {
|
||||
clear: left;
|
||||
}
|
||||
.navbar .nav {
|
||||
float: none;
|
||||
margin: 0 0 9px;
|
||||
}
|
||||
.navbar .nav > li {
|
||||
float: none;
|
||||
}
|
||||
.navbar .nav > li > a {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.navbar .nav > .divider-vertical {
|
||||
display: none;
|
||||
}
|
||||
.navbar .nav > li > a, .navbar .dropdown-menu a {
|
||||
padding: 6px 15px;
|
||||
font-weight: bold;
|
||||
color: #999999;
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.navbar .dropdown-menu li + li a {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.navbar .nav > li > a:hover, .navbar .dropdown-menu a:hover {
|
||||
background-color: #222222;
|
||||
}
|
||||
.navbar .dropdown-menu {
|
||||
position: static;
|
||||
top: auto;
|
||||
left: auto;
|
||||
float: none;
|
||||
display: block;
|
||||
max-width: none;
|
||||
margin: 0 15px;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
-webkit-border-radius: 0;
|
||||
-moz-border-radius: 0;
|
||||
border-radius: 0;
|
||||
-webkit-box-shadow: none;
|
||||
-moz-box-shadow: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.navbar .dropdown-menu:before, .navbar .dropdown-menu:after {
|
||||
display: none;
|
||||
}
|
||||
.navbar .dropdown-menu .divider {
|
||||
display: none;
|
||||
}
|
||||
.navbar-form, .navbar-search {
|
||||
float: none;
|
||||
padding: 9px 15px;
|
||||
margin: 9px 0;
|
||||
border-top: 1px solid #222222;
|
||||
border-bottom: 1px solid #222222;
|
||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
-moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.navbar .nav.pull-right {
|
||||
float: none;
|
||||
margin-left: 0;
|
||||
}
|
||||
.navbar-static .navbar-inner {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.btn-navbar {
|
||||
display: block;
|
||||
}
|
||||
.nav-collapse {
|
||||
overflow: hidden;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
@media (min-width: 980px) {
|
||||
.nav-collapse.collapse {
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
.row {
|
||||
margin-left: -30px;
|
||||
*zoom: 1;
|
||||
}
|
||||
.row:before, .row:after {
|
||||
display: table;
|
||||
content: "";
|
||||
}
|
||||
.row:after {
|
||||
clear: both;
|
||||
}
|
||||
[class*="span"] {
|
||||
float: left;
|
||||
margin-left: 30px;
|
||||
}
|
||||
.span1 {
|
||||
width: 70px;
|
||||
}
|
||||
.span2 {
|
||||
width: 170px;
|
||||
}
|
||||
.span3 {
|
||||
width: 270px;
|
||||
}
|
||||
.span4 {
|
||||
width: 370px;
|
||||
}
|
||||
.span5 {
|
||||
width: 470px;
|
||||
}
|
||||
.span6 {
|
||||
width: 570px;
|
||||
}
|
||||
.span7 {
|
||||
width: 670px;
|
||||
}
|
||||
.span8 {
|
||||
width: 770px;
|
||||
}
|
||||
.span9 {
|
||||
width: 870px;
|
||||
}
|
||||
.span10 {
|
||||
width: 970px;
|
||||
}
|
||||
.span11 {
|
||||
width: 1070px;
|
||||
}
|
||||
.span12, .container {
|
||||
width: 1170px;
|
||||
}
|
||||
.offset1 {
|
||||
margin-left: 130px;
|
||||
}
|
||||
.offset2 {
|
||||
margin-left: 230px;
|
||||
}
|
||||
.offset3 {
|
||||
margin-left: 330px;
|
||||
}
|
||||
.offset4 {
|
||||
margin-left: 430px;
|
||||
}
|
||||
.offset5 {
|
||||
margin-left: 530px;
|
||||
}
|
||||
.offset6 {
|
||||
margin-left: 630px;
|
||||
}
|
||||
.offset7 {
|
||||
margin-left: 730px;
|
||||
}
|
||||
.offset8 {
|
||||
margin-left: 830px;
|
||||
}
|
||||
.offset9 {
|
||||
margin-left: 930px;
|
||||
}
|
||||
.offset10 {
|
||||
margin-left: 1030px;
|
||||
}
|
||||
.offset11 {
|
||||
margin-left: 1130px;
|
||||
}
|
||||
.row-fluid {
|
||||
width: 100%;
|
||||
*zoom: 1;
|
||||
}
|
||||
.row-fluid:before, .row-fluid:after {
|
||||
display: table;
|
||||
content: "";
|
||||
}
|
||||
.row-fluid:after {
|
||||
clear: both;
|
||||
}
|
||||
.row-fluid > [class*="span"] {
|
||||
float: left;
|
||||
margin-left: 2.564102564%;
|
||||
}
|
||||
.row-fluid > [class*="span"]:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.row-fluid .span1 {
|
||||
width: 5.982905983%;
|
||||
}
|
||||
.row-fluid .span2 {
|
||||
width: 14.529914530000001%;
|
||||
}
|
||||
.row-fluid .span3 {
|
||||
width: 23.076923077%;
|
||||
}
|
||||
.row-fluid .span4 {
|
||||
width: 31.623931624%;
|
||||
}
|
||||
.row-fluid .span5 {
|
||||
width: 40.170940171000005%;
|
||||
}
|
||||
.row-fluid .span6 {
|
||||
width: 48.717948718%;
|
||||
}
|
||||
.row-fluid .span7 {
|
||||
width: 57.264957265%;
|
||||
}
|
||||
.row-fluid .span8 {
|
||||
width: 65.81196581200001%;
|
||||
}
|
||||
.row-fluid .span9 {
|
||||
width: 74.358974359%;
|
||||
}
|
||||
.row-fluid .span10 {
|
||||
width: 82.905982906%;
|
||||
}
|
||||
.row-fluid .span11 {
|
||||
width: 91.45299145300001%;
|
||||
}
|
||||
.row-fluid .span12 {
|
||||
width: 100%;
|
||||
}
|
||||
input.span1, textarea.span1, .uneditable-input.span1 {
|
||||
width: 60px;
|
||||
}
|
||||
input.span2, textarea.span2, .uneditable-input.span2 {
|
||||
width: 160px;
|
||||
}
|
||||
input.span3, textarea.span3, .uneditable-input.span3 {
|
||||
width: 260px;
|
||||
}
|
||||
input.span4, textarea.span4, .uneditable-input.span4 {
|
||||
width: 360px;
|
||||
}
|
||||
input.span5, textarea.span5, .uneditable-input.span5 {
|
||||
width: 460px;
|
||||
}
|
||||
input.span6, textarea.span6, .uneditable-input.span6 {
|
||||
width: 560px;
|
||||
}
|
||||
input.span7, textarea.span7, .uneditable-input.span7 {
|
||||
width: 660px;
|
||||
}
|
||||
input.span8, textarea.span8, .uneditable-input.span8 {
|
||||
width: 760px;
|
||||
}
|
||||
input.span9, textarea.span9, .uneditable-input.span9 {
|
||||
width: 860px;
|
||||
}
|
||||
input.span10, textarea.span10, .uneditable-input.span10 {
|
||||
width: 960px;
|
||||
}
|
||||
input.span11, textarea.span11, .uneditable-input.span11 {
|
||||
width: 1060px;
|
||||
}
|
||||
input.span12, textarea.span12, .uneditable-input.span12 {
|
||||
width: 1160px;
|
||||
}
|
||||
.thumbnails {
|
||||
margin-left: -30px;
|
||||
}
|
||||
.thumbnails > li {
|
||||
margin-left: 30px;
|
||||
}
|
||||
}
|
||||
3932
public/css/bootstrap.css
vendored
Executable file
186
public/css/common.css
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
|
||||
body
|
||||
{ font-family: 'Helvetica Neue', Helvetica, Verdana, Tahoma, sans-serif
|
||||
; line-height: 1.4em
|
||||
}
|
||||
|
||||
:focus { outline: none }
|
||||
|
||||
img, fieldset { border: none }
|
||||
|
||||
ul, ol, li { list-style: none }
|
||||
|
||||
a { color: #405e83 }
|
||||
a:hover { color: #000 }
|
||||
|
||||
#wrapper
|
||||
{ width: 90%
|
||||
; max-width: 1000px
|
||||
; margin: 0 auto
|
||||
; padding: 0
|
||||
}
|
||||
|
||||
#header { margin: 2em 0 0.7em }
|
||||
|
||||
#nav
|
||||
{ margin: 2.5em 0 0
|
||||
; float: right
|
||||
; font-size: 0.9em
|
||||
; color: #405e83
|
||||
}
|
||||
|
||||
#nav.authorized { margin-top: 1em }
|
||||
|
||||
#nav a
|
||||
{ color: #405e83
|
||||
; text-decoration: none
|
||||
}
|
||||
|
||||
#nav a:hover { color: #8db051 }
|
||||
|
||||
#content
|
||||
{ background-color: #e4e8ed
|
||||
; padding-bottom: 2em
|
||||
}
|
||||
|
||||
.section-heading
|
||||
{ margin: 0
|
||||
; background-color: #405e83
|
||||
; padding: 1.1em
|
||||
; font-weight: normal
|
||||
; color: #fff
|
||||
; display: inline-block
|
||||
; border-top-right-radius: 5px
|
||||
; border-bottom-right-radius: 5px
|
||||
}
|
||||
|
||||
.section-heading.top { border-top-right-radius: 0 }
|
||||
|
||||
.section
|
||||
{ clear: left
|
||||
; width: 80%
|
||||
; margin: auto
|
||||
; padding: 1em 0 2em
|
||||
}
|
||||
|
||||
.clear { clear: both }
|
||||
|
||||
#flash
|
||||
{ border: solid 1px #bbb
|
||||
; font-weight: bold
|
||||
; padding: 0.7em 0.4em
|
||||
; margin: 1em auto
|
||||
; width: 70%
|
||||
; font-size: 1.2em
|
||||
; text-align: center
|
||||
; clear: left
|
||||
; border-radius: 5px
|
||||
}
|
||||
|
||||
.notice { background-color: #d3edc5 }
|
||||
.warning { background-color: #ffc }
|
||||
#flash.error { background-color: #fcc }
|
||||
|
||||
/* for input fields and validation messages */
|
||||
input.error, p.error { background-color: #ffc }
|
||||
|
||||
button#hide-flash
|
||||
{ background-color: #092
|
||||
; border-color: #061
|
||||
; float: right
|
||||
; padding: 0.3em 0.5em
|
||||
; margin-top: -0.3em
|
||||
}
|
||||
|
||||
#flash.error button#hide-flash
|
||||
{ background-color: #a00
|
||||
; border-color: #600
|
||||
}
|
||||
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="phone"],
|
||||
input[type="tel"],
|
||||
input[type="text"],
|
||||
input[type="url"]
|
||||
{ font-family: 'Helvetica Neue', Helvetica, Verdana, Tahoma, sans-serif
|
||||
; padding: 0 0.2em
|
||||
; border: solid 1px #CCD3DB
|
||||
; font-size: 1em
|
||||
; height: 1.6em
|
||||
}
|
||||
|
||||
input.editable
|
||||
{ padding: 0.2em 0.3em
|
||||
; margin-right: 0.5em
|
||||
}
|
||||
|
||||
label
|
||||
{ margin: 0 0.2em 0 0
|
||||
; font-weight: bold
|
||||
; font-size: 1em
|
||||
; color: #405e83
|
||||
}
|
||||
|
||||
button,
|
||||
input[type="submit"]
|
||||
{ border: solid 2px #41749a
|
||||
; background-color: #509ed4
|
||||
; color: #f9f9f9
|
||||
; font-size: 0.8em
|
||||
; border-radius: 3px
|
||||
; cursor: pointer
|
||||
; -moz-transition: all 0.3s ease
|
||||
; -o-transition: all 0.3s ease
|
||||
; -webkit-transition: all 0.3s ease
|
||||
; transition: all 0.3s ease
|
||||
}
|
||||
|
||||
button:hover,
|
||||
input[type="submit"]:hover
|
||||
{ color: #fff
|
||||
; border-color: #444
|
||||
}
|
||||
|
||||
.placeholder { color: #999 }
|
||||
|
||||
.hidden { display: none }
|
||||
|
||||
.editable,
|
||||
.editable-json
|
||||
{ color: #405e83
|
||||
; background-color: #f6f6f6
|
||||
; padding: 0.3em
|
||||
}
|
||||
|
||||
.editable form,
|
||||
.editable-json form
|
||||
{ display: inline
|
||||
; padding: 0
|
||||
; margin: 0
|
||||
}
|
||||
|
||||
p.error
|
||||
{ text-align: center
|
||||
; background-color: #fee
|
||||
; padding: 0.5em 0
|
||||
; width: 60%
|
||||
; border: solid 2px #c99
|
||||
; border-radius: 3px
|
||||
}
|
||||
|
||||
.edit-instructions
|
||||
{ font-variant: italic
|
||||
; color: #888
|
||||
; vertical-align: middle
|
||||
}
|
||||
|
||||
#footer
|
||||
{ width: 100%
|
||||
; padding: 1em 0 2em 0
|
||||
; font-size: 0.7em
|
||||
; color: #28313c
|
||||
; text-align: center
|
||||
}
|
||||
|
||||
#copyright { text-align: right }
|
||||
51
public/css/contact.css
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
|
||||
#content
|
||||
{ padding: 0
|
||||
; background-color: #fff
|
||||
; line-height: 1.2em
|
||||
}
|
||||
|
||||
#about-us,
|
||||
#contact-us,
|
||||
#contact-email,
|
||||
#address-phone
|
||||
{ width: 50%
|
||||
; min-height: 400px
|
||||
; float: left
|
||||
; margin-bottom: 0
|
||||
}
|
||||
|
||||
#contact-email,
|
||||
#address-phone
|
||||
{ min-height: 300px }
|
||||
|
||||
#about-us, #contact-email { background-color: #e4e8ed }
|
||||
#contact-us, #address-phone { background-color: #e0e4e8 }
|
||||
|
||||
.section
|
||||
{ color: #405e83
|
||||
; font-size: 0.9em
|
||||
; width: 90%
|
||||
; margin-top: 1em
|
||||
}
|
||||
|
||||
table.section { margin-top: 0 }
|
||||
|
||||
#contact-us p.section
|
||||
{ margin: 0
|
||||
; padding: 0
|
||||
; text-align: center
|
||||
; font-size: 1em
|
||||
}
|
||||
|
||||
#contact-spinner { display: none }
|
||||
|
||||
table.section { padding-top: 1em }
|
||||
|
||||
th
|
||||
{ text-align: right
|
||||
; padding-right: 0.6em
|
||||
}
|
||||
|
||||
td a { color: #405e83 }
|
||||
td a:hover { color: #000 }
|
||||
86
public/css/edit-project.css
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
.section { width: 90% }
|
||||
|
||||
#photo-form
|
||||
{ height: 0
|
||||
; width: 0
|
||||
; opacity: 0
|
||||
; position: fixed
|
||||
; top: 0
|
||||
}
|
||||
|
||||
#upload-target
|
||||
{ width: 0
|
||||
; height: 0
|
||||
; opacity: 0
|
||||
}
|
||||
|
||||
.save
|
||||
{ width: 90%
|
||||
; margin: 1em auto
|
||||
; text-align: right
|
||||
}
|
||||
|
||||
input.save-button
|
||||
{ background-color: #092
|
||||
; border-color: #061
|
||||
}
|
||||
|
||||
.save-button-spinner { display: none }
|
||||
|
||||
table#project-info
|
||||
{ width: 90%
|
||||
; margin: 1em auto
|
||||
}
|
||||
|
||||
table#project-info th
|
||||
{ color: #405e83
|
||||
; text-align: right
|
||||
; vertical-align: top
|
||||
; padding: 0.2em 0.5em 0.7em
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="tel"],
|
||||
input[type="url"]
|
||||
{ padding: 0 0.2em
|
||||
; border: #CCD3DB 1px solid
|
||||
; font-size: 1em
|
||||
; height: 1.5em
|
||||
; margin: 0
|
||||
}
|
||||
|
||||
textarea
|
||||
{ border: #CCD3DB 1px solid
|
||||
; font-size: 0.9em
|
||||
; line-height: 1.2em
|
||||
; padding: 0.3em
|
||||
; margin: 0
|
||||
}
|
||||
|
||||
#photos li
|
||||
{ display: inline-block
|
||||
; width: 72px
|
||||
; height: 84px
|
||||
; padding: 0
|
||||
; margin: 0.2em 0.25em 0.7em
|
||||
; text-align: center
|
||||
}
|
||||
|
||||
li#add-photo-box { display: inline-block }
|
||||
#photos li#add-photo-spinner { height: 18px }
|
||||
|
||||
.add-photo { font-size: 0.8em }
|
||||
a.add-photo { font-size: 0.9em }
|
||||
.remove-photo { color: #900 }
|
||||
|
||||
#ie-photo-uploaderQueue { display: none }
|
||||
|
||||
#ie-photo-uploaderUploader
|
||||
{ height: 64px
|
||||
; width: 64px
|
||||
}
|
||||
|
||||
#drag-n-drop
|
||||
{ padding-left: 3em
|
||||
; font-style: italic
|
||||
}
|
||||
2
public/css/faq.css
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
.question { font-weight: bold }
|
||||
0
public/css/index.css
Normal file
101
public/css/jquery.lightbox-0.5.css
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* jQuery lightBox plugin
|
||||
* This jQuery plugin was inspired and based on Lightbox 2 by Lokesh Dhakar (http://www.huddletogether.com/projects/lightbox2/)
|
||||
* and adapted to me for use like a plugin from jQuery.
|
||||
* @name jquery-lightbox-0.5.css
|
||||
* @author Leandro Vieira Pinho - http://leandrovieira.com
|
||||
* @version 0.5
|
||||
* @date April 11, 2008
|
||||
* @category jQuery plugin
|
||||
* @copyright (c) 2008 Leandro Vieira Pinho (leandrovieira.com)
|
||||
* @license CCAttribution-ShareAlike 2.5 Brazil - http://creativecommons.org/licenses/by-sa/2.5/br/deed.en_US
|
||||
* @example Visit http://leandrovieira.com/projects/jquery/lightbox/ for more informations about this jQuery plugin
|
||||
*/
|
||||
#jquery-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 90;
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
}
|
||||
#jquery-lightbox {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
text-align: center;
|
||||
line-height: 0;
|
||||
}
|
||||
#jquery-lightbox a img { border: none; }
|
||||
#lightbox-container-image-box {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
#lightbox-container-image { padding: 10px; }
|
||||
#lightbox-loading {
|
||||
position: absolute;
|
||||
top: 40%;
|
||||
left: 0%;
|
||||
height: 25%;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
line-height: 0;
|
||||
}
|
||||
#lightbox-nav {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
#lightbox-container-image-box > #lightbox-nav { left: 0; }
|
||||
#lightbox-nav a { outline: none;}
|
||||
#lightbox-nav-btnPrev, #lightbox-nav-btnNext {
|
||||
width: 49%;
|
||||
height: 100%;
|
||||
zoom: 1;
|
||||
display: block;
|
||||
}
|
||||
#lightbox-nav-btnPrev {
|
||||
left: 0;
|
||||
float: left;
|
||||
}
|
||||
#lightbox-nav-btnNext {
|
||||
right: 0;
|
||||
float: right;
|
||||
}
|
||||
#lightbox-container-image-data-box {
|
||||
font: 10px Verdana, Helvetica, sans-serif;
|
||||
background-color: #fff;
|
||||
margin: 0 auto;
|
||||
line-height: 1.4em;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
padding: 0 10px 0;
|
||||
}
|
||||
#lightbox-container-image-data {
|
||||
padding: 0 10px;
|
||||
color: #666;
|
||||
}
|
||||
#lightbox-container-image-data #lightbox-image-details {
|
||||
width: 70%;
|
||||
float: left;
|
||||
text-align: left;
|
||||
}
|
||||
#lightbox-image-details-caption { font-weight: bold; }
|
||||
#lightbox-image-details-currentNumber {
|
||||
display: block;
|
||||
clear: left;
|
||||
padding-bottom: 1.0em;
|
||||
}
|
||||
#lightbox-secNav-btnClose {
|
||||
width: 66px;
|
||||
float: right;
|
||||
padding-bottom: 0.7em;
|
||||
}
|
||||
35
public/css/projects.css
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
|
||||
table#projects
|
||||
{ border-collapse: collapse
|
||||
; margin: 0 auto 2em
|
||||
}
|
||||
|
||||
#projects td { padding: 0.3em 0 }
|
||||
#projects th { text-align: center }
|
||||
|
||||
#projects tr.headings th
|
||||
{ color: #666
|
||||
; padding: 0.3em 0 0.8em
|
||||
}
|
||||
|
||||
#projects th.name,
|
||||
#projects td.name
|
||||
{ text-align: left }
|
||||
|
||||
#projects tr.project { background-color: #dce1e8 }
|
||||
#projects tr.project:nth-child(even) { background-color: #e4e8ed }
|
||||
#projects tr.project:hover { background-color: #eee }
|
||||
|
||||
#projects tr.project td
|
||||
{ border-bottom: solid 1px #aaa
|
||||
; padding: 0.5em 0
|
||||
}
|
||||
|
||||
#projects tr.project.first td { border-top: solid 1px #aaa }
|
||||
|
||||
#projects td.created { text-align: center }
|
||||
|
||||
#projects td.name a
|
||||
{ color: #333
|
||||
; font-size: 1.2em
|
||||
}
|
||||
24
public/css/reset-password.css
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
|
||||
table#reset-password { margin: auto }
|
||||
|
||||
th
|
||||
{ text-align: right
|
||||
; vertical-align: top
|
||||
; padding-top: 0.6em
|
||||
; padding-right: 0.4em
|
||||
}
|
||||
|
||||
label { line-height: 1.7em }
|
||||
|
||||
input[type="text"], /* fake password placeholder element created by jquery.placeholder */
|
||||
input[type="email"],
|
||||
input[type="password"]
|
||||
{ margin: 0.3em 0 0 }
|
||||
|
||||
#reset-password-spinner { display: none }
|
||||
|
||||
#submit-cell
|
||||
{ text-align: right
|
||||
; color: #405e83
|
||||
; padding-top: 0.5em
|
||||
}
|
||||
55
public/css/sign-in.css
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
|
||||
#content
|
||||
{ background-color: #fff
|
||||
; padding-bottom: 0
|
||||
}
|
||||
|
||||
#sign-in
|
||||
{ padding: 1em
|
||||
; background-color: #E4E8ED
|
||||
; margin: auto
|
||||
}
|
||||
|
||||
th
|
||||
{ text-align: right
|
||||
; vertical-align: top
|
||||
; padding-top: 0.6em
|
||||
; padding-right: 0.4em
|
||||
}
|
||||
|
||||
label { line-height: 1.7em }
|
||||
|
||||
input[type="text"], /* fake password placeholder element created by jquery.placeholder */
|
||||
input[type="email"],
|
||||
input[type="password"]
|
||||
{ margin: 0.3em 0 0 }
|
||||
|
||||
#password-cell { text-align: right }
|
||||
|
||||
#forgot-password-link
|
||||
{ font-size: 0.8em
|
||||
; padding-right: 0.5em
|
||||
; margin-top: 0.5em
|
||||
; display: inline-block
|
||||
; color: #405e83
|
||||
}
|
||||
|
||||
#forgot-password-link:hover { color: #000 }
|
||||
|
||||
label[for="remember"]
|
||||
{ font-size: 0.9em
|
||||
; font-weight: normal
|
||||
}
|
||||
|
||||
#submit-cell
|
||||
{ text-align: right
|
||||
; color: #405e83
|
||||
}
|
||||
|
||||
#sign-in-spinner { display: none }
|
||||
|
||||
#sign-up
|
||||
{ text-align: center
|
||||
; padding-top: 1em
|
||||
; color: #405e83
|
||||
}
|
||||
85
public/css/sign-up.css
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
|
||||
#blurb
|
||||
{ margin: 0
|
||||
; padding: 1em
|
||||
; font-weight: bold
|
||||
; font-size: 1.2em
|
||||
; color: #509ed4
|
||||
; text-align: left
|
||||
}
|
||||
|
||||
#sign-up-table
|
||||
{ width: 55%
|
||||
; margin: auto 1em
|
||||
; padding: 1em
|
||||
; float: left
|
||||
}
|
||||
|
||||
th
|
||||
{ text-align: right
|
||||
; vertical-align: top
|
||||
}
|
||||
|
||||
td { text-align: left }
|
||||
|
||||
label
|
||||
{ margin: 0 0.2em 0 0
|
||||
; font-weight: bold
|
||||
; font-size: 1em
|
||||
; color: #405e83
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"]
|
||||
{ padding: 0 0.2em
|
||||
; border: #CCD3DB 1px solid
|
||||
; font-size: 1em
|
||||
; height: 1.6em
|
||||
}
|
||||
|
||||
label[for="terms"]
|
||||
{ font-size: 0.8em
|
||||
; color: #28313c
|
||||
}
|
||||
|
||||
#terms-cell { padding: 0.5em 0 0.7em }
|
||||
#terms a { color: #28313c }
|
||||
|
||||
#sign-up-cell { text-align: center }
|
||||
|
||||
#sign-up-button
|
||||
{ border: solid 1px #444
|
||||
; border-radius: 3px
|
||||
; background-color: #ff9806
|
||||
; font-size: 1.3em
|
||||
; padding: 0.2em 0.4em
|
||||
; font-weight: bold
|
||||
; cursor: pointer
|
||||
; color: #000
|
||||
}
|
||||
|
||||
#sign-up-button:hover
|
||||
{ background-color: #FFD395
|
||||
; border-color: #000
|
||||
}
|
||||
|
||||
#sign-up-spinner { display: none }
|
||||
|
||||
#sign-in
|
||||
{ width: 40%
|
||||
; min-width: 150px
|
||||
; max-width: 400px
|
||||
; text-align: center
|
||||
; float: right
|
||||
}
|
||||
|
||||
#sign-in p
|
||||
{ margin-top: 5em
|
||||
; font-weight: bold
|
||||
; color: #405e83
|
||||
}
|
||||
|
||||
#sign-in a { text-decoration: none }
|
||||
|
||||
#sign-in-spinner { display: none }
|
||||
2
public/css/terms.css
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
ol { list-style-type: none }
|
||||
52
public/css/uploadify.css
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
Uploadify v2.1.4
|
||||
Release Date: November 8, 2010
|
||||
|
||||
Copyright (c) 2010 Ronnie Garcia, Travis Nickels
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
.uploadifyQueueItem {
|
||||
background-color: #F5F5F5;
|
||||
border: 2px solid #E5E5E5;
|
||||
font: 11px Verdana, Geneva, sans-serif;
|
||||
margin-top: 5px;
|
||||
padding: 10px;
|
||||
width: 350px;
|
||||
}
|
||||
.uploadifyError {
|
||||
background-color: #FDE5DD !important;
|
||||
border: 2px solid #FBCBBC !important;
|
||||
}
|
||||
.uploadifyQueueItem .cancel {
|
||||
float: right;
|
||||
}
|
||||
.uploadifyQueue .completed {
|
||||
background-color: #E5E5E5;
|
||||
}
|
||||
.uploadifyProgress {
|
||||
background-color: #E5E5E5;
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.uploadifyProgressBar {
|
||||
background-color: #0099FF;
|
||||
height: 3px;
|
||||
width: 1px;
|
||||
}
|
||||
BIN
public/images/add-photo.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/images/glyphicons-halflings-white.png
Executable file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/images/glyphicons-halflings.png
Executable file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/images/lightbox-blank.gif
Executable file
|
After Width: | Height: | Size: 43 B |
BIN
public/images/lightbox-btn-close.gif
Executable file
|
After Width: | Height: | Size: 700 B |
BIN
public/images/lightbox-btn-next.gif
Executable file
|
After Width: | Height: | Size: 812 B |
BIN
public/images/lightbox-btn-prev.gif
Executable file
|
After Width: | Height: | Size: 832 B |
BIN
public/images/lightbox-ico-loading.gif
Executable file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/images/logo.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
public/images/sign-up-now-button-hover.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/images/sign-up-now-button.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
public/images/spinner.gif
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
137
public/index.html
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>stormy</title>
|
||||
<meta name="description" content="">
|
||||
<meta name="author" content="">
|
||||
|
||||
<!-- Le HTML5 shim, for IE6-8 support of HTML elements -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
|
||||
<![endif]-->
|
||||
|
||||
<!-- Le styles -->
|
||||
<link href="css/bootstrap.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
padding-top: 60px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
.sidebar-nav {
|
||||
padding: 9px 0;
|
||||
}
|
||||
</style>
|
||||
<link href="css/bootstrap-responsive.css" rel="stylesheet">
|
||||
|
||||
<!-- Le fav and touch icons -->
|
||||
<link rel="shortcut icon" href="img/favicon.ico">
|
||||
<link rel="apple-touch-icon" href="img/apple-touch-icon.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="img/apple-touch-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="img/apple-touch-icon-114x114.png">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="navbar navbar-fixed-top">
|
||||
<div class="navbar-inner">
|
||||
<div class="container-fluid">
|
||||
<a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</a>
|
||||
<a class="brand" href="#">Project name</a>
|
||||
<div class="nav-collapse">
|
||||
<ul class="nav">
|
||||
<li class="active"><a href="#">Home</a></li>
|
||||
<li><a href="#about">About</a></li>
|
||||
<li><a href="#contact">Contact</a></li>
|
||||
</ul>
|
||||
<p class="navbar-text pull-right">Logged in as <a href="#">username</a></p>
|
||||
</div><!--/.nav-collapse -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row-fluid">
|
||||
<div class="span3">
|
||||
<div class="well sidebar-nav">
|
||||
<ul class="nav nav-list">
|
||||
<li class="nav-header">Sidebar</li>
|
||||
<li class="active"><a href="#">Link</a></li>
|
||||
<li><a href="#">Link</a></li>
|
||||
<li><a href="#">Link</a></li>
|
||||
<li><a href="#">Link</a></li>
|
||||
<li class="nav-header">Sidebar</li>
|
||||
<li><a href="#">Link</a></li>
|
||||
<li><a href="#">Link</a></li>
|
||||
<li><a href="#">Link</a></li>
|
||||
<li><a href="#">Link</a></li>
|
||||
<li><a href="#">Link</a></li>
|
||||
<li><a href="#">Link</a></li>
|
||||
<li class="nav-header">Sidebar</li>
|
||||
<li><a href="#">Link</a></li>
|
||||
<li><a href="#">Link</a></li>
|
||||
<li><a href="#">Link</a></li>
|
||||
</ul>
|
||||
</div><!--/.well -->
|
||||
</div><!--/span-->
|
||||
<div class="span9">
|
||||
<div class="hero-unit">
|
||||
<h1>Hello, world!</h1>
|
||||
<p>This is a template for a simple marketing or informational website. It includes a large callout called the hero unit and three supporting pieces of content. Use it as a starting point to create something more unique.</p>
|
||||
<p><a class="btn btn-primary btn-large">Learn more »</a></p>
|
||||
</div>
|
||||
<div class="row-fluid">
|
||||
<div class="span4">
|
||||
<h2>Heading</h2>
|
||||
<p>Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. </p>
|
||||
<p><a class="btn" href="#">View details »</a></p>
|
||||
</div><!--/span-->
|
||||
<div class="span4">
|
||||
<h2>Heading</h2>
|
||||
<p>Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. </p>
|
||||
<p><a class="btn" href="#">View details »</a></p>
|
||||
</div><!--/span-->
|
||||
<div class="span4">
|
||||
<h2>Heading</h2>
|
||||
<p>Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. </p>
|
||||
<p><a class="btn" href="#">View details »</a></p>
|
||||
</div><!--/span-->
|
||||
</div><!--/row-->
|
||||
<div class="row-fluid">
|
||||
<div class="span4">
|
||||
<h2>Heading</h2>
|
||||
<p>Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. </p>
|
||||
<p><a class="btn" href="#">View details »</a></p>
|
||||
</div><!--/span-->
|
||||
<div class="span4">
|
||||
<h2>Heading</h2>
|
||||
<p>Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. </p>
|
||||
<p><a class="btn" href="#">View details »</a></p>
|
||||
</div><!--/span-->
|
||||
<div class="span4">
|
||||
<h2>Heading</h2>
|
||||
<p>Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna mollis euismod. Donec sed odio dui. </p>
|
||||
<p><a class="btn" href="#">View details »</a></p>
|
||||
</div><!--/span-->
|
||||
</div><!--/row-->
|
||||
</div><!--/span-->
|
||||
</div><!--/row-->
|
||||
|
||||
<hr>
|
||||
|
||||
<footer>
|
||||
<p>© 2012 Sami Samhuri</p>
|
||||
</footer>
|
||||
|
||||
</div><!--/.fluid-container-->
|
||||
|
||||
<!-- Le javascript
|
||||
================================================== -->
|
||||
<!-- Placed at the end of the document so the pages load faster -->
|
||||
<script src="js/jquery.js"></script>
|
||||
<script src="js/bootstrap.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
145
public/js/account-editable.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
$(function() {
|
||||
|
||||
var editableOptions = {
|
||||
indicator: '<img src="/images/spinner.gif"> Saving...'
|
||||
, submit: 'OK'
|
||||
, cancel: 'Cancel'
|
||||
, tooltip: 'Click to edit'
|
||||
, select: true
|
||||
, onblur: 'ignore'
|
||||
, placeholder: '(none)'
|
||||
}
|
||||
|
||||
$('.editable').editable('/account/update', $.extend({}, editableOptions, {
|
||||
onsubmit: function(options, el) {
|
||||
var $input = $('input', el)
|
||||
var value = $input.val()
|
||||
if ($.trim(value)) {
|
||||
$input.removeClass('error')
|
||||
return true
|
||||
}
|
||||
else {
|
||||
$input.addClass('error')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
var updaters = {
|
||||
|
||||
email: updaterForField({
|
||||
type: 'email address'
|
||||
, name: 'email'
|
||||
, validate: window.SI.EmailIsValid
|
||||
, failHandlers: {
|
||||
taken: function(type, name) {
|
||||
$(this).after('<p id="email-taken" class="error">That email address is already taken.</p>')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
$.each(['email'], function(i, id) {
|
||||
var $el = $('#' + id)
|
||||
$el
|
||||
.data('original', $el.text())
|
||||
.editable(updaters[id], $.extend({}, editableOptions, {
|
||||
oncancel: function(options) {
|
||||
$('#' + id + '-taken').remove()
|
||||
$('#' + id + '-invalid').remove()
|
||||
$('.edit-instructions').show()
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
function invalidHTMLFor(type, name) {
|
||||
return '<p id="' + name + '-invalid" class="error">Invalid ' + type + '.</p>'
|
||||
}
|
||||
|
||||
// options: type, name, validate, [failHandlers]
|
||||
function updaterForField(options) {
|
||||
var name = options.name
|
||||
, type = options.type
|
||||
, validate = options.validate
|
||||
, failHandlers = options.failHandlers || {}
|
||||
|
||||
return function(value, options) {
|
||||
$('#' + name + '-taken').remove()
|
||||
$('#' + name + '-invalid').remove()
|
||||
|
||||
var $el = $('#' + name)
|
||||
, self = this
|
||||
|
||||
value = $.trim(value)
|
||||
|
||||
if (value === $.trim($el.data('original'))) {
|
||||
$('.edit-instructions').show()
|
||||
return value || '(none)'
|
||||
}
|
||||
|
||||
function restoreInput(options) {
|
||||
options = options || {}
|
||||
self.editing = false
|
||||
$(self)
|
||||
.html($el.data('original'))
|
||||
.trigger('click')
|
||||
var input = $('input', self)
|
||||
input.val(value)
|
||||
if (options.error) {
|
||||
input
|
||||
.addClass('error')
|
||||
.focus()
|
||||
.select()
|
||||
}
|
||||
}
|
||||
|
||||
if (!validate(value)) {
|
||||
restoreInput({ error: true })
|
||||
$(this).after(invalidHTMLFor(type, name))
|
||||
}
|
||||
else {
|
||||
$('input', this).removeClass('error')
|
||||
$(this).html(options.indicator)
|
||||
|
||||
$.post('/account/update.json', { 'id': name, 'value': value }, function(data) {
|
||||
if (data.status === 'ok') {
|
||||
var previousValue = $.trim($(self).data('original'))
|
||||
value = $.trim(value)
|
||||
|
||||
$(self)
|
||||
.html(value || '(none)')
|
||||
.data('original', value)
|
||||
self.editing = false
|
||||
$('.edit-instructions').show()
|
||||
|
||||
if (name === 'email') {
|
||||
if (previousValue.toLowerCase() !== value.toLowerCase()) {
|
||||
$('#email-verified').hide()
|
||||
$('#email-verification').show()
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
restoreInput({ error: true })
|
||||
|
||||
// custom handler
|
||||
if (data.reason in failHandlers) {
|
||||
failHandlers[data.reason].call(self, type, name)
|
||||
}
|
||||
|
||||
// default, invalid
|
||||
else {
|
||||
$(self).after(invalidHTMLFor(type, name))
|
||||
}
|
||||
}
|
||||
}).error(function() {
|
||||
restoreInput()
|
||||
alert('Failed to update ' + type + '. Try again later.')
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
91
public/js/account.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
$(function() {
|
||||
|
||||
$('#change-password-link').click(function() {
|
||||
$(this).hide()
|
||||
$('#change-password').show()
|
||||
$('#password-changed').hide()
|
||||
$('#old-password').focus()
|
||||
return false
|
||||
})
|
||||
|
||||
$('#change-password-button').click(function() {
|
||||
changePassword()
|
||||
return false
|
||||
})
|
||||
|
||||
$('#send-email-verification').click(function() {
|
||||
$('#sending-email-verification').show()
|
||||
$(this).hide()
|
||||
var self = this
|
||||
$.post('/account/send-email-verification', function(data) {
|
||||
if (data.status === 'ok') {
|
||||
$(self)
|
||||
.after('Sent! Follow the link in your email to complete the verification.')
|
||||
.remove()
|
||||
}
|
||||
else {
|
||||
alert('Failed to send verification email. Try again later.')
|
||||
}
|
||||
}).error(function() {
|
||||
alert('Failed to send verification email. Try again later.')
|
||||
}).complete(function() {
|
||||
$('#sending-email-verification').hide()
|
||||
$(self).show()
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
function changePassword() {
|
||||
var oldPassword = $('#old-password').val()
|
||||
, newPassword = $('#new-password').val()
|
||||
, confirmation = $('#password-confirmation').val()
|
||||
if ($.trim(oldPassword) && $.trim(newPassword) && newPassword === confirmation) {
|
||||
$('#change-password-form input[type="password"]').removeClass('error')
|
||||
$('#change-password-form input[type="submit"]').hide()
|
||||
$('#change-password-form .spinner').show()
|
||||
$.post('/account/password', $('#change-password-form').serialize(), function(data) {
|
||||
if (data.status === 'ok') {
|
||||
$('input[type="password"]').val('')
|
||||
$('#change-password').hide()
|
||||
$('#change-password-link').show()
|
||||
$('#password-changed').show()
|
||||
}
|
||||
// incorrect old password
|
||||
else if (data.reason === 'incorrect') {
|
||||
$('#old-password')
|
||||
.val('')
|
||||
.addClass('error')
|
||||
.focus()
|
||||
}
|
||||
// invalid new password
|
||||
else {
|
||||
$('#new-password')
|
||||
.val('')
|
||||
.addClass('error')
|
||||
.focus()
|
||||
$('#password-confirmation')
|
||||
.val('')
|
||||
.addClass('error')
|
||||
}
|
||||
}).error(function(x) {
|
||||
alert('Failed to change password. Try again later.')
|
||||
}).complete(function() {
|
||||
$('#change-password-form input[type="submit"]').show()
|
||||
$('#change-password-form .spinner').hide()
|
||||
})
|
||||
}
|
||||
else {
|
||||
if ($.trim(newPassword)) {
|
||||
$('#password-confirmation')
|
||||
.val('')
|
||||
.addClass('error')
|
||||
.focus()
|
||||
}
|
||||
else {
|
||||
$('input[type="password"]').val('').addClass('error')
|
||||
$('#old-password').focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
7
public/js/admin-account.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
$(function() {
|
||||
|
||||
$('#delete').click(function() {
|
||||
return confirm("Are you sure you want to delete " + window.SI.email + "?")
|
||||
})
|
||||
|
||||
})
|
||||
7
public/js/admin-project.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
$(function() {
|
||||
|
||||
$('#delete').click(function() {
|
||||
return confirm("Are you sure?")
|
||||
})
|
||||
|
||||
})
|
||||
1733
public/js/bootstrap.js
vendored
Executable file
22
public/js/common.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
$(function() {
|
||||
|
||||
$('#sign-out-link').click(function() {
|
||||
$('#sign-out-form').submit()
|
||||
return false
|
||||
})
|
||||
|
||||
$('#flash').append('<button id="hide-flash">OK</button>')
|
||||
|
||||
$('#hide-flash').click(function() {
|
||||
$(this).fadeOut()
|
||||
$('#flash').fadeOut()
|
||||
})
|
||||
|
||||
// global Stormy Weather object
|
||||
window.SI = window.SI || {}
|
||||
window.SI.EmailRegex = /^[^@]+@[^.@]+(\.[^.@]+)+$/
|
||||
window.SI.EmailIsValid = function(v) { return window.SI.EmailRegex.test(v) }
|
||||
|
||||
$('input[placeholder], textarea[placeholder]').placeholder()
|
||||
|
||||
})
|
||||
41
public/js/contact.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
$(function() {
|
||||
|
||||
$('#contact-form').submit(function() {
|
||||
var valid = true
|
||||
, focused = false
|
||||
, messageField = $('#message')
|
||||
, emailField = $('#email')
|
||||
|
||||
if ($.trim(messageField.val()) === '') {
|
||||
valid = false
|
||||
messageField.addClass('error')
|
||||
if (!focused) {
|
||||
focused = true
|
||||
messageField.focus().select()
|
||||
}
|
||||
}
|
||||
else {
|
||||
messageField.removeClass('error')
|
||||
}
|
||||
|
||||
if (!window.SI.EmailIsValid(emailField.val())) {
|
||||
valid = false
|
||||
emailField.addClass('error')
|
||||
if (!focused) {
|
||||
focused = true
|
||||
emailField.focus().select()
|
||||
}
|
||||
}
|
||||
else {
|
||||
emailField.removeClass('error')
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
$('input[type="submit"]').hide()
|
||||
$('#contact-spinner').show()
|
||||
}
|
||||
|
||||
return valid
|
||||
})
|
||||
|
||||
})
|
||||
215
public/js/edit-project.js
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
$(function() {
|
||||
|
||||
initTmpl()
|
||||
|
||||
$('#name').blur(function() {
|
||||
var name = $.trim($(this).val())
|
||||
if (name === this.placeholder) name = ''
|
||||
$('.save-button').val('Save ' + (name || 'This Project'))
|
||||
})
|
||||
|
||||
$('form#project').submit(validateProject)
|
||||
|
||||
initLightBox()
|
||||
|
||||
$('#photos').dragsort({
|
||||
dragSelector: 'li.photo'
|
||||
, itemSelector: 'li.photo'
|
||||
, dragEnd: updatePhotoOrder
|
||||
, placeHolderTemplate: '<li class="placeholder"><div></div></li>'
|
||||
, itemClicked: function(item) { $('a.thumbnail', item).click() }
|
||||
})
|
||||
|
||||
var $photos = $('#photos-container')
|
||||
, $addPhotoBox = $('#add-photo-box')
|
||||
, $photoUploader = $('#photo-uploader')
|
||||
|
||||
// fuck IE
|
||||
if ($.browser.msie) {
|
||||
$('#ie-photo-uploader').uploadify({
|
||||
'uploader' : '/uploadify/uploadify.swf',
|
||||
'script' : '/uploadify',
|
||||
'multi' : false,
|
||||
'buttonImg' : '/images/add-photo.png',
|
||||
'method' : 'post',
|
||||
'cancelImg' : '/uploadify/cancel.png',
|
||||
'auto' : true,
|
||||
'fileExt' : ['jpg', 'jpeg', 'png'],
|
||||
'sizeLimit' : 10 * 1024 * 1024 * 1024, // 10 MB is way more than enough
|
||||
'scriptData' : { id: window.SI.projectId },
|
||||
|
||||
'onComplete': function(_a, _b, _c, text) {
|
||||
completePhotoUpload(text)
|
||||
},
|
||||
'onError': function() {
|
||||
completePhotoUpload('fail')
|
||||
},
|
||||
'onOpen': function() {
|
||||
$('#add-photo-spinner').remove()
|
||||
$addPhotoBox.addClass('hidden').before('<li id="add-photo-spinner"><img src="/images/spinner.gif"></li>')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
$('.add-photo').click(function() {
|
||||
$photoUploader.focus().click()
|
||||
return false
|
||||
})
|
||||
|
||||
$photoUploader.change(function() {
|
||||
$addPhotoBox.addClass('hidden').before('<li id="add-photo-spinner"><img src="/images/spinner.gif"></li>')
|
||||
$('#photo-form').submit()
|
||||
return false
|
||||
})
|
||||
|
||||
$('#upload-target').load(function() {
|
||||
completePhotoUpload($(this).contents().text())
|
||||
})
|
||||
|
||||
var photoTemplate = window.SI.tmpl($('#photo-template').html())
|
||||
|
||||
function completePhotoUpload(text) {
|
||||
$('#add-photo-spinner').remove()
|
||||
var photoForm = $('#photo-form').get(0)
|
||||
if (photoForm) photoForm.reset()
|
||||
try {
|
||||
var response = JSON.parse(text)
|
||||
if (response.status === 'ok') {
|
||||
$addPhotoBox.before(photoTemplate(response.data.photo))
|
||||
initLightBox()
|
||||
if (response.data.n >= 10) {
|
||||
$addPhotoBox.addClass('hidden')
|
||||
}
|
||||
else {
|
||||
$addPhotoBox.removeClass('hidden')
|
||||
}
|
||||
}
|
||||
else {
|
||||
$addPhotoBox.removeClass('hidden')
|
||||
alert('Failed to add photo. Try again later.')
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
$addPhotoBox.removeClass('hidden')
|
||||
alert('Failed to add photo. Try again later.')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var removeCount = 0
|
||||
$('.remove-photo').live('click', function() {
|
||||
var id = this.id
|
||||
, photoId = id.replace(/^remove-photo-/, '')
|
||||
, data = { id: window.SI.projectId, photo_id: photoId }
|
||||
, spinnerId = 'remove-photo-spinner-' + photoId
|
||||
, $this = $(this)
|
||||
$this.hide().after('<img id="' + spinnerId + '" src="/images/spinner.gif">')
|
||||
removeCount += 1
|
||||
$.post('/project/remove-photo', data, function(response) {
|
||||
removeCount -= 1
|
||||
if (response.status === 'ok' && removeCount === 0) {
|
||||
$addPhotoBox.removeClass('hidden')
|
||||
$('li.photo').remove()
|
||||
|
||||
$.each(response.data.photos, function(i, photo) {
|
||||
$addPhotoBox.before(photoTemplate(photo))
|
||||
})
|
||||
|
||||
initLightBox()
|
||||
}
|
||||
else {
|
||||
if (removeCount === 0) {
|
||||
$('#' + spinnerId).remove()
|
||||
$this.show()
|
||||
alert('Failed to remove photo. Try again later.')
|
||||
}
|
||||
}
|
||||
}).error(function() {
|
||||
removeCount -= 1
|
||||
if (removeCount === 0) {
|
||||
$('#' + spinnerId).remove()
|
||||
$this.show()
|
||||
alert('Failed to remove photo. Try again later.')
|
||||
}
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
function initLightBox() {
|
||||
$('#photos a.thumbnail').lightBox()
|
||||
$('#photos a.thumbnail').live('click', function(){ console.dir(this) })
|
||||
}
|
||||
|
||||
// Simple JavaScript Templating
|
||||
// John Resig - http://ejohn.org/ - MIT Licensed
|
||||
// http://ejohn.org/blog/javascript-micro-templating/
|
||||
function initTmpl() {
|
||||
var cache = {}
|
||||
|
||||
window.SI = window.SI || {}
|
||||
window.SI.tmpl = function tmpl(str, data) {
|
||||
// Figure out if we're getting a template, or if we need to
|
||||
// load the template - and be sure to cache the result.
|
||||
var fn = !/\W/.test(str) ?
|
||||
cache[str] = cache[str] ||
|
||||
tmpl($('#' + str).html()) :
|
||||
|
||||
// Generate a reusable function that will serve as a template
|
||||
// generator (and which will be cached).
|
||||
new Function("obj",
|
||||
"var p=[],print=function(){p.push.apply(p,arguments)};" +
|
||||
|
||||
// Introduce the data as local variables using with(){}
|
||||
"with(obj){p.push('" +
|
||||
|
||||
// Convert the template into pure JavaScript
|
||||
str
|
||||
.replace(/[\r\t\n]/g, " ")
|
||||
.split("<%").join("\t")
|
||||
.replace(/((^|%>)[^\t]*)'/g, "$1\r")
|
||||
.replace(/\t=(.*?)%>/g, "',$1,'")
|
||||
.split("\t").join("');")
|
||||
.split("%>").join("p.push('")
|
||||
.split("\r").join("\\'")
|
||||
+ "')}return p.join('')")
|
||||
|
||||
// Provide some basic currying to the user
|
||||
return data ? fn( data ) : fn
|
||||
}
|
||||
}
|
||||
|
||||
function updatePhotoOrder() {
|
||||
initLightBox()
|
||||
var ids = []
|
||||
$('#photos li.photo').each(function() {
|
||||
ids.push(this.id.replace('photo-', ''))
|
||||
})
|
||||
var data = { id: window.SI.projectId, order: ids }
|
||||
$.post('/project/photo-order', data, function(response) {
|
||||
// noop
|
||||
}).error(function() {
|
||||
alert('Failed to reorder photos. Try again later.')
|
||||
})
|
||||
}
|
||||
|
||||
function validateProject() {
|
||||
var valid = true
|
||||
, nameField = $('#name')
|
||||
|
||||
if ($.trim(nameField.val()).length === 0) {
|
||||
valid = false
|
||||
nameField.addClass('error').focus().select()
|
||||
}
|
||||
else {
|
||||
nameField.removeClass('error')
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
$('.save-button').hide()
|
||||
$('.save-button-spinner').show()
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
10
public/js/forgot-password.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
$(function() {
|
||||
|
||||
$('#email').focus()
|
||||
|
||||
$('#forgot-password-form').submit(function() {
|
||||
$('input[type="submit"]', this).hide()
|
||||
$('#spinner').show()
|
||||
})
|
||||
|
||||
})
|
||||
5
public/js/index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
$(function() {
|
||||
|
||||
// this space intentionally left blank
|
||||
|
||||
})
|
||||
297
public/js/jquery.dragsort.js
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
// jQuery List DragSort v0.4
|
||||
// Website: http://dragsort.codeplex.com/
|
||||
// License: http://dragsort.codeplex.com/license
|
||||
|
||||
(function($) {
|
||||
|
||||
$.fn.dragsort = function(options) {
|
||||
var opts = $.extend({}, $.fn.dragsort.defaults, options);
|
||||
var lists = [];
|
||||
var list = null, lastPos = null;
|
||||
|
||||
this.each(function(i, cont) {
|
||||
|
||||
if ($(cont).is("table") && $(cont).children().size() == 1 && $(cont).children().is("tbody"))
|
||||
cont = $(cont).children().get(0);
|
||||
|
||||
var newList = {
|
||||
draggedItem: null,
|
||||
placeHolderItem: null,
|
||||
pos: null,
|
||||
offset: null,
|
||||
offsetLimit: null,
|
||||
scroll: null,
|
||||
container: cont,
|
||||
|
||||
init: function() {
|
||||
$(this.container).attr("data-listIdx", i).mousedown(this.grabItem)
|
||||
$(this.container).children(opts.itemSelector).each(function(j) { $(this).attr("data-itemIdx", j); });
|
||||
},
|
||||
|
||||
grabItem: function(e) {
|
||||
if (e.which != 1 || $(e.target).is(opts.dragSelectorExclude))
|
||||
return;
|
||||
|
||||
var elm = e.target;
|
||||
while (!$(elm).is("[data-listIdx='" + $(this).attr("data-listIdx") + "'] " + opts.dragSelector)) {
|
||||
if (elm == this) return;
|
||||
elm = elm.parentNode;
|
||||
}
|
||||
|
||||
if (list != null && list.draggedItem != null)
|
||||
list.dropItem();
|
||||
|
||||
list = lists[$(this).attr("data-listIdx")];
|
||||
list.draggedItem = $(elm).closest(opts.itemSelector);
|
||||
var mt = parseInt(list.draggedItem.css("marginTop"));
|
||||
var ml = parseInt(list.draggedItem.css("marginLeft"));
|
||||
list.offset = list.draggedItem.offset();
|
||||
list.offset.top = e.pageY - list.offset.top + (isNaN(mt) ? 0 : mt) - 1;
|
||||
list.offset.left = e.pageX - list.offset.left + (isNaN(ml) ? 0 : ml) - 1;
|
||||
list.draggedItem.startOffset = list.draggedItem.offset()
|
||||
list.draggedItem.startTime = +new Date()
|
||||
|
||||
if (!opts.dragBetween) {
|
||||
var containerHeight = $(list.container).outerHeight() == 0 ? Math.max(1, Math.round(0.5 + $(list.container).children(opts.itemSelector).size() * list.draggedItem.outerWidth() / $(list.container).outerWidth())) * list.draggedItem.outerHeight() : $(list.container).outerHeight();
|
||||
list.offsetLimit = $(list.container).offset();
|
||||
list.offsetLimit.right = list.offsetLimit.left + $(list.container).outerWidth() - list.draggedItem.outerWidth();
|
||||
list.offsetLimit.bottom = list.offsetLimit.top + containerHeight - list.draggedItem.outerHeight();
|
||||
}
|
||||
|
||||
var h = list.draggedItem.height();
|
||||
var w = list.draggedItem.width();
|
||||
var orig = list.draggedItem.attr("style");
|
||||
list.draggedItem.attr("data-origStyle", orig ? orig : "");
|
||||
if (opts.itemSelector == "tr") {
|
||||
list.draggedItem.children().each(function() { $(this).width($(this).width()); });
|
||||
list.placeHolderItem = list.draggedItem.clone().attr("data-placeHolder", true);
|
||||
list.draggedItem.after(list.placeHolderItem);
|
||||
list.placeHolderItem.children().each(function() { $(this).css({ borderWidth:0, width: $(this).width() + 1, height: $(this).height() + 1 }).html(" "); });
|
||||
} else {
|
||||
list.draggedItem.after(opts.placeHolderTemplate);
|
||||
list.placeHolderItem = list.draggedItem.next().css({ height: h, width: w }).attr("data-placeHolder", true);
|
||||
}
|
||||
list.draggedItem.css({ position: "absolute", opacity: 0.8, "z-index": 999, height: h, width: w });
|
||||
|
||||
$(lists).each(function(i, l) { l.createDropTargets(); l.buildPositionTable(); });
|
||||
|
||||
list.scroll = { moveX: 0, moveY: 0, maxX: $(document).width() - $(window).width(), maxY: $(document).height() - $(window).height() };
|
||||
list.scroll.scrollY = window.setInterval(function() {
|
||||
if (opts.scrollContainer != window) {
|
||||
$(opts.scrollContainer).scrollTop($(opts.scrollContainer).scrollTop() + list.scroll.moveY);
|
||||
return;
|
||||
}
|
||||
var t = $(opts.scrollContainer).scrollTop();
|
||||
if (list.scroll.moveY > 0 && t < list.scroll.maxY || list.scroll.moveY < 0 && t > 0) {
|
||||
$(opts.scrollContainer).scrollTop(t + list.scroll.moveY);
|
||||
list.draggedItem.css("top", list.draggedItem.offset().top + list.scroll.moveY + 1);
|
||||
}
|
||||
}, 10);
|
||||
list.scroll.scrollX = window.setInterval(function() {
|
||||
if (opts.scrollContainer != window) {
|
||||
$(opts.scrollContainer).scrollLeft($(opts.scrollContainer).scrollLeft() + list.scroll.moveX);
|
||||
return;
|
||||
}
|
||||
var l = $(opts.scrollContainer).scrollLeft();
|
||||
if (list.scroll.moveX > 0 && l < list.scroll.maxX || list.scroll.moveX < 0 && l > 0) {
|
||||
$(opts.scrollContainer).scrollLeft(l + list.scroll.moveX);
|
||||
list.draggedItem.css("left", list.draggedItem.offset().left + list.scroll.moveX + 1);
|
||||
}
|
||||
}, 10);
|
||||
|
||||
list.setPos(e.pageX, e.pageY);
|
||||
$(document).bind("selectstart", list.stopBubble); //stop ie text selection
|
||||
$(document).bind("mousemove", list.swapItems);
|
||||
$(document).bind("mouseup", list.dropItem);
|
||||
if (opts.scrollContainer != window)
|
||||
$(window).bind("DOMMouseScroll mousewheel", list.wheel);
|
||||
return false; //stop moz text selection
|
||||
},
|
||||
|
||||
setPos: function(x, y) {
|
||||
var top = y - this.offset.top;
|
||||
var left = x - this.offset.left;
|
||||
|
||||
if (!opts.dragBetween) {
|
||||
top = Math.min(this.offsetLimit.bottom, Math.max(top, this.offsetLimit.top));
|
||||
left = Math.min(this.offsetLimit.right, Math.max(left, this.offsetLimit.left));
|
||||
}
|
||||
|
||||
this.draggedItem.parents().each(function() {
|
||||
if ($(this).css("position") != "static" && (!$.browser.mozilla || $(this).css("display") != "table")) {
|
||||
var offset = $(this).offset();
|
||||
top -= offset.top;
|
||||
left -= offset.left;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (opts.scrollContainer == window) {
|
||||
y -= $(window).scrollTop();
|
||||
x -= $(window).scrollLeft();
|
||||
y = Math.max(0, y - $(window).height() + 5) + Math.min(0, y - 5);
|
||||
x = Math.max(0, x - $(window).width() + 5) + Math.min(0, x - 5);
|
||||
} else {
|
||||
var cont = $(opts.scrollContainer);
|
||||
var offset = cont.offset();
|
||||
y = Math.max(0, y - cont.height() - offset.top) + Math.min(0, y - offset.top);
|
||||
x = Math.max(0, x - cont.width() - offset.left) + Math.min(0, x - offset.left);
|
||||
}
|
||||
|
||||
list.scroll.moveX = x == 0 ? 0 : x * opts.scrollSpeed / Math.abs(x);
|
||||
list.scroll.moveY = y == 0 ? 0 : y * opts.scrollSpeed / Math.abs(y);
|
||||
|
||||
this.draggedItem.css({ top: top, left: left });
|
||||
},
|
||||
|
||||
wheel: function(e) {
|
||||
if (($.browser.safari || $.browser.mozilla) && list && opts.scrollContainer != window) {
|
||||
var cont = $(opts.scrollContainer);
|
||||
var offset = cont.offset();
|
||||
if (e.pageX > offset.left && e.pageX < offset.left + cont.width() && e.pageY > offset.top && e.pageY < offset.top + cont.height()) {
|
||||
var delta = e.detail ? e.detail * 5 : e.wheelDelta / -2;
|
||||
cont.scrollTop(cont.scrollTop() + delta);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
buildPositionTable: function() {
|
||||
var item = this.draggedItem == null ? null : this.draggedItem.get(0);
|
||||
var pos = [];
|
||||
$(this.container).children(opts.itemSelector).each(function(i, elm) {
|
||||
if (elm != item) {
|
||||
var loc = $(elm).offset();
|
||||
loc.right = loc.left + $(elm).width();
|
||||
loc.bottom = loc.top + $(elm).height();
|
||||
loc.elm = elm;
|
||||
pos.push(loc);
|
||||
}
|
||||
});
|
||||
this.pos = pos;
|
||||
},
|
||||
|
||||
dropItem: function() {
|
||||
if (list.draggedItem == null)
|
||||
return;
|
||||
|
||||
list.placeHolderItem.before(list.draggedItem);
|
||||
|
||||
//list.draggedItem.attr("style", "") doesn't work on IE8 and jQuery 1.5 or lower
|
||||
//list.draggedItem.removeAttr("style") doesn't work on chrome and jQuery 1.6 (works jQuery 1.5 or lower)
|
||||
var orig = list.draggedItem.attr("data-origStyle");
|
||||
list.draggedItem.attr("style", orig);
|
||||
if (orig == "")
|
||||
list.draggedItem.removeAttr("style");
|
||||
list.draggedItem.removeAttr("data-origStyle");
|
||||
list.placeHolderItem.remove();
|
||||
|
||||
$("[data-dropTarget]").remove();
|
||||
|
||||
window.clearInterval(list.scroll.scrollY);
|
||||
window.clearInterval(list.scroll.scrollX);
|
||||
|
||||
var changed = false;
|
||||
$(lists).each(function() {
|
||||
$(this.container).children(opts.itemSelector).each(function(j) {
|
||||
if (parseInt($(this).attr("data-itemIdx")) != j) {
|
||||
changed = true;
|
||||
$(this).attr("data-itemIdx", j);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var duration = +new Date() - list.draggedItem.startTime
|
||||
, offset = list.draggedItem.offset()
|
||||
, diffY = Math.abs(list.draggedItem.startOffset.top - offset.top)
|
||||
, diffX = Math.abs(list.draggedItem.startOffset.left - offset.left)
|
||||
, itemClicked = duration < 500 && diffX < 3 && diffY < 3
|
||||
|
||||
if (changed)
|
||||
opts.dragEnd.apply(list.draggedItem);
|
||||
else if (itemClicked)
|
||||
opts.itemClicked(list.draggedItem)
|
||||
|
||||
list.draggedItem = null;
|
||||
$(document).unbind("selectstart", list.stopBubble);
|
||||
$(document).unbind("mousemove", list.swapItems);
|
||||
$(document).unbind("mouseup", list.dropItem);
|
||||
if (opts.scrollContainer != window)
|
||||
$(window).unbind("DOMMouseScroll mousewheel", list.wheel);
|
||||
return false;
|
||||
},
|
||||
|
||||
stopBubble: function() { return false; },
|
||||
|
||||
swapItems: function(e) {
|
||||
if (list.draggedItem == null)
|
||||
return false;
|
||||
|
||||
list.setPos(e.pageX, e.pageY);
|
||||
|
||||
var ei = list.findPos(e.pageX, e.pageY);
|
||||
var nlist = list;
|
||||
for (var i = 0; ei == -1 && opts.dragBetween && i < lists.length; i++) {
|
||||
ei = lists[i].findPos(e.pageX, e.pageY);
|
||||
nlist = lists[i];
|
||||
}
|
||||
|
||||
if (ei == -1 || $(nlist.pos[ei].elm).attr("data-placeHolder"))
|
||||
return false;
|
||||
|
||||
if (lastPos == null || lastPos.top > list.draggedItem.offset().top || lastPos.left > list.draggedItem.offset().left)
|
||||
$(nlist.pos[ei].elm).before(list.placeHolderItem);
|
||||
else
|
||||
$(nlist.pos[ei].elm).after(list.placeHolderItem);
|
||||
|
||||
$(lists).each(function(i, l) { l.createDropTargets(); l.buildPositionTable(); });
|
||||
lastPos = list.draggedItem.offset();
|
||||
return false;
|
||||
},
|
||||
|
||||
findPos: function(x, y) {
|
||||
for (var i = 0; i < this.pos.length; i++) {
|
||||
if (this.pos[i].left < x && this.pos[i].right > x && this.pos[i].top < y && this.pos[i].bottom > y)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
},
|
||||
|
||||
createDropTargets: function() {
|
||||
if (!opts.dragBetween)
|
||||
return;
|
||||
|
||||
$(lists).each(function() {
|
||||
var ph = $(this.container).find("[data-placeHolder]");
|
||||
var dt = $(this.container).find("[data-dropTarget]");
|
||||
if (ph.size() > 0 && dt.size() > 0)
|
||||
dt.remove();
|
||||
else if (ph.size() == 0 && dt.size() == 0) {
|
||||
//list.placeHolderItem.clone().removeAttr("data-placeHolder") crashes in IE7 and jquery 1.5.1 (doesn't in jquery 1.4.2 or IE8)
|
||||
$(this.container).append(list.placeHolderItem.removeAttr("data-placeHolder").clone().attr("data-dropTarget", true));
|
||||
list.placeHolderItem.attr("data-placeHolder", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
newList.init();
|
||||
lists.push(newList);
|
||||
});
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
$.fn.dragsort.defaults = {
|
||||
itemClicked: function() { },
|
||||
itemSelector: "li",
|
||||
dragSelector: "li",
|
||||
dragSelectorExclude: "input, textarea, a[href]",
|
||||
dragEnd: function() { },
|
||||
dragBetween: false,
|
||||
placeHolderTemplate: "<li> </li>",
|
||||
scrollContainer: window,
|
||||
scrollSpeed: 5
|
||||
};
|
||||
|
||||
})(jQuery);
|
||||
548
public/js/jquery.jeditable.js
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
/*
|
||||
* Jeditable - jQuery in place edit plugin
|
||||
*
|
||||
* Copyright (c) 2006-2009 Mika Tuupola, Dylan Verheul
|
||||
*
|
||||
* Licensed under the MIT license:
|
||||
* http://www.opensource.org/licenses/mit-license.php
|
||||
*
|
||||
* Project home:
|
||||
* http://www.appelsiini.net/projects/jeditable
|
||||
*
|
||||
* Based on editable by Dylan Verheul <dylan_at_dyve.net>:
|
||||
* http://www.dyve.net/jquery/?editable
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Version 1.7.1
|
||||
*
|
||||
* ** means there is basic unit tests for this parameter.
|
||||
*
|
||||
* @name Jeditable
|
||||
* @type jQuery
|
||||
* @param String target (POST) URL or function to send edited content to **
|
||||
* @param Hash options additional options
|
||||
* @param String options[method] method to use to send edited content (POST or PUT) **
|
||||
* @param Function options[callback] Function to run after submitting edited content **
|
||||
* @param String options[name] POST parameter name of edited content
|
||||
* @param String options[id] POST parameter name of edited div id
|
||||
* @param Hash options[submitdata] Extra parameters to send when submitting edited content.
|
||||
* @param String options[type] text, textarea or select (or any 3rd party input type) **
|
||||
* @param Integer options[rows] number of rows if using textarea **
|
||||
* @param Integer options[cols] number of columns if using textarea **
|
||||
* @param Mixed options[height] 'auto', 'none' or height in pixels **
|
||||
* @param Mixed options[width] 'auto', 'none' or width in pixels **
|
||||
* @param String options[loadurl] URL to fetch input content before editing **
|
||||
* @param String options[loadtype] Request type for load url. Should be GET or POST.
|
||||
* @param String options[loadtext] Text to display while loading external content.
|
||||
* @param Mixed options[loaddata] Extra parameters to pass when fetching content before editing.
|
||||
* @param Mixed options[data] Or content given as paramameter. String or function.**
|
||||
* @param String options[indicator] indicator html to show when saving
|
||||
* @param String options[tooltip] optional tooltip text via title attribute **
|
||||
* @param String options[event] jQuery event such as 'click' of 'dblclick' **
|
||||
* @param String options[submit] submit button value, empty means no button **
|
||||
* @param String options[cancel] cancel button value, empty means no button **
|
||||
* @param String options[cssclass] CSS class to apply to input form. 'inherit' to copy from parent. **
|
||||
* @param String options[style] Style to apply to input form 'inherit' to copy from parent. **
|
||||
* @param String options[select] true or false, when true text is highlighted ??
|
||||
* @param String options[placeholder] Placeholder text or html to insert when element is empty. **
|
||||
* @param String options[onblur] 'cancel', 'submit', 'ignore' or function ??
|
||||
*
|
||||
* @param Function options[onsubmit] function(settings, original) { ... } called before submit
|
||||
* @param Function options[onreset] function(settings, original) { ... } called before reset
|
||||
* @param Function options[onerror] function(settings, original, xhr) { ... } called on error
|
||||
*
|
||||
* @param Hash options[ajaxoptions] jQuery Ajax options. See docs.jquery.com.
|
||||
*
|
||||
*/
|
||||
|
||||
(function($) {
|
||||
|
||||
$.fn.editable = function(target, options) {
|
||||
|
||||
if ('disable' == target) {
|
||||
$(this).data('disabled.editable', true);
|
||||
return;
|
||||
}
|
||||
if ('enable' == target) {
|
||||
$(this).data('disabled.editable', false);
|
||||
return;
|
||||
}
|
||||
if ('destroy' == target) {
|
||||
$(this)
|
||||
.unbind($(this).data('event.editable'))
|
||||
.removeData('disabled.editable')
|
||||
.removeData('event.editable');
|
||||
return;
|
||||
}
|
||||
|
||||
var settings = $.extend({}, $.fn.editable.defaults, {target:target}, options);
|
||||
|
||||
/* setup some functions */
|
||||
var plugin = $.editable.types[settings.type].plugin || function() { };
|
||||
var submit = $.editable.types[settings.type].submit || function() { };
|
||||
var buttons = $.editable.types[settings.type].buttons
|
||||
|| $.editable.types['defaults'].buttons;
|
||||
var content = $.editable.types[settings.type].content
|
||||
|| $.editable.types['defaults'].content;
|
||||
var element = $.editable.types[settings.type].element
|
||||
|| $.editable.types['defaults'].element;
|
||||
var reset = $.editable.types[settings.type].reset
|
||||
|| $.editable.types['defaults'].reset;
|
||||
var callback = settings.callback || function() { };
|
||||
var onedit = settings.onedit || function() { };
|
||||
var onsubmit = settings.onsubmit || function() { };
|
||||
var onreset = settings.onreset || function() { };
|
||||
var onerror = settings.onerror || reset;
|
||||
|
||||
/* show tooltip */
|
||||
if (settings.tooltip) {
|
||||
$(this).attr('title', settings.tooltip);
|
||||
}
|
||||
|
||||
settings.autowidth = 'auto' == settings.width;
|
||||
settings.autoheight = 'auto' == settings.height;
|
||||
|
||||
return this.each(function() {
|
||||
|
||||
/* save this to self because this changes when scope changes */
|
||||
var self = this;
|
||||
|
||||
/* inlined block elements lose their width and height after first edit */
|
||||
/* save them for later use as workaround */
|
||||
var savedwidth = $(self).width();
|
||||
var savedheight = $(self).height();
|
||||
|
||||
/* save so it can be later used by $.editable('destroy') */
|
||||
$(this).data('event.editable', settings.event);
|
||||
|
||||
/* if element is empty add something clickable (if requested) */
|
||||
if (!$.trim($(this).html())) {
|
||||
$(this).html(settings.placeholder);
|
||||
}
|
||||
|
||||
$(this).bind(settings.event, function(e) {
|
||||
|
||||
/* abort if disabled for this element */
|
||||
if (true === $(this).data('disabled.editable')) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* prevent throwing an exeption if edit field is clicked again */
|
||||
if (self.editing) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* abort if onedit hook returns false */
|
||||
if (false === onedit.apply(this, [settings, self])) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* prevent default action and bubbling */
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
/* remove tooltip */
|
||||
if (settings.tooltip) {
|
||||
$(self).removeAttr('title');
|
||||
}
|
||||
|
||||
/* figure out how wide and tall we are, saved width and height */
|
||||
/* are workaround for http://dev.jquery.com/ticket/2190 */
|
||||
if (0 == $(self).width()) {
|
||||
//$(self).css('visibility', 'hidden');
|
||||
settings.width = savedwidth;
|
||||
settings.height = savedheight;
|
||||
} else {
|
||||
if (settings.width != 'none') {
|
||||
settings.width =
|
||||
settings.autowidth ? $(self).width() : settings.width;
|
||||
}
|
||||
if (settings.height != 'none') {
|
||||
settings.height =
|
||||
settings.autoheight ? $(self).height() : settings.height;
|
||||
}
|
||||
}
|
||||
//$(this).css('visibility', '');
|
||||
|
||||
/* remove placeholder text, replace is here because of IE */
|
||||
if ($(this).html().toLowerCase().replace(/(;|")/g, '') ==
|
||||
settings.placeholder.toLowerCase().replace(/(;|")/g, '')) {
|
||||
$(this).html('');
|
||||
}
|
||||
|
||||
self.editing = true;
|
||||
self.revert = $(self).html();
|
||||
$(self).html('');
|
||||
|
||||
/* create the form object */
|
||||
var form = $('<form />');
|
||||
|
||||
/* apply css or style or both */
|
||||
if (settings.cssclass) {
|
||||
if ('inherit' == settings.cssclass) {
|
||||
form.attr('class', $(self).attr('class'));
|
||||
} else {
|
||||
form.attr('class', settings.cssclass);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.style) {
|
||||
if ('inherit' == settings.style) {
|
||||
form.attr('style', $(self).attr('style'));
|
||||
/* IE needs the second line or display wont be inherited */
|
||||
form.css('display', $(self).css('display'));
|
||||
} else {
|
||||
form.attr('style', settings.style);
|
||||
}
|
||||
}
|
||||
|
||||
/* add main input element to form and store it in input */
|
||||
var input = element.apply(form, [settings, self]);
|
||||
|
||||
/* set input content via POST, GET, given data or existing value */
|
||||
var input_content;
|
||||
|
||||
if (settings.loadurl) {
|
||||
var t = setTimeout(function() {
|
||||
input.disabled = true;
|
||||
content.apply(form, [settings.loadtext, settings, self]);
|
||||
}, 100);
|
||||
|
||||
var loaddata = {};
|
||||
loaddata[settings.id] = self.id;
|
||||
if ($.isFunction(settings.loaddata)) {
|
||||
$.extend(loaddata, settings.loaddata.apply(self, [self.revert, settings]));
|
||||
} else {
|
||||
$.extend(loaddata, settings.loaddata);
|
||||
}
|
||||
$.ajax({
|
||||
type : settings.loadtype,
|
||||
url : settings.loadurl,
|
||||
data : loaddata,
|
||||
async : false,
|
||||
success: function(result) {
|
||||
window.clearTimeout(t);
|
||||
input_content = result;
|
||||
input.disabled = false;
|
||||
}
|
||||
});
|
||||
} else if (settings.data) {
|
||||
input_content = settings.data;
|
||||
if ($.isFunction(settings.data)) {
|
||||
input_content = settings.data.apply(self, [self.revert, settings]);
|
||||
}
|
||||
} else {
|
||||
input_content = self.revert;
|
||||
}
|
||||
content.apply(form, [input_content, settings, self]);
|
||||
|
||||
input.attr('name', settings.name);
|
||||
|
||||
/* add buttons to the form */
|
||||
buttons.apply(form, [settings, self]);
|
||||
|
||||
/* add created form to self */
|
||||
$(self).append(form);
|
||||
|
||||
/* attach 3rd party plugin if requested */
|
||||
plugin.apply(form, [settings, self]);
|
||||
|
||||
/* focus to first visible form element */
|
||||
$(':input:visible:enabled:first', form).focus();
|
||||
|
||||
/* highlight input contents when requested */
|
||||
if (settings.select) {
|
||||
input.select();
|
||||
}
|
||||
|
||||
/* discard changes if pressing esc */
|
||||
input.keydown(function(e) {
|
||||
if (e.keyCode == 27) {
|
||||
e.preventDefault();
|
||||
//self.reset();
|
||||
reset.apply(form, [settings, self]);
|
||||
}
|
||||
});
|
||||
|
||||
/* discard, submit or nothing with changes when clicking outside */
|
||||
/* do nothing is usable when navigating with tab */
|
||||
var t;
|
||||
if ('cancel' == settings.onblur) {
|
||||
input.blur(function(e) {
|
||||
/* prevent canceling if submit was clicked */
|
||||
t = setTimeout(function() {
|
||||
reset.apply(form, [settings, self]);
|
||||
}, 500);
|
||||
});
|
||||
} else if ('submit' == settings.onblur) {
|
||||
input.blur(function(e) {
|
||||
/* prevent double submit if submit was clicked */
|
||||
t = setTimeout(function() {
|
||||
form.submit();
|
||||
}, 200);
|
||||
});
|
||||
} else if ($.isFunction(settings.onblur)) {
|
||||
input.blur(function(e) {
|
||||
settings.onblur.apply(self, [input.val(), settings]);
|
||||
});
|
||||
} else {
|
||||
input.blur(function(e) {
|
||||
/* TODO: maybe something here */
|
||||
});
|
||||
}
|
||||
|
||||
form.submit(function(e) {
|
||||
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
}
|
||||
|
||||
/* do no submit */
|
||||
e.preventDefault();
|
||||
|
||||
/* call before submit hook. */
|
||||
/* if it returns false abort submitting */
|
||||
if (false !== onsubmit.apply(form, [settings, self])) {
|
||||
/* custom inputs call before submit hook. */
|
||||
/* if it returns false abort submitting */
|
||||
if (false !== submit.apply(form, [settings, self])) {
|
||||
|
||||
/* check if given target is function */
|
||||
if ($.isFunction(settings.target)) {
|
||||
var str = settings.target.apply(self, [input.val(), settings]);
|
||||
if (str !== false) {
|
||||
$(self).html(str);
|
||||
self.editing = false;
|
||||
callback.apply(self, [self.innerHTML, settings]);
|
||||
/* TODO: this is not dry */
|
||||
if (!$.trim($(self).html())) {
|
||||
$(self).html(settings.placeholder);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/* add edited content and id of edited element to POST */
|
||||
var submitdata = {};
|
||||
submitdata[settings.name] = input.val();
|
||||
submitdata[settings.id] = self.id;
|
||||
/* add extra data to be POST:ed */
|
||||
if ($.isFunction(settings.submitdata)) {
|
||||
$.extend(submitdata, settings.submitdata.apply(self, [self.revert, settings]));
|
||||
} else {
|
||||
$.extend(submitdata, settings.submitdata);
|
||||
}
|
||||
|
||||
/* quick and dirty PUT support */
|
||||
if ('PUT' == settings.method) {
|
||||
submitdata['_method'] = 'put';
|
||||
}
|
||||
|
||||
/* show the saving indicator */
|
||||
$(self).html(settings.indicator);
|
||||
|
||||
/* defaults for ajaxoptions */
|
||||
var ajaxoptions = {
|
||||
type : 'POST',
|
||||
data : submitdata,
|
||||
dataType: 'html',
|
||||
url : settings.target,
|
||||
success : function(result, status) {
|
||||
if (ajaxoptions.dataType == 'html') {
|
||||
$(self).html(result);
|
||||
}
|
||||
self.editing = false;
|
||||
callback.apply(self, [result, settings]);
|
||||
if (!$.trim($(self).html())) {
|
||||
$(self).html(settings.placeholder);
|
||||
}
|
||||
},
|
||||
error : function(xhr, status, error) {
|
||||
onerror.apply(form, [settings, self, xhr]);
|
||||
}
|
||||
};
|
||||
|
||||
/* override with what is given in settings.ajaxoptions */
|
||||
$.extend(ajaxoptions, settings.ajaxoptions);
|
||||
$.ajax(ajaxoptions);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* show tooltip again */
|
||||
$(self).attr('title', settings.tooltip);
|
||||
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
/* privileged methods */
|
||||
this.reset = function(form) {
|
||||
/* prevent calling reset twice when blurring */
|
||||
if (this.editing) {
|
||||
/* before reset hook, if it returns false abort reseting */
|
||||
if (false !== onreset.apply(form, [settings, self])) {
|
||||
$(self).html(self.revert);
|
||||
self.editing = false;
|
||||
if (!$.trim($(self).html())) {
|
||||
$(self).html(settings.placeholder);
|
||||
}
|
||||
/* show tooltip again */
|
||||
if (settings.tooltip) {
|
||||
$(self).attr('title', settings.tooltip);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
|
||||
$.editable = {
|
||||
types: {
|
||||
defaults: {
|
||||
element : function(settings, original) {
|
||||
var input = $('<input type="hidden"></input>');
|
||||
$(this).append(input);
|
||||
return(input);
|
||||
},
|
||||
content : function(string, settings, original) {
|
||||
$(':input:first', this).val(string);
|
||||
},
|
||||
reset : function(settings, original) {
|
||||
original.reset(this);
|
||||
},
|
||||
buttons : function(settings, original) {
|
||||
var form = this;
|
||||
if (settings.submit) {
|
||||
/* if given html string use that */
|
||||
if (settings.submit.match(/>$/)) {
|
||||
var submit = $(settings.submit).click(function() {
|
||||
if (submit.attr("type") != "submit") {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
/* otherwise use button with given string as text */
|
||||
} else {
|
||||
var submit = $('<button type="submit" />');
|
||||
submit.html(settings.submit);
|
||||
}
|
||||
$(this).append(submit);
|
||||
}
|
||||
if (settings.cancel) {
|
||||
/* if given html string use that */
|
||||
if (settings.cancel.match(/>$/)) {
|
||||
var cancel = $(settings.cancel);
|
||||
/* otherwise use button with given string as text */
|
||||
} else {
|
||||
var cancel = $('<button type="cancel" />');
|
||||
cancel.html(settings.cancel);
|
||||
}
|
||||
$(this).append(cancel);
|
||||
|
||||
$(cancel).click(function(event) {
|
||||
//original.reset();
|
||||
if ($.isFunction($.editable.types[settings.type].reset)) {
|
||||
var reset = $.editable.types[settings.type].reset;
|
||||
} else {
|
||||
var reset = $.editable.types['defaults'].reset;
|
||||
}
|
||||
reset.apply(form, [settings, original]);
|
||||
if ($.isFunction(settings.oncancel)) {
|
||||
settings.oncancel.apply(form, [settings, original]);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
text: {
|
||||
element : function(settings, original) {
|
||||
var input = $('<input type="text" class="editable" />');
|
||||
if (settings.width != 'none') { input.width(settings.width); }
|
||||
if (settings.height != 'none') { input.height(settings.height); }
|
||||
/* https://bugzilla.mozilla.org/show_bug.cgi?id=236791 */
|
||||
//input[0].setAttribute('autocomplete','off');
|
||||
input.attr('autocomplete','off');
|
||||
$(this).append(input);
|
||||
return(input);
|
||||
}
|
||||
},
|
||||
textarea: {
|
||||
element : function(settings, original) {
|
||||
var textarea = $('<textarea />');
|
||||
if (settings.rows) {
|
||||
textarea.attr('rows', settings.rows);
|
||||
} else if (settings.height != "none") {
|
||||
textarea.height(settings.height);
|
||||
}
|
||||
if (settings.cols) {
|
||||
textarea.attr('cols', settings.cols);
|
||||
} else if (settings.width != "none") {
|
||||
textarea.width(settings.width);
|
||||
}
|
||||
$(this).append(textarea);
|
||||
return(textarea);
|
||||
}
|
||||
},
|
||||
select: {
|
||||
element : function(settings, original) {
|
||||
var select = $('<select />');
|
||||
$(this).append(select);
|
||||
return(select);
|
||||
},
|
||||
content : function(data, settings, original) {
|
||||
/* If it is string assume it is json. */
|
||||
if (String == data.constructor) {
|
||||
eval ('var json = ' + data);
|
||||
} else {
|
||||
/* Otherwise assume it is a hash already. */
|
||||
var json = data;
|
||||
}
|
||||
for (var key in json) {
|
||||
if (!json.hasOwnProject(key)) {
|
||||
continue;
|
||||
}
|
||||
if ('selected' == key) {
|
||||
continue;
|
||||
}
|
||||
var option = $('<option />').val(key).append(json[key]);
|
||||
$('select', this).append(option);
|
||||
}
|
||||
/* Loop option again to set selected. IE needed this... */
|
||||
$('select', this).children().each(function() {
|
||||
if ($(this).val() == json['selected'] ||
|
||||
$(this).text() == $.trim(original.revert)) {
|
||||
$(this).attr('selected', 'selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/* Add new input type */
|
||||
addInputType: function(name, input) {
|
||||
$.editable.types[name] = input;
|
||||
}
|
||||
};
|
||||
|
||||
// publicly accessible defaults
|
||||
$.fn.editable.defaults = {
|
||||
name : 'value',
|
||||
id : 'id',
|
||||
type : 'text',
|
||||
width : 'auto',
|
||||
height : 'auto',
|
||||
event : 'click.editable',
|
||||
onblur : 'cancel',
|
||||
loadtype : 'GET',
|
||||
loadtext : 'Loading...',
|
||||
placeholder: 'Click to edit',
|
||||
loaddata : {},
|
||||
submitdata : {},
|
||||
ajaxoptions: {}
|
||||
};
|
||||
|
||||
})(jQuery);
|
||||
472
public/js/jquery.lightbox-0.5.js
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
/**
|
||||
* jQuery lightBox plugin
|
||||
* This jQuery plugin was inspired and based on Lightbox 2 by Lokesh Dhakar (http://www.huddletogether.com/projects/lightbox2/)
|
||||
* and adapted to me for use like a plugin from jQuery.
|
||||
* @name jquery-lightbox-0.5.js
|
||||
* @author Leandro Vieira Pinho - http://leandrovieira.com
|
||||
* @version 0.5
|
||||
* @date April 11, 2008
|
||||
* @category jQuery plugin
|
||||
* @copyright (c) 2008 Leandro Vieira Pinho (leandrovieira.com)
|
||||
* @license CCAttribution-ShareAlike 2.5 Brazil - http://creativecommons.org/licenses/by-sa/2.5/br/deed.en_US
|
||||
* @example Visit http://leandrovieira.com/projects/jquery/lightbox/ for more informations about this jQuery plugin
|
||||
*/
|
||||
|
||||
// Offering a Custom Alias suport - More info: http://docs.jquery.com/Plugins/Authoring#Custom_Alias
|
||||
(function($) {
|
||||
/**
|
||||
* $ is an alias to jQuery object
|
||||
*
|
||||
*/
|
||||
$.fn.lightBox = function(settings) {
|
||||
// Settings to configure the jQuery lightBox plugin how you like
|
||||
settings = jQuery.extend({
|
||||
// Configuration related to overlay
|
||||
overlayBgColor: '#000', // (string) Background color to overlay; inform a hexadecimal value like: #RRGGBB. Where RR, GG, and BB are the hexadecimal values for the red, green, and blue values of the color.
|
||||
overlayOpacity: 0.8, // (integer) Opacity value to overlay; inform: 0.X. Where X are number from 0 to 9
|
||||
// Configuration related to navigation
|
||||
fixedNavigation: false, // (boolean) Boolean that informs if the navigation (next and prev button) will be fixed or not in the interface.
|
||||
// Configuration related to images
|
||||
imageLoading: '/images/lightbox-ico-loading.gif', // (string) Path and the name of the loading icon
|
||||
imageBtnPrev: '/images/lightbox-btn-prev.gif', // (string) Path and the name of the prev button image
|
||||
imageBtnNext: '/images/lightbox-btn-next.gif', // (string) Path and the name of the next button image
|
||||
imageBtnClose: '/images/lightbox-btn-close.gif', // (string) Path and the name of the close btn
|
||||
imageBlank: '/images/lightbox-blank.gif', // (string) Path and the name of a blank image (one pixel)
|
||||
// Configuration related to container image box
|
||||
containerBorderSize: 10, // (integer) If you adjust the padding in the CSS for the container, #lightbox-container-image-box, you will need to update this value
|
||||
containerResizeSpeed: 400, // (integer) Specify the resize duration of container image. These number are miliseconds. 400 is default.
|
||||
// Configuration related to texts in caption. For example: Image 2 of 8. You can alter either "Image" and "of" texts.
|
||||
txtImage: 'Image', // (string) Specify text "Image"
|
||||
txtOf: 'of', // (string) Specify text "of"
|
||||
// Configuration related to keyboard navigation
|
||||
keyToClose: 'c', // (string) (c = close) Letter to close the jQuery lightBox interface. Beyond this letter, the letter X and the SCAPE key is used to.
|
||||
keyToPrev: 'p', // (string) (p = previous) Letter to show the previous image
|
||||
keyToNext: 'n', // (string) (n = next) Letter to show the next image.
|
||||
// Don´t alter these variables in any way
|
||||
imageArray: [],
|
||||
activeImage: 0
|
||||
},settings);
|
||||
// Caching the jQuery object with all elements matched
|
||||
var jQueryMatchedObj = this; // This, in this context, refer to jQuery object
|
||||
/**
|
||||
* Initializing the plugin calling the start function
|
||||
*
|
||||
* @return boolean false
|
||||
*/
|
||||
function _initialize() {
|
||||
_start(this,jQueryMatchedObj); // This, in this context, refer to object (link) which the user have clicked
|
||||
return false; // Avoid the browser following the link
|
||||
}
|
||||
/**
|
||||
* Start the jQuery lightBox plugin
|
||||
*
|
||||
* @param object objClicked The object (link) whick the user have clicked
|
||||
* @param object jQueryMatchedObj The jQuery object with all elements matched
|
||||
*/
|
||||
function _start(objClicked,jQueryMatchedObj) {
|
||||
// Hime some elements to avoid conflict with overlay in IE. These elements appear above the overlay.
|
||||
$('embed, object, select').css({ 'visibility' : 'hidden' });
|
||||
// Call the function to create the markup structure; style some elements; assign events in some elements.
|
||||
_set_interface();
|
||||
// Unset total images in imageArray
|
||||
settings.imageArray.length = 0;
|
||||
// Unset image active information
|
||||
settings.activeImage = 0;
|
||||
// We have an image set? Or just an image? Let´s see it.
|
||||
if ( jQueryMatchedObj.length == 1 ) {
|
||||
settings.imageArray.push(new Array(objClicked.getAttribute('href'),objClicked.getAttribute('title')));
|
||||
} else {
|
||||
// Add an Array (as many as we have), with href and title atributes, inside the Array that storage the images references
|
||||
for ( var i = 0; i < jQueryMatchedObj.length; i++ ) {
|
||||
settings.imageArray.push(new Array(jQueryMatchedObj[i].getAttribute('href'),jQueryMatchedObj[i].getAttribute('title')));
|
||||
}
|
||||
}
|
||||
while ( settings.imageArray[settings.activeImage][0] != objClicked.getAttribute('href') ) {
|
||||
settings.activeImage++;
|
||||
}
|
||||
// Call the function that prepares image exibition
|
||||
_set_image_to_view();
|
||||
}
|
||||
/**
|
||||
* Create the jQuery lightBox plugin interface
|
||||
*
|
||||
* The HTML markup will be like that:
|
||||
<div id="jquery-overlay"></div>
|
||||
<div id="jquery-lightbox">
|
||||
<div id="lightbox-container-image-box">
|
||||
<div id="lightbox-container-image">
|
||||
<img src="../fotos/XX.jpg" id="lightbox-image">
|
||||
<div id="lightbox-nav">
|
||||
<a href="#" id="lightbox-nav-btnPrev"></a>
|
||||
<a href="#" id="lightbox-nav-btnNext"></a>
|
||||
</div>
|
||||
<div id="lightbox-loading">
|
||||
<a href="#" id="lightbox-loading-link">
|
||||
<img src="/images/lightbox-ico-loading.gif">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lightbox-container-image-data-box">
|
||||
<div id="lightbox-container-image-data">
|
||||
<div id="lightbox-image-details">
|
||||
<span id="lightbox-image-details-caption"></span>
|
||||
<span id="lightbox-image-details-currentNumber"></span>
|
||||
</div>
|
||||
<div id="lightbox-secNav">
|
||||
<a href="#" id="lightbox-secNav-btnClose">
|
||||
<img src="/images/lightbox-btn-close.gif">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
*
|
||||
*/
|
||||
function _set_interface() {
|
||||
// Apply the HTML markup into body tag
|
||||
$('body').append('<div id="jquery-overlay"></div><div id="jquery-lightbox"><div id="lightbox-container-image-box"><div id="lightbox-container-image"><img id="lightbox-image"><div style="" id="lightbox-nav"><a href="#" id="lightbox-nav-btnPrev"></a><a href="#" id="lightbox-nav-btnNext"></a></div><div id="lightbox-loading"><a href="#" id="lightbox-loading-link"><img src="' + settings.imageLoading + '"></a></div></div></div><div id="lightbox-container-image-data-box"><div id="lightbox-container-image-data"><div id="lightbox-image-details"><span id="lightbox-image-details-caption"></span><span id="lightbox-image-details-currentNumber"></span></div><div id="lightbox-secNav"><a href="#" id="lightbox-secNav-btnClose"><img src="' + settings.imageBtnClose + '"></a></div></div></div></div>');
|
||||
// Get page sizes
|
||||
var arrPageSizes = ___getPageSize();
|
||||
// Style overlay and show it
|
||||
$('#jquery-overlay').css({
|
||||
backgroundColor: settings.overlayBgColor,
|
||||
opacity: settings.overlayOpacity,
|
||||
width: arrPageSizes[0],
|
||||
height: arrPageSizes[1]
|
||||
}).fadeIn();
|
||||
// Get page scroll
|
||||
var arrPageScroll = ___getPageScroll();
|
||||
// Calculate top and left offset for the jquery-lightbox div object and show it
|
||||
$('#jquery-lightbox').css({
|
||||
top: arrPageScroll[1] + (arrPageSizes[3] / 10),
|
||||
left: arrPageScroll[0]
|
||||
}).show();
|
||||
// Assigning click events in elements to close overlay
|
||||
$('#jquery-overlay,#jquery-lightbox').click(function() {
|
||||
_finish();
|
||||
});
|
||||
// Assign the _finish function to lightbox-loading-link and lightbox-secNav-btnClose objects
|
||||
$('#lightbox-loading-link,#lightbox-secNav-btnClose').click(function() {
|
||||
_finish();
|
||||
return false;
|
||||
});
|
||||
// If window was resized, calculate the new overlay dimensions
|
||||
$(window).resize(function() {
|
||||
// Get page sizes
|
||||
var arrPageSizes = ___getPageSize();
|
||||
// Style overlay and show it
|
||||
$('#jquery-overlay').css({
|
||||
width: arrPageSizes[0],
|
||||
height: arrPageSizes[1]
|
||||
});
|
||||
// Get page scroll
|
||||
var arrPageScroll = ___getPageScroll();
|
||||
// Calculate top and left offset for the jquery-lightbox div object and show it
|
||||
$('#jquery-lightbox').css({
|
||||
top: arrPageScroll[1] + (arrPageSizes[3] / 10),
|
||||
left: arrPageScroll[0]
|
||||
});
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Prepares image exibition; doing a image´s preloader to calculate it´s size
|
||||
*
|
||||
*/
|
||||
function _set_image_to_view() { // show the loading
|
||||
// Show the loading
|
||||
$('#lightbox-loading').show();
|
||||
if ( settings.fixedNavigation ) {
|
||||
$('#lightbox-image,#lightbox-container-image-data-box,#lightbox-image-details-currentNumber').hide();
|
||||
} else {
|
||||
// Hide some elements
|
||||
$('#lightbox-image,#lightbox-nav,#lightbox-nav-btnPrev,#lightbox-nav-btnNext,#lightbox-container-image-data-box,#lightbox-image-details-currentNumber').hide();
|
||||
}
|
||||
// Image preload process
|
||||
var objImagePreloader = new Image();
|
||||
objImagePreloader.onload = function() {
|
||||
$('#lightbox-image').attr('src',settings.imageArray[settings.activeImage][0]);
|
||||
// Perfomance an effect in the image container resizing it
|
||||
_resize_container_image_box(objImagePreloader.width,objImagePreloader.height);
|
||||
// clear onLoad, IE behaves irratically with animated gifs otherwise
|
||||
objImagePreloader.onload=function(){};
|
||||
};
|
||||
objImagePreloader.src = settings.imageArray[settings.activeImage][0];
|
||||
};
|
||||
/**
|
||||
* Perfomance an effect in the image container resizing it
|
||||
*
|
||||
* @param integer intImageWidth The image´s width that will be showed
|
||||
* @param integer intImageHeight The image´s height that will be showed
|
||||
*/
|
||||
function _resize_container_image_box(intImageWidth,intImageHeight) {
|
||||
// Get current width and height
|
||||
var intCurrentWidth = $('#lightbox-container-image-box').width();
|
||||
var intCurrentHeight = $('#lightbox-container-image-box').height();
|
||||
// Get the width and height of the selected image plus the padding
|
||||
var intWidth = (intImageWidth + (settings.containerBorderSize * 2)); // Plus the image´s width and the left and right padding value
|
||||
var intHeight = (intImageHeight + (settings.containerBorderSize * 2)); // Plus the image´s height and the left and right padding value
|
||||
// Diferences
|
||||
var intDiffW = intCurrentWidth - intWidth;
|
||||
var intDiffH = intCurrentHeight - intHeight;
|
||||
// Perfomance the effect
|
||||
$('#lightbox-container-image-box').animate({ width: intWidth, height: intHeight },settings.containerResizeSpeed,function() { _show_image(); });
|
||||
if ( ( intDiffW == 0 ) && ( intDiffH == 0 ) ) {
|
||||
if ( $.browser.msie ) {
|
||||
___pause(250);
|
||||
} else {
|
||||
___pause(100);
|
||||
}
|
||||
}
|
||||
$('#lightbox-container-image-data-box').css({ width: intImageWidth });
|
||||
$('#lightbox-nav-btnPrev,#lightbox-nav-btnNext').css({ height: intImageHeight + (settings.containerBorderSize * 2) });
|
||||
};
|
||||
/**
|
||||
* Show the prepared image
|
||||
*
|
||||
*/
|
||||
function _show_image() {
|
||||
$('#lightbox-loading').hide();
|
||||
$('#lightbox-image').fadeIn(function() {
|
||||
_show_image_data();
|
||||
_set_navigation();
|
||||
});
|
||||
_preload_neighbor_images();
|
||||
};
|
||||
/**
|
||||
* Show the image information
|
||||
*
|
||||
*/
|
||||
function _show_image_data() {
|
||||
$('#lightbox-container-image-data-box').slideDown('fast');
|
||||
$('#lightbox-image-details-caption').hide();
|
||||
if ( settings.imageArray[settings.activeImage][1] ) {
|
||||
$('#lightbox-image-details-caption').html(settings.imageArray[settings.activeImage][1]).show();
|
||||
}
|
||||
// If we have a image set, display 'Image X of X'
|
||||
if ( settings.imageArray.length > 1 ) {
|
||||
$('#lightbox-image-details-currentNumber').html(settings.txtImage + ' ' + ( settings.activeImage + 1 ) + ' ' + settings.txtOf + ' ' + settings.imageArray.length).show();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Display the button navigations
|
||||
*
|
||||
*/
|
||||
function _set_navigation() {
|
||||
$('#lightbox-nav').show();
|
||||
|
||||
// Instead to define this configuration in CSS file, we define here. And it´s need to IE. Just.
|
||||
$('#lightbox-nav-btnPrev,#lightbox-nav-btnNext').css({ 'background' : 'transparent url(' + settings.imageBlank + ') no-repeat' });
|
||||
|
||||
// Show the prev button, if not the first image in set
|
||||
if ( settings.activeImage != 0 ) {
|
||||
if ( settings.fixedNavigation ) {
|
||||
$('#lightbox-nav-btnPrev').css({ 'background' : 'url(' + settings.imageBtnPrev + ') left 15% no-repeat' })
|
||||
.unbind()
|
||||
.bind('click',function() {
|
||||
settings.activeImage = settings.activeImage - 1;
|
||||
_set_image_to_view();
|
||||
return false;
|
||||
});
|
||||
} else {
|
||||
// Show the images button for Next buttons
|
||||
$('#lightbox-nav-btnPrev').unbind().hover(function() {
|
||||
$(this).css({ 'background' : 'url(' + settings.imageBtnPrev + ') left 15% no-repeat' });
|
||||
},function() {
|
||||
$(this).css({ 'background' : 'transparent url(' + settings.imageBlank + ') no-repeat' });
|
||||
}).show().bind('click',function() {
|
||||
settings.activeImage = settings.activeImage - 1;
|
||||
_set_image_to_view();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show the next button, if not the last image in set
|
||||
if ( settings.activeImage != ( settings.imageArray.length -1 ) ) {
|
||||
if ( settings.fixedNavigation ) {
|
||||
$('#lightbox-nav-btnNext').css({ 'background' : 'url(' + settings.imageBtnNext + ') right 15% no-repeat' })
|
||||
.unbind()
|
||||
.bind('click',function() {
|
||||
settings.activeImage = settings.activeImage + 1;
|
||||
_set_image_to_view();
|
||||
return false;
|
||||
});
|
||||
} else {
|
||||
// Show the images button for Next buttons
|
||||
$('#lightbox-nav-btnNext').unbind().hover(function() {
|
||||
$(this).css({ 'background' : 'url(' + settings.imageBtnNext + ') right 15% no-repeat' });
|
||||
},function() {
|
||||
$(this).css({ 'background' : 'transparent url(' + settings.imageBlank + ') no-repeat' });
|
||||
}).show().bind('click',function() {
|
||||
settings.activeImage = settings.activeImage + 1;
|
||||
_set_image_to_view();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
// Enable keyboard navigation
|
||||
_enable_keyboard_navigation();
|
||||
}
|
||||
/**
|
||||
* Enable a support to keyboard navigation
|
||||
*
|
||||
*/
|
||||
function _enable_keyboard_navigation() {
|
||||
$(document).keydown(function(objEvent) {
|
||||
_keyboard_action(objEvent);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Disable the support to keyboard navigation
|
||||
*
|
||||
*/
|
||||
function _disable_keyboard_navigation() {
|
||||
$(document).unbind();
|
||||
}
|
||||
/**
|
||||
* Perform the keyboard actions
|
||||
*
|
||||
*/
|
||||
function _keyboard_action(objEvent) {
|
||||
// To ie
|
||||
if ( objEvent == null ) {
|
||||
keycode = event.keyCode;
|
||||
escapeKey = 27;
|
||||
// To Mozilla
|
||||
} else {
|
||||
keycode = objEvent.keyCode;
|
||||
escapeKey = objEvent.DOM_VK_ESCAPE;
|
||||
}
|
||||
// Get the key in lower case form
|
||||
key = String.fromCharCode(keycode).toLowerCase();
|
||||
// Verify the keys to close the ligthBox
|
||||
if ( ( key == settings.keyToClose ) || ( key == 'x' ) || ( keycode == escapeKey ) ) {
|
||||
_finish();
|
||||
}
|
||||
// Verify the key to show the previous image
|
||||
if ( ( key == settings.keyToPrev ) || ( keycode == 37 ) ) {
|
||||
// If we´re not showing the first image, call the previous
|
||||
if ( settings.activeImage != 0 ) {
|
||||
settings.activeImage = settings.activeImage - 1;
|
||||
_set_image_to_view();
|
||||
_disable_keyboard_navigation();
|
||||
}
|
||||
}
|
||||
// Verify the key to show the next image
|
||||
if ( ( key == settings.keyToNext ) || ( keycode == 39 ) ) {
|
||||
// If we´re not showing the last image, call the next
|
||||
if ( settings.activeImage != ( settings.imageArray.length - 1 ) ) {
|
||||
settings.activeImage = settings.activeImage + 1;
|
||||
_set_image_to_view();
|
||||
_disable_keyboard_navigation();
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Preload prev and next images being showed
|
||||
*
|
||||
*/
|
||||
function _preload_neighbor_images() {
|
||||
if ( (settings.imageArray.length -1) > settings.activeImage ) {
|
||||
objNext = new Image();
|
||||
objNext.src = settings.imageArray[settings.activeImage + 1][0];
|
||||
}
|
||||
if ( settings.activeImage > 0 ) {
|
||||
objPrev = new Image();
|
||||
objPrev.src = settings.imageArray[settings.activeImage -1][0];
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Remove jQuery lightBox plugin HTML markup
|
||||
*
|
||||
*/
|
||||
function _finish() {
|
||||
$('#jquery-lightbox').remove();
|
||||
$('#jquery-overlay').fadeOut(function() { $('#jquery-overlay').remove(); });
|
||||
// Show some elements to avoid conflict with overlay in IE. These elements appear above the overlay.
|
||||
$('embed, object, select').css({ 'visibility' : 'visible' });
|
||||
}
|
||||
/**
|
||||
/ THIRD FUNCTION
|
||||
* getPageSize() by quirksmode.com
|
||||
*
|
||||
* @return Array Return an array with page width, height and window width, height
|
||||
*/
|
||||
function ___getPageSize() {
|
||||
var xScroll, yScroll;
|
||||
if (window.innerHeight && window.scrollMaxY) {
|
||||
xScroll = window.innerWidth + window.scrollMaxX;
|
||||
yScroll = window.innerHeight + window.scrollMaxY;
|
||||
} else if (document.body.scrollHeight > document.body.offsetHeight){ // all but Explorer Mac
|
||||
xScroll = document.body.scrollWidth;
|
||||
yScroll = document.body.scrollHeight;
|
||||
} else { // Explorer Mac...would also work in Explorer 6 Strict, Mozilla and Safari
|
||||
xScroll = document.body.offsetWidth;
|
||||
yScroll = document.body.offsetHeight;
|
||||
}
|
||||
var windowWidth, windowHeight;
|
||||
if (self.innerHeight) { // all except Explorer
|
||||
if(document.documentElement.clientWidth){
|
||||
windowWidth = document.documentElement.clientWidth;
|
||||
} else {
|
||||
windowWidth = self.innerWidth;
|
||||
}
|
||||
windowHeight = self.innerHeight;
|
||||
} else if (document.documentElement && document.documentElement.clientHeight) { // Explorer 6 Strict Mode
|
||||
windowWidth = document.documentElement.clientWidth;
|
||||
windowHeight = document.documentElement.clientHeight;
|
||||
} else if (document.body) { // other Explorers
|
||||
windowWidth = document.body.clientWidth;
|
||||
windowHeight = document.body.clientHeight;
|
||||
}
|
||||
// for small pages with total height less then height of the viewport
|
||||
if(yScroll < windowHeight){
|
||||
pageHeight = windowHeight;
|
||||
} else {
|
||||
pageHeight = yScroll;
|
||||
}
|
||||
// for small pages with total width less then width of the viewport
|
||||
if(xScroll < windowWidth){
|
||||
pageWidth = xScroll;
|
||||
} else {
|
||||
pageWidth = windowWidth;
|
||||
}
|
||||
arrayPageSize = new Array(pageWidth,pageHeight,windowWidth,windowHeight);
|
||||
return arrayPageSize;
|
||||
};
|
||||
/**
|
||||
/ THIRD FUNCTION
|
||||
* getPageScroll() by quirksmode.com
|
||||
*
|
||||
* @return Array Return an array with x,y page scroll values.
|
||||
*/
|
||||
function ___getPageScroll() {
|
||||
var xScroll, yScroll;
|
||||
if (self.pageYOffset) {
|
||||
yScroll = self.pageYOffset;
|
||||
xScroll = self.pageXOffset;
|
||||
} else if (document.documentElement && document.documentElement.scrollTop) { // Explorer 6 Strict
|
||||
yScroll = document.documentElement.scrollTop;
|
||||
xScroll = document.documentElement.scrollLeft;
|
||||
} else if (document.body) {// all other Explorers
|
||||
yScroll = document.body.scrollTop;
|
||||
xScroll = document.body.scrollLeft;
|
||||
}
|
||||
arrayPageScroll = new Array(xScroll,yScroll);
|
||||
return arrayPageScroll;
|
||||
};
|
||||
/**
|
||||
* Stop the code execution from a escified time in milisecond
|
||||
*
|
||||
*/
|
||||
function ___pause(ms) {
|
||||
var date = new Date();
|
||||
curDate = null;
|
||||
do { var curDate = new Date(); }
|
||||
while ( curDate - date < ms);
|
||||
};
|
||||
// Return the jQuery object for chaining. The unbind method is used to avoid click conflict when the plugin is called more than once
|
||||
return this.unbind('click').click(_initialize);
|
||||
};
|
||||
})(jQuery); // Call and execute the function immediately passing the jQuery object
|
||||
106
public/js/jquery.placeholder.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Placeholder plugin for jQuery
|
||||
* ---
|
||||
* Copyright 2010, Daniel Stocks (http://webcloud.se)
|
||||
* Released under the MIT, BSD, and GPL Licenses.
|
||||
*/
|
||||
(function($) {
|
||||
function Placeholder(input) {
|
||||
this.input = input;
|
||||
if (input.attr('type') == 'password') {
|
||||
this.handlePassword();
|
||||
}
|
||||
// Prevent placeholder values from submitting
|
||||
$(input[0].form).submit(function() {
|
||||
if (input.hasClass('placeholder') && input[0].value == input.attr('placeholder')) {
|
||||
input[0].value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
Placeholder.prototype = {
|
||||
show : function(loading) {
|
||||
// FF and IE saves values when you refresh the page. If the user refreshes the page with
|
||||
// the placeholders showing they will be the default values and the input fields won't be empty.
|
||||
if (this.input[0].value === '' || (loading && this.valueIsPlaceholder())) {
|
||||
if (this.isPassword) {
|
||||
try {
|
||||
this.input[0].setAttribute('type', 'text');
|
||||
} catch (e) {
|
||||
this.input.before(this.fakePassword.show()).hide();
|
||||
}
|
||||
}
|
||||
this.input.addClass('placeholder');
|
||||
this.input[0].value = this.input.attr('placeholder');
|
||||
}
|
||||
},
|
||||
hide : function() {
|
||||
if (this.valueIsPlaceholder() && this.input.hasClass('placeholder')) {
|
||||
this.input.removeClass('placeholder');
|
||||
this.input[0].value = '';
|
||||
if (this.isPassword) {
|
||||
try {
|
||||
this.input[0].setAttribute('type', 'password');
|
||||
} catch (e) { }
|
||||
// Restore focus for Opera and IE
|
||||
this.input.show();
|
||||
this.input[0].focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
valueIsPlaceholder : function() {
|
||||
return this.input[0].value == this.input.attr('placeholder');
|
||||
},
|
||||
handlePassword: function() {
|
||||
var input = this.input;
|
||||
input.attr('realType', 'password');
|
||||
this.isPassword = true;
|
||||
// IE < 9 doesn't allow changing the type of password inputs
|
||||
if ($.browser.msie && input[0].outerHTML) {
|
||||
var fakeHTML = $(input[0].outerHTML.replace(/type=(['"])?password\1/gi, 'type=$1text$1'));
|
||||
this.fakePassword = fakeHTML.val(input.attr('placeholder')).addClass('placeholder').focus(function() {
|
||||
input.trigger('focus');
|
||||
$(this).hide();
|
||||
});
|
||||
$(input[0].form).submit(function() {
|
||||
fakeHTML.remove();
|
||||
input.show()
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
var NATIVE_SUPPORT = !!("placeholder" in document.createElement( "input" ));
|
||||
$.fn.placeholder = function() {
|
||||
return NATIVE_SUPPORT ? this : this.each(function() {
|
||||
var input = $(this);
|
||||
var placeholder = new Placeholder(input);
|
||||
placeholder.show(true);
|
||||
input.focus(function() {
|
||||
placeholder.hide();
|
||||
});
|
||||
input.blur(function() {
|
||||
placeholder.show(false);
|
||||
});
|
||||
|
||||
// On page refresh, IE doesn't re-populate user input
|
||||
// until the window.onload event is fired.
|
||||
if ($.browser.msie) {
|
||||
$(window).load(function() {
|
||||
if(input.val()) {
|
||||
input.removeClass("placeholder");
|
||||
}
|
||||
placeholder.show(true);
|
||||
});
|
||||
// What's even worse, the text cursor disappears
|
||||
// when tabbing between text inputs, here's a fix
|
||||
input.focus(function() {
|
||||
if(this.value == "") {
|
||||
var range = this.createTextRange();
|
||||
range.collapse(true);
|
||||
range.moveStart('character', 0);
|
||||
range.select();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
})(jQuery);
|
||||
296
public/js/jquery.uploadify.v2.1.4.js
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
/*
|
||||
Uploadify v2.1.4
|
||||
Release Date: November 8, 2010
|
||||
|
||||
Copyright (c) 2010 Ronnie Garcia, Travis Nickels
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
if(jQuery)(
|
||||
function(jQuery){
|
||||
jQuery.extend(jQuery.fn,{
|
||||
uploadify:function(options) {
|
||||
jQuery(this).each(function(){
|
||||
var settings = jQuery.extend({
|
||||
id : jQuery(this).attr('id'), // The ID of the object being Uploadified
|
||||
uploader : 'uploadify.swf', // The path to the uploadify swf file
|
||||
script : 'uploadify.php', // The path to the uploadify backend upload script
|
||||
expressInstall : null, // The path to the express install swf file
|
||||
folder : '', // The path to the upload folder
|
||||
height : 30, // The height of the flash button
|
||||
width : 120, // The width of the flash button
|
||||
cancelImg : 'cancel.png', // The path to the cancel image for the default file queue item container
|
||||
wmode : 'opaque', // The wmode of the flash file
|
||||
scriptAccess : 'sameDomain', // Set to "always" to allow script access across domains
|
||||
fileDataName : 'Filedata', // The name of the file collection object in the backend upload script
|
||||
method : 'POST', // The method for sending variables to the backend upload script
|
||||
queueSizeLimit : 999, // The maximum size of the file queue
|
||||
simUploadLimit : 1, // The number of simultaneous uploads allowed
|
||||
queueID : false, // The optional ID of the queue container
|
||||
displayData : 'percentage', // Set to "speed" to show the upload speed in the default queue item
|
||||
removeCompleted : true, // Set to true if you want the queue items to be removed when a file is done uploading
|
||||
onInit : function() {}, // Function to run when uploadify is initialized
|
||||
onSelect : function() {}, // Function to run when a file is selected
|
||||
onSelectOnce : function() {}, // Function to run once when files are added to the queue
|
||||
onQueueFull : function() {}, // Function to run when the queue reaches capacity
|
||||
onCheck : function() {}, // Function to run when script checks for duplicate files on the server
|
||||
onCancel : function() {}, // Function to run when an item is cleared from the queue
|
||||
onClearQueue : function() {}, // Function to run when the queue is manually cleared
|
||||
onError : function() {}, // Function to run when an upload item returns an error
|
||||
onProgress : function() {}, // Function to run each time the upload progress is updated
|
||||
onComplete : function() {}, // Function to run when an upload is completed
|
||||
onAllComplete : function() {} // Function to run when all uploads are completed
|
||||
}, options);
|
||||
jQuery(this).data('settings',settings);
|
||||
var pagePath = location.pathname;
|
||||
pagePath = pagePath.split('/');
|
||||
pagePath.pop();
|
||||
pagePath = pagePath.join('/') + '/';
|
||||
var data = {};
|
||||
data.uploadifyID = settings.id;
|
||||
data.pagepath = pagePath;
|
||||
if (settings.buttonImg) data.buttonImg = escape(settings.buttonImg);
|
||||
if (settings.buttonText) data.buttonText = escape(settings.buttonText);
|
||||
if (settings.rollover) data.rollover = true;
|
||||
data.script = settings.script;
|
||||
data.folder = escape(settings.folder);
|
||||
if (settings.scriptData) {
|
||||
var scriptDataString = '';
|
||||
for (var name in settings.scriptData) {
|
||||
scriptDataString += '&' + name + '=' + settings.scriptData[name];
|
||||
}
|
||||
data.scriptData = escape(scriptDataString.substr(1));
|
||||
}
|
||||
data.width = settings.width;
|
||||
data.height = settings.height;
|
||||
data.wmode = settings.wmode;
|
||||
data.method = settings.method;
|
||||
data.queueSizeLimit = settings.queueSizeLimit;
|
||||
data.simUploadLimit = settings.simUploadLimit;
|
||||
if (settings.hideButton) data.hideButton = true;
|
||||
if (settings.fileDesc) data.fileDesc = settings.fileDesc;
|
||||
if (settings.fileExt) data.fileExt = settings.fileExt;
|
||||
if (settings.multi) data.multi = true;
|
||||
if (settings.auto) data.auto = true;
|
||||
if (settings.sizeLimit) data.sizeLimit = settings.sizeLimit;
|
||||
if (settings.checkScript) data.checkScript = settings.checkScript;
|
||||
if (settings.fileDataName) data.fileDataName = settings.fileDataName;
|
||||
if (settings.queueID) data.queueID = settings.queueID;
|
||||
if (settings.onInit() !== false) {
|
||||
jQuery(this).css('display','none');
|
||||
jQuery(this).after('<div id="' + jQuery(this).attr('id') + 'Uploader"></div>');
|
||||
swfobject.embedSWF(settings.uploader, settings.id + 'Uploader', settings.width, settings.height, '9.0.24', settings.expressInstall, data, {'quality':'high','wmode':settings.wmode,'allowScriptAccess':settings.scriptAccess},{},function(event) {
|
||||
if (typeof(settings.onSWFReady) == 'function' && event.success) settings.onSWFReady();
|
||||
});
|
||||
if (settings.queueID == false) {
|
||||
jQuery("#" + jQuery(this).attr('id') + "Uploader").after('<div id="' + jQuery(this).attr('id') + 'Queue" class="uploadifyQueue"></div>');
|
||||
} else {
|
||||
jQuery("#" + settings.queueID).addClass('uploadifyQueue');
|
||||
}
|
||||
}
|
||||
if (typeof(settings.onOpen) == 'function') {
|
||||
jQuery(this).bind("uploadifyOpen", settings.onOpen);
|
||||
}
|
||||
jQuery(this).bind("uploadifySelect", {'action': settings.onSelect, 'queueID': settings.queueID}, function(event, ID, fileObj) {
|
||||
if (event.data.action(event, ID, fileObj) !== false) {
|
||||
var byteSize = Math.round(fileObj.size / 1024 * 100) * .01;
|
||||
var suffix = 'KB';
|
||||
if (byteSize > 1000) {
|
||||
byteSize = Math.round(byteSize *.001 * 100) * .01;
|
||||
suffix = 'MB';
|
||||
}
|
||||
var sizeParts = byteSize.toString().split('.');
|
||||
if (sizeParts.length > 1) {
|
||||
byteSize = sizeParts[0] + '.' + sizeParts[1].substr(0,2);
|
||||
} else {
|
||||
byteSize = sizeParts[0];
|
||||
}
|
||||
if (fileObj.name.length > 20) {
|
||||
fileName = fileObj.name.substr(0,20) + '...';
|
||||
} else {
|
||||
fileName = fileObj.name;
|
||||
}
|
||||
queue = '#' + jQuery(this).attr('id') + 'Queue';
|
||||
if (event.data.queueID) {
|
||||
queue = '#' + event.data.queueID;
|
||||
}
|
||||
jQuery(queue).append('<div id="' + jQuery(this).attr('id') + ID + '" class="uploadifyQueueItem">\
|
||||
<div class="cancel">\
|
||||
<a href="javascript:jQuery(\'#' + jQuery(this).attr('id') + '\').uploadifyCancel(\'' + ID + '\')"><img src="' + settings.cancelImg + '" border="0" /></a>\
|
||||
</div>\
|
||||
<span class="fileName">' + fileName + ' (' + byteSize + suffix + ')</span><span class="percentage"></span>\
|
||||
<div class="uploadifyProgress">\
|
||||
<div id="' + jQuery(this).attr('id') + ID + 'ProgressBar" class="uploadifyProgressBar"><!--Progress Bar--></div>\
|
||||
</div>\
|
||||
</div>');
|
||||
}
|
||||
});
|
||||
jQuery(this).bind("uploadifySelectOnce", {'action': settings.onSelectOnce}, function(event, data) {
|
||||
event.data.action(event, data);
|
||||
if (settings.auto) {
|
||||
if (settings.checkScript) {
|
||||
jQuery(this).uploadifyUpload(null, false);
|
||||
} else {
|
||||
jQuery(this).uploadifyUpload(null, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
jQuery(this).bind("uploadifyQueueFull", {'action': settings.onQueueFull}, function(event, queueSizeLimit) {
|
||||
if (event.data.action(event, queueSizeLimit) !== false) {
|
||||
alert('The queue is full. The max size is ' + queueSizeLimit + '.');
|
||||
}
|
||||
});
|
||||
jQuery(this).bind("uploadifyCheckExist", {'action': settings.onCheck}, function(event, checkScript, fileQueueObj, folder, single) {
|
||||
var postData = new Object();
|
||||
postData = fileQueueObj;
|
||||
postData.folder = (folder.substr(0,1) == '/') ? folder : pagePath + folder;
|
||||
if (single) {
|
||||
for (var ID in fileQueueObj) {
|
||||
var singleFileID = ID;
|
||||
}
|
||||
}
|
||||
jQuery.post(checkScript, postData, function(data) {
|
||||
for(var key in data) {
|
||||
if (event.data.action(event, data, key) !== false) {
|
||||
var replaceFile = confirm("Do you want to replace the file " + data[key] + "?");
|
||||
if (!replaceFile) {
|
||||
document.getElementById(jQuery(event.target).attr('id') + 'Uploader').cancelFileUpload(key,true,true);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (single) {
|
||||
document.getElementById(jQuery(event.target).attr('id') + 'Uploader').startFileUpload(singleFileID, true);
|
||||
} else {
|
||||
document.getElementById(jQuery(event.target).attr('id') + 'Uploader').startFileUpload(null, true);
|
||||
}
|
||||
}, "json");
|
||||
});
|
||||
jQuery(this).bind("uploadifyCancel", {'action': settings.onCancel}, function(event, ID, fileObj, data, remove, clearFast) {
|
||||
if (event.data.action(event, ID, fileObj, data, clearFast) !== false) {
|
||||
if (remove) {
|
||||
var fadeSpeed = (clearFast == true) ? 0 : 250;
|
||||
jQuery("#" + jQuery(this).attr('id') + ID).fadeOut(fadeSpeed, function() { jQuery(this).remove() });
|
||||
}
|
||||
}
|
||||
});
|
||||
jQuery(this).bind("uploadifyClearQueue", {'action': settings.onClearQueue}, function(event, clearFast) {
|
||||
var queueID = (settings.queueID) ? settings.queueID : jQuery(this).attr('id') + 'Queue';
|
||||
if (clearFast) {
|
||||
jQuery("#" + queueID).find('.uploadifyQueueItem').remove();
|
||||
}
|
||||
if (event.data.action(event, clearFast) !== false) {
|
||||
jQuery("#" + queueID).find('.uploadifyQueueItem').each(function() {
|
||||
var index = jQuery('.uploadifyQueueItem').index(this);
|
||||
jQuery(this).delay(index * 100).fadeOut(250, function() { jQuery(this).remove() });
|
||||
});
|
||||
}
|
||||
});
|
||||
var errorArray = [];
|
||||
jQuery(this).bind("uploadifyError", {'action': settings.onError}, function(event, ID, fileObj, errorObj) {
|
||||
if (event.data.action(event, ID, fileObj, errorObj) !== false) {
|
||||
var fileArray = new Array(ID, fileObj, errorObj);
|
||||
errorArray.push(fileArray);
|
||||
jQuery("#" + jQuery(this).attr('id') + ID).find('.percentage').text(" - " + errorObj.type + " Error");
|
||||
jQuery("#" + jQuery(this).attr('id') + ID).find('.uploadifyProgress').hide();
|
||||
jQuery("#" + jQuery(this).attr('id') + ID).addClass('uploadifyError');
|
||||
}
|
||||
});
|
||||
if (typeof(settings.onUpload) == 'function') {
|
||||
jQuery(this).bind("uploadifyUpload", settings.onUpload);
|
||||
}
|
||||
jQuery(this).bind("uploadifyProgress", {'action': settings.onProgress, 'toDisplay': settings.displayData}, function(event, ID, fileObj, data) {
|
||||
if (event.data.action(event, ID, fileObj, data) !== false) {
|
||||
jQuery("#" + jQuery(this).attr('id') + ID + "ProgressBar").animate({'width': data.percentage + '%'},250,function() {
|
||||
if (data.percentage == 100) {
|
||||
jQuery(this).closest('.uploadifyProgress').fadeOut(250,function() {jQuery(this).remove()});
|
||||
}
|
||||
});
|
||||
if (event.data.toDisplay == 'percentage') displayData = ' - ' + data.percentage + '%';
|
||||
if (event.data.toDisplay == 'speed') displayData = ' - ' + data.speed + 'KB/s';
|
||||
if (event.data.toDisplay == null) displayData = ' ';
|
||||
jQuery("#" + jQuery(this).attr('id') + ID).find('.percentage').text(displayData);
|
||||
}
|
||||
});
|
||||
jQuery(this).bind("uploadifyComplete", {'action': settings.onComplete}, function(event, ID, fileObj, response, data) {
|
||||
if (event.data.action(event, ID, fileObj, unescape(response), data) !== false) {
|
||||
jQuery("#" + jQuery(this).attr('id') + ID).find('.percentage').text(' - Completed');
|
||||
if (settings.removeCompleted) {
|
||||
jQuery("#" + jQuery(event.target).attr('id') + ID).fadeOut(250,function() {jQuery(this).remove()});
|
||||
}
|
||||
jQuery("#" + jQuery(event.target).attr('id') + ID).addClass('completed');
|
||||
}
|
||||
});
|
||||
if (typeof(settings.onAllComplete) == 'function') {
|
||||
jQuery(this).bind("uploadifyAllComplete", {'action': settings.onAllComplete}, function(event, data) {
|
||||
if (event.data.action(event, data) !== false) {
|
||||
errorArray = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
uploadifySettings:function(settingName, settingValue, resetObject) {
|
||||
var returnValue = false;
|
||||
jQuery(this).each(function() {
|
||||
if (settingName == 'scriptData' && settingValue != null) {
|
||||
if (resetObject) {
|
||||
var scriptData = settingValue;
|
||||
} else {
|
||||
var scriptData = jQuery.extend(jQuery(this).data('settings').scriptData, settingValue);
|
||||
}
|
||||
var scriptDataString = '';
|
||||
for (var name in scriptData) {
|
||||
scriptDataString += '&' + name + '=' + scriptData[name];
|
||||
}
|
||||
settingValue = escape(scriptDataString.substr(1));
|
||||
}
|
||||
returnValue = document.getElementById(jQuery(this).attr('id') + 'Uploader').updateSettings(settingName, settingValue);
|
||||
});
|
||||
if (settingValue == null) {
|
||||
if (settingName == 'scriptData') {
|
||||
var returnSplit = unescape(returnValue).split('&');
|
||||
var returnObj = new Object();
|
||||
for (var i = 0; i < returnSplit.length; i++) {
|
||||
var iSplit = returnSplit[i].split('=');
|
||||
returnObj[iSplit[0]] = iSplit[1];
|
||||
}
|
||||
returnValue = returnObj;
|
||||
}
|
||||
}
|
||||
return returnValue;
|
||||
},
|
||||
uploadifyUpload:function(ID,checkComplete) {
|
||||
jQuery(this).each(function() {
|
||||
if (!checkComplete) checkComplete = false;
|
||||
document.getElementById(jQuery(this).attr('id') + 'Uploader').startFileUpload(ID, checkComplete);
|
||||
});
|
||||
},
|
||||
uploadifyCancel:function(ID) {
|
||||
jQuery(this).each(function() {
|
||||
document.getElementById(jQuery(this).attr('id') + 'Uploader').cancelFileUpload(ID, true, true, false);
|
||||
});
|
||||
},
|
||||
uploadifyClearQueue:function() {
|
||||
jQuery(this).each(function() {
|
||||
document.getElementById(jQuery(this).attr('id') + 'Uploader').clearFileUploadQueue(false);
|
||||
});
|
||||
}
|
||||
})
|
||||
})(jQuery);
|
||||
5
public/js/projects.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
$(function() {
|
||||
|
||||
// this space intentionally left blank
|
||||
|
||||
})
|
||||
40
public/js/reset-password.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
$(function() {
|
||||
|
||||
$('#password').focus()
|
||||
|
||||
$('#reset-password-form').submit(function() {
|
||||
var passwordField = $('#password')
|
||||
, confirmationField = $('#password-confirmation')
|
||||
, valid = true
|
||||
, focused = false
|
||||
|
||||
if ($.trim(passwordField.val()).length === 0) {
|
||||
passwordField.addClass('error').val('').focus()
|
||||
focused = true
|
||||
valid = false
|
||||
}
|
||||
else {
|
||||
passwordField.removeClass('error')
|
||||
}
|
||||
|
||||
if (passwordField.val() !== confirmationField.val()) {
|
||||
confirmationField.addClass('error').val('')
|
||||
valid = false
|
||||
if (!focused) {
|
||||
confirmationField.focus()
|
||||
focused = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
confirmationField.removeClass('error')
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
$('#reset-password-button').hide()
|
||||
$('#reset-password-spinner').show()
|
||||
}
|
||||
|
||||
return valid
|
||||
})
|
||||
|
||||
})
|
||||
40
public/js/sign-in.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
$(function() {
|
||||
|
||||
$('input[name="email"]').focus()
|
||||
|
||||
$('#forgot-password-link').click(function() {
|
||||
window.location.href = '/forgot-password/' + $('#email').val()
|
||||
return false
|
||||
})
|
||||
|
||||
$('#sign-in-form').submit(function() {
|
||||
var emailField = $('input[name="email"]')
|
||||
, passwordField = $('input[name="password"]')
|
||||
, valid = true
|
||||
, focused = false
|
||||
|
||||
if ($.trim(emailField.val()).length === 0) {
|
||||
emailField.addClass('error').val('').focus()
|
||||
focused = true
|
||||
valid = false
|
||||
}
|
||||
if ($.trim(passwordField.val()).length === 0) {
|
||||
passwordField.addClass('error').val('')
|
||||
valid = false
|
||||
if (!focused) {
|
||||
passwordField.focus()
|
||||
focused = true
|
||||
}
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
emailField.removeClass('error')
|
||||
passwordField.removeClass('error')
|
||||
$('#sign-in-button').hide()
|
||||
$('#sign-in-spinner').show()
|
||||
}
|
||||
|
||||
return valid
|
||||
})
|
||||
|
||||
})
|
||||
59
public/js/sign-up.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
$(function() {
|
||||
|
||||
if (window.SI.errors) {
|
||||
for (var name in window.SI.errors) {
|
||||
$('input[name="' + name + '"]').addClass('error')
|
||||
}
|
||||
}
|
||||
|
||||
var Validators = {
|
||||
email: window.SI.EmailIsValid
|
||||
, password_confirmation: function(v) { return v === $('input[name="password"]').val() }
|
||||
}
|
||||
|
||||
// TODO validate password confirmation on each keypress
|
||||
|
||||
$('#sign-up-form').submit(function() {
|
||||
var valid = true
|
||||
, focused = false
|
||||
|
||||
// Presence check
|
||||
$.each(['first_name', 'last_name', 'email', 'password', 'password_confirmation'], function(i, name) {
|
||||
var field = $('input[name="' + name + '"]')
|
||||
, value = $.trim(field.val())
|
||||
, validator = Validators[name]
|
||||
if (value.length === 0 || (validator && !validator(value))) {
|
||||
field.addClass('error')
|
||||
if (!focused) {
|
||||
focused = true
|
||||
field.focus().select()
|
||||
}
|
||||
valid = false
|
||||
}
|
||||
else {
|
||||
field.removeClass('error')
|
||||
}
|
||||
})
|
||||
|
||||
if (!$('input[name="terms"]').attr('checked')) {
|
||||
valid = false
|
||||
$('#terms-cell').addClass('error')
|
||||
}
|
||||
else {
|
||||
$('#terms-cell').removeClass('error')
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
$('#sign-up-button').hide()
|
||||
$('#sign-up-spinner').show()
|
||||
}
|
||||
|
||||
return valid
|
||||
})
|
||||
|
||||
$('#sign-in-button').click(function() {
|
||||
$(this).hide()
|
||||
$('#sign-in-spinner').show()
|
||||
})
|
||||
|
||||
})
|
||||
4
public/js/swfobject.js
Normal file
2
public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
29
public/sitemap.xml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset
|
||||
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<!-- created with Free Online Sitemap Generator www.xml-sitemaps.com -->
|
||||
|
||||
<url>
|
||||
<loc>http://example.com/</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://example.com/sign-up</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://example.com/sign-in</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://example.com/contact</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://example.com/terms</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
</url>
|
||||
</urlset>
|
||||
BIN
public/uploadify/cancel.png
Executable file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/uploadify/uploadify.swf
Executable file
289
test/common.rb
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'rubygems'
|
||||
require 'bundler/setup'
|
||||
require 'json'
|
||||
gem 'minitest'
|
||||
require 'minitest/unit'
|
||||
require 'rack/test'
|
||||
require 'redis'
|
||||
require 'mock-pony'
|
||||
require 'mock-renderer'
|
||||
|
||||
require 'simplecov'
|
||||
SimpleCov.start
|
||||
|
||||
ENV['RACK_ENV'] = 'test'
|
||||
|
||||
module Stormy
|
||||
KeyPrefix = 'TEST:' unless const_defined?(:KeyPrefix)
|
||||
end
|
||||
|
||||
require 'stormy'
|
||||
require 'stormy/server'
|
||||
|
||||
module Stormy
|
||||
module Test
|
||||
|
||||
class Unit < MiniTest::Unit
|
||||
|
||||
def before_suites
|
||||
end
|
||||
|
||||
def after_suites
|
||||
# nuke test data
|
||||
redis = Redis.new
|
||||
redis.keys(Stormy.key('*')).each do |key|
|
||||
redis.del key
|
||||
end
|
||||
if Pony.sent_mail.length > 0
|
||||
puts "\nLeftover mail: #{Pony.sent_mail.length}"
|
||||
Pony.sent_mail.each do |m|
|
||||
puts
|
||||
puts "To: #{m[:to]}"
|
||||
puts "From: #{m[:from]}"
|
||||
puts "Subject: #{m[:subject]}"
|
||||
puts "Content type: #{m[:content_type]}"
|
||||
puts m[:body]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def _run_suites(suites, type)
|
||||
begin
|
||||
before_suites
|
||||
super(suites, type)
|
||||
ensure
|
||||
after_suites
|
||||
end
|
||||
end
|
||||
|
||||
def _run_suite(suite, type)
|
||||
begin
|
||||
suite.before_suite if suite.respond_to?(:before_suite)
|
||||
super(suite, type)
|
||||
ensure
|
||||
suite.after_suite if suite.respond_to?(:after_suite)
|
||||
end
|
||||
end
|
||||
|
||||
end # Unit
|
||||
|
||||
|
||||
#############
|
||||
### Cases ###
|
||||
#############
|
||||
|
||||
class Case < Unit::TestCase
|
||||
|
||||
include Stormy::Models
|
||||
|
||||
def redis
|
||||
@redis ||= Redis.new
|
||||
end
|
||||
|
||||
def fixtures(name)
|
||||
@_fixtures ||= {}
|
||||
@_fixtures[name] ||= JSON.parse(File.read(File.join(this_dir, "fixtures", "#{name}.json")))
|
||||
end
|
||||
|
||||
def photo_file(filename)
|
||||
@_photos ||= {}
|
||||
@_photos[filename] ||= File.expand_path(File.join(this_dir, "photos", filename))
|
||||
end
|
||||
|
||||
def video_file(filename)
|
||||
@_videos ||= {}
|
||||
@_videos[filename] ||= File.expand_path(File.join(this_dir, "videos", filename))
|
||||
end
|
||||
|
||||
def erb(template, options = {}, locals = {})
|
||||
@renderer ||= MockRenderer.new
|
||||
@renderer.erb(template, options, locals)
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def this_dir
|
||||
@_this_dir ||= File.dirname(__FILE__)
|
||||
end
|
||||
|
||||
end # Case
|
||||
|
||||
|
||||
class HelperCase < Case
|
||||
|
||||
include Stormy::Helpers::Utils
|
||||
|
||||
end # HelperCase
|
||||
|
||||
|
||||
class ControllerCase < Case
|
||||
|
||||
include Rack::Test::Methods
|
||||
|
||||
def app
|
||||
Stormy::Server
|
||||
end
|
||||
|
||||
def session
|
||||
last_request.env['rack.session']
|
||||
end
|
||||
|
||||
def flash
|
||||
session['flash']
|
||||
end
|
||||
|
||||
def config
|
||||
@config ||= Stormy::Config.instance
|
||||
end
|
||||
|
||||
def assert_response_json_equal(data)
|
||||
assert_equal data, JSON.parse(last_response.body)
|
||||
end
|
||||
|
||||
def assert_response_json_ok(data = nil)
|
||||
assert_ok
|
||||
data = if data
|
||||
{ 'status' => 'ok', 'data' => data }
|
||||
else
|
||||
{ 'status' => 'ok' }
|
||||
end
|
||||
assert_response_json_equal data
|
||||
end
|
||||
|
||||
def assert_response_json_fail(reason = nil)
|
||||
assert_ok
|
||||
data = if reason
|
||||
{ 'status' => 'fail', 'reason' => reason }
|
||||
else
|
||||
{ 'status' => 'fail' }
|
||||
end
|
||||
assert_response_json_equal data
|
||||
end
|
||||
|
||||
def assert_ok
|
||||
puts last_response.body unless last_response.ok?
|
||||
assert last_response.ok?, "expected ok response 2xx, got #{last_response.status}"
|
||||
end
|
||||
|
||||
def assert_bad_request
|
||||
assert_equal 400, last_response.status
|
||||
end
|
||||
|
||||
def assert_not_authorized
|
||||
assert_equal 403, last_response.status
|
||||
end
|
||||
|
||||
def assert_not_found
|
||||
assert_equal 404, last_response.status
|
||||
end
|
||||
|
||||
def assert_redirected(path = nil)
|
||||
assert_equal 302, last_response.status, "expected 302 redirect, got #{last_response.status} (#{last_response.body})"
|
||||
if path
|
||||
url = if path.starts_with?('http')
|
||||
path
|
||||
else
|
||||
"http://example.org#{path}"
|
||||
end
|
||||
assert_equal url, last_response.headers['Location']
|
||||
end
|
||||
end
|
||||
|
||||
end # ControllerCase
|
||||
|
||||
|
||||
###############
|
||||
### Helpers ###
|
||||
###############
|
||||
|
||||
module Helpers
|
||||
|
||||
module Accounts
|
||||
|
||||
include Stormy::Models
|
||||
|
||||
def accounts
|
||||
@accounts ||= fixtures('accounts')
|
||||
end
|
||||
|
||||
def setup_accounts
|
||||
@existing_account_data = accounts['sami']
|
||||
@existing_account = Account.create(@existing_account_data)
|
||||
|
||||
@account_data = accounts['freddy']
|
||||
end
|
||||
|
||||
def teardown_accounts
|
||||
if @signed_in
|
||||
@signed_in = false
|
||||
sign_out
|
||||
end
|
||||
Account.list_ids.each do |id|
|
||||
Account.delete!(id)
|
||||
end
|
||||
end
|
||||
|
||||
def sign_in(account_data = @existing_account_data, options = {})
|
||||
post '/sign-in', account_data.merge(options)
|
||||
@signed_in = true
|
||||
end
|
||||
|
||||
def sign_out
|
||||
post '/sign-out'
|
||||
end
|
||||
|
||||
end # Accounts
|
||||
|
||||
|
||||
module Admins
|
||||
|
||||
include Stormy::Models
|
||||
|
||||
def setup_admins
|
||||
@admin_data ||= fixtures('admins')['sami']
|
||||
@admin = Admin.create(@admin_data)
|
||||
end
|
||||
|
||||
def teardown_admins
|
||||
@admin.delete!
|
||||
end
|
||||
|
||||
end # Admins
|
||||
|
||||
module Projects
|
||||
|
||||
include Stormy::Models
|
||||
|
||||
def projects
|
||||
@projects ||= fixtures('projects')
|
||||
end
|
||||
|
||||
def setup_projects(options = {})
|
||||
account = options[:owner] || @existing_account
|
||||
@existing_project_data = projects['stormy']
|
||||
if account
|
||||
@existing_project_data['account_id'] = account.id
|
||||
end
|
||||
@existing_project = Project.create(@existing_project_data)
|
||||
|
||||
@new_project_data = projects['dating-free']
|
||||
end
|
||||
|
||||
def teardown_projects
|
||||
Project.list_ids.each do |id|
|
||||
Project.delete!(id)
|
||||
end
|
||||
end
|
||||
|
||||
end # Projects
|
||||
|
||||
end # Helpers
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
MiniTest::Unit.runner = Stormy::Test::Unit.new
|
||||
MiniTest::Unit.autorun
|
||||
283
test/controllers/test-accounts_controller.rb
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
#!/usr/bin/env ruby
|
||||
#
|
||||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'common'
|
||||
|
||||
class AccountsControllerTest < Stormy::Test::ControllerCase
|
||||
|
||||
include Stormy::Test::Helpers::Accounts
|
||||
include Stormy::Test::Helpers::Projects
|
||||
|
||||
def setup
|
||||
setup_accounts
|
||||
end
|
||||
|
||||
def teardown
|
||||
teardown_accounts
|
||||
end
|
||||
|
||||
|
||||
###############
|
||||
### Sign Up ###
|
||||
###############
|
||||
|
||||
def test_sign_up
|
||||
# populate session['source'] and session['source_info']
|
||||
get '/promo'
|
||||
|
||||
get '/sign-up', {}
|
||||
assert_ok
|
||||
end
|
||||
|
||||
def test_sign_up_with_valid_data
|
||||
post '/sign-up', @account_data
|
||||
assert_redirected '/projects'
|
||||
assert_equal 1, Pony.sent_mail.length
|
||||
assert mail = Pony.sent_mail.shift
|
||||
end
|
||||
|
||||
def test_sign_up_with_missing_fields
|
||||
account_data = @account_data.dup
|
||||
|
||||
# first name
|
||||
account_data['first_name'] = nil
|
||||
post '/sign-up', account_data
|
||||
assert_redirected '/sign-up'
|
||||
|
||||
# last name
|
||||
account_data['last_name'] = nil
|
||||
post '/sign-up', account_data
|
||||
assert_redirected '/sign-up'
|
||||
|
||||
# password
|
||||
account_data['password'] = nil
|
||||
post '/sign-up', account_data
|
||||
assert_redirected '/sign-up'
|
||||
end
|
||||
|
||||
def test_sign_up_with_invalid_data
|
||||
account_data = @account_data.dup
|
||||
account_data['email'] = 'not an email address'
|
||||
post '/sign-up', account_data
|
||||
assert_redirected '/sign-up'
|
||||
end
|
||||
|
||||
def test_sign_up_with_existing_email
|
||||
post '/sign-up', @existing_account_data
|
||||
assert_redirected '/sign-up'
|
||||
end
|
||||
|
||||
|
||||
#####################
|
||||
### Authorization ###
|
||||
#####################
|
||||
|
||||
def test_sign_in
|
||||
get '/sign-in'
|
||||
assert_ok
|
||||
end
|
||||
|
||||
def test_sign_in_submit
|
||||
sign_in
|
||||
assert_redirected '/projects'
|
||||
end
|
||||
|
||||
def test_sign_in_remember
|
||||
sign_in(@existing_account_data, 'remember' => 'on')
|
||||
assert_redirected '/projects'
|
||||
|
||||
post '/sign-out'
|
||||
assert_redirected '/'
|
||||
|
||||
get '/projects'
|
||||
assert_ok
|
||||
|
||||
# deletes remembered cookie
|
||||
sign_in
|
||||
end
|
||||
|
||||
def test_sign_in_with_invalid_credentials
|
||||
sign_in(@account_data)
|
||||
assert_redirected '/sign-in'
|
||||
end
|
||||
|
||||
def test_sign_in_redirect
|
||||
# authorized page redirects to sign-in
|
||||
get '/account'
|
||||
assert_redirected '/sign-in'
|
||||
|
||||
# redirects to original URL after signing in
|
||||
sign_in
|
||||
assert_redirected '/account'
|
||||
end
|
||||
|
||||
def test_sign_out
|
||||
post '/sign-out'
|
||||
assert_redirected '/'
|
||||
end
|
||||
|
||||
def test_forgot_password
|
||||
get '/forgot-password'
|
||||
assert_ok
|
||||
end
|
||||
|
||||
def test_forgot_password_existing_email
|
||||
post '/forgot-password', { :email => @existing_account.email }
|
||||
assert_redirected '/sign-in'
|
||||
assert Account.fetch(@existing_account.id).password_reset_token
|
||||
assert_equal 1, Pony.sent_mail.length
|
||||
assert mail = Pony.sent_mail.shift
|
||||
end
|
||||
|
||||
def test_forgot_password_non_existent_email
|
||||
post '/forgot-password', { :email => 'not a real email' }
|
||||
assert_redirected '/forgot-password'
|
||||
end
|
||||
|
||||
def test_forgot_password_missing_email
|
||||
post '/forgot-password', { :email => '' }
|
||||
assert_redirected '/forgot-password'
|
||||
end
|
||||
|
||||
def test_reset_password
|
||||
email = @existing_account.email
|
||||
post '/forgot-password', { :email => email }
|
||||
assert_redirected '/sign-in'
|
||||
|
||||
assert_equal 1, Pony.sent_mail.length
|
||||
assert mail = Pony.sent_mail.shift
|
||||
|
||||
token = Account.fetch(@existing_account.id).password_reset_token
|
||||
get "/sign-in/#{email}/#{token}"
|
||||
assert_ok
|
||||
|
||||
new_password = 'new password'
|
||||
post '/account/reset-password', { 'password' => new_password }
|
||||
assert_redirected '/projects'
|
||||
assert_equal @existing_account.id, Account.check_password(@existing_account.email, new_password)
|
||||
assert Account.fetch(@existing_account.id).password == new_password
|
||||
|
||||
# token is only good for one use
|
||||
get "/sign-in/#{email}/#{token}"
|
||||
assert_redirected "/forgot-password/#{email}"
|
||||
end
|
||||
|
||||
|
||||
###############
|
||||
### Account ###
|
||||
###############
|
||||
|
||||
def test_account
|
||||
sign_in
|
||||
get '/account'
|
||||
assert_ok
|
||||
end
|
||||
|
||||
def test_account_password
|
||||
sign_in
|
||||
new_password = 'my new password'
|
||||
post '/account/password', {
|
||||
'old-password' => @existing_account_data['password'],
|
||||
'new-password' => new_password,
|
||||
'password-confirmation' => new_password
|
||||
}
|
||||
assert_response_json_ok
|
||||
end
|
||||
|
||||
def test_account_password_incorrect
|
||||
sign_in
|
||||
post '/account/password', {
|
||||
'old-password' => 'wrong password',
|
||||
'new-password' => 'irrelevant',
|
||||
'password-confirmation' => 'irrelevant'
|
||||
}
|
||||
assert_response_json_fail 'incorrect'
|
||||
end
|
||||
|
||||
def test_account_password_invalid
|
||||
sign_in
|
||||
post '/account/password', {
|
||||
'old-password' => @existing_account_data['password'],
|
||||
'new-password' => ' ',
|
||||
'password-confirmation' => ' '
|
||||
}
|
||||
assert_response_json_fail 'invalid'
|
||||
end
|
||||
|
||||
def test_account_update_json
|
||||
sign_in
|
||||
|
||||
# email
|
||||
post '/account/update.json', { :id => 'email', :value => @existing_account.email }
|
||||
# noop, but is ok
|
||||
assert_response_json_ok
|
||||
# does not send email verification mail
|
||||
assert_equal 0, Pony.sent_mail.length
|
||||
|
||||
post '/account/update.json', { :id => 'email', :value => 'sami-different@example.com' }
|
||||
assert_response_json_ok
|
||||
assert_equal 1, Pony.sent_mail.length
|
||||
assert mail = Pony.sent_mail.shift
|
||||
|
||||
post '/account/update.json', { :id => 'email', :value => 'not an email address' }
|
||||
assert_response_json_fail 'invalid'
|
||||
|
||||
other_account = Account.create(@account_data)
|
||||
post '/account/update.json', { :id => 'email', :value => other_account.email }
|
||||
assert_response_json_fail 'taken'
|
||||
other_account.delete!
|
||||
end
|
||||
|
||||
def test_account_update
|
||||
sign_in
|
||||
|
||||
# valid data
|
||||
new_phone = '640-555-1234'
|
||||
post '/account/update', { 'id' => 'phone', 'value' => new_phone }
|
||||
assert_ok
|
||||
assert_equal new_phone, last_response.body
|
||||
|
||||
# invalid data
|
||||
post '/account/update', { 'id' => 'first_name', 'value' => '' }
|
||||
assert_bad_request
|
||||
|
||||
# non-updatable fields are ignored, but treated the same as updatable fields from the server's perspective
|
||||
post '/account/update', { 'id' => 'email_verified', 'value' => 'true' }
|
||||
assert_ok
|
||||
assert_equal 'true', last_response.body
|
||||
end
|
||||
|
||||
|
||||
####################
|
||||
### Verification ###
|
||||
####################
|
||||
|
||||
def test_verify_email
|
||||
get "/account/verify/#{@existing_account.email}/#{@existing_account.email_verification_token}"
|
||||
assert_redirected '/account'
|
||||
assert_nil Account.fetch(@existing_account.id).email_verification_token
|
||||
end
|
||||
|
||||
def test_verify_email_with_invalid_token_signed_in
|
||||
sign_in
|
||||
get "/account/verify/#{@existing_account.email}/not-a-real-token"
|
||||
assert_redirected '/account'
|
||||
assert_equal @existing_account.email_verification_token, Account.fetch(@existing_account.id).email_verification_token
|
||||
end
|
||||
|
||||
def test_verify_email_with_invalid_token_not_signed_in
|
||||
get "/account/verify/#{@existing_account.email}/not-a-real-token"
|
||||
assert_ok
|
||||
assert_equal @existing_account.email_verification_token, Account.fetch(@existing_account.id).email_verification_token
|
||||
end
|
||||
|
||||
def test_account_send_email_verification
|
||||
sign_in
|
||||
post '/account/send-email-verification'
|
||||
assert_response_json_ok
|
||||
assert_equal 1, Pony.sent_mail.length
|
||||
assert mail = Pony.sent_mail.shift
|
||||
end
|
||||
|
||||
end
|
||||
326
test/controllers/test-admin_controller.rb
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
#!/usr/bin/env ruby
|
||||
#
|
||||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'common'
|
||||
|
||||
class AdminControllerTest < Stormy::Test::ControllerCase
|
||||
|
||||
include Stormy::Test::Helpers::Accounts
|
||||
include Stormy::Test::Helpers::Projects
|
||||
include Stormy::Helpers::Admin
|
||||
include Stormy::Helpers::FAQ
|
||||
include Stormy::Helpers::Utils
|
||||
|
||||
def admins
|
||||
@admins ||= fixtures('admins')
|
||||
end
|
||||
|
||||
def setup
|
||||
@existing_admin_data = admins['sami']
|
||||
@existing_admin = Admin.create(@existing_admin_data)
|
||||
|
||||
@admin_data = admins['freddy']
|
||||
end
|
||||
|
||||
def teardown
|
||||
post '/admin/sign-out'
|
||||
Admin.list_ids.each do |id|
|
||||
Admin.delete!(id)
|
||||
end
|
||||
end
|
||||
|
||||
def sign_in(admin = @existing_admin_data)
|
||||
post '/admin/sign-in', admin
|
||||
end
|
||||
|
||||
|
||||
#####################
|
||||
### Sign In & Out ###
|
||||
#####################
|
||||
|
||||
def test_sign_in
|
||||
get '/admin/sign-in'
|
||||
assert_ok
|
||||
end
|
||||
|
||||
def test_sign_in_submit
|
||||
sign_in
|
||||
assert_redirected '/admin'
|
||||
end
|
||||
|
||||
def test_sign_in_with_invalid_credentials
|
||||
sign_in(@admin_data)
|
||||
assert_redirected '/admin/sign-in'
|
||||
end
|
||||
|
||||
def test_sign_in_redirect
|
||||
sign_in
|
||||
assert_redirected '/admin'
|
||||
end
|
||||
|
||||
def test_sign_out
|
||||
post '/admin/sign-out'
|
||||
assert_redirected '/admin'
|
||||
end
|
||||
|
||||
|
||||
############################
|
||||
### Dashboard & Password ###
|
||||
############################
|
||||
|
||||
def test_dashboard
|
||||
sign_in
|
||||
get '/admin'
|
||||
assert_ok
|
||||
assert last_response.body.match(/<title>Dashboard/)
|
||||
end
|
||||
|
||||
def test_change_password
|
||||
sign_in
|
||||
get '/admin/password'
|
||||
assert_ok
|
||||
|
||||
new_password = 'new password'
|
||||
post '/admin/password', { 'password' => new_password, 'password_confirmation' => new_password }
|
||||
assert_redirected '/admin'
|
||||
@existing_admin.reload!
|
||||
assert @existing_admin.password == new_password
|
||||
|
||||
# incorrect confirmation
|
||||
post '/admin/password', { 'password' => new_password, 'password_confirmation' => 'oops' }
|
||||
assert_redirected '/admin/password'
|
||||
end
|
||||
|
||||
|
||||
################
|
||||
### Accounts ###
|
||||
################
|
||||
|
||||
def test_accounts
|
||||
sign_in
|
||||
get '/admin/accounts'
|
||||
assert_ok
|
||||
end
|
||||
|
||||
def test_account
|
||||
setup_accounts
|
||||
sign_in
|
||||
|
||||
get '/admin/account/' + @existing_account.email
|
||||
assert_ok
|
||||
|
||||
get '/admin/account/not@an.account'
|
||||
# this was the previous listing, kind of weird but meh
|
||||
assert_redirected '/admin/account/' + @existing_account.email
|
||||
|
||||
teardown_accounts
|
||||
end
|
||||
|
||||
def test_update_account
|
||||
setup_accounts
|
||||
sign_in
|
||||
|
||||
# redirected to proper page when changing email addresses
|
||||
new_email = 'sami-different@example.com'
|
||||
post '/admin/account/' + @existing_account.email, {
|
||||
'new_email' => new_email,
|
||||
'first_name' => 'Samson',
|
||||
'last_name' => 'Simpson',
|
||||
'phone' => '+12501234567'
|
||||
}
|
||||
assert_redirected '/admin/account/' + new_email
|
||||
@existing_account.reload!
|
||||
assert_equal 'Samson', @existing_account.first_name
|
||||
assert_equal 'Simpson', @existing_account.last_name
|
||||
assert_equal new_email, @existing_account.email
|
||||
|
||||
# email is verified if changed, verification status stays the same if not changed
|
||||
assert @existing_account.email_verified?
|
||||
|
||||
# redirected to dashboard for non-existent email
|
||||
post '/admin/account/' + @account_data['email']
|
||||
assert_redirected '/admin'
|
||||
|
||||
# redirected to original page if email is taken
|
||||
@account = Account.create(@account_data)
|
||||
post '/admin/account/' + @existing_account.email, {
|
||||
'new_email' => @account.email
|
||||
}
|
||||
assert_redirected '/admin/account/' + @existing_account.email
|
||||
|
||||
# redirected to account page if fields are invalid
|
||||
post '/admin/account/' + @existing_account.email, {
|
||||
'first_name' => '',
|
||||
'last_name' => '',
|
||||
'phone' => ''
|
||||
}
|
||||
assert_redirected '/admin/account/' + @existing_account.email
|
||||
|
||||
# not updated
|
||||
@existing_account.reload!
|
||||
assert_equal 'Samson', @existing_account.first_name
|
||||
assert_equal 'Simpson', @existing_account.last_name
|
||||
assert_equal '+12501234567', @existing_account.phone
|
||||
|
||||
teardown_accounts
|
||||
end
|
||||
|
||||
def test_sign_in_as_user
|
||||
setup_accounts
|
||||
sign_in
|
||||
|
||||
get '/admin/sign-in-as/' + @existing_account.email
|
||||
assert_equal @existing_account.id, session[:id]
|
||||
assert_redirected '/projects'
|
||||
|
||||
teardown_accounts
|
||||
end
|
||||
|
||||
def test_delete_account
|
||||
setup_accounts
|
||||
setup_projects
|
||||
sign_in
|
||||
|
||||
# make sure the last listing is marked so we are redirected correctly
|
||||
get '/admin/accounts'
|
||||
assert_ok
|
||||
|
||||
get "/admin/account/#{@existing_account.email}/delete"
|
||||
assert_redirected '/admin/accounts'
|
||||
|
||||
assert_nil Account.fetch(@existing_account.id)
|
||||
assert_nil Project.fetch(@existing_project.id)
|
||||
|
||||
# non-existent accounts are already gone, so no problem
|
||||
get "/admin/account/nobody@nowhere.net/delete"
|
||||
|
||||
# this time the last listing was not marked, so we are redirected to the dashboard
|
||||
assert_redirected '/admin'
|
||||
|
||||
teardown_projects
|
||||
teardown_accounts
|
||||
end
|
||||
|
||||
|
||||
################
|
||||
### Projects ###
|
||||
################
|
||||
|
||||
def test_projects
|
||||
setup_accounts
|
||||
setup_projects
|
||||
sign_in
|
||||
|
||||
get '/admin/projects'
|
||||
assert_ok
|
||||
|
||||
teardown_projects
|
||||
teardown_accounts
|
||||
end
|
||||
|
||||
def test_project
|
||||
setup_accounts
|
||||
setup_projects
|
||||
sign_in
|
||||
|
||||
# non-existent project
|
||||
get '/admin/project/999'
|
||||
assert_redirected '/admin'
|
||||
|
||||
# existing project
|
||||
get '/admin/project/' + @existing_project.id
|
||||
assert_ok
|
||||
|
||||
teardown_projects
|
||||
teardown_accounts
|
||||
end
|
||||
|
||||
def test_delete_project
|
||||
setup_accounts
|
||||
setup_projects
|
||||
sign_in
|
||||
|
||||
# make sure the last listing is marked so we are redirected correctly
|
||||
get '/admin/projects'
|
||||
|
||||
get "/admin/project/#{@existing_project.id}/delete"
|
||||
assert_redirected '/admin/projects'
|
||||
assert_nil Project.fetch(@existing_project.id)
|
||||
|
||||
teardown_projects
|
||||
teardown_accounts
|
||||
end
|
||||
|
||||
|
||||
###########
|
||||
### FAQ ###
|
||||
###########
|
||||
|
||||
def test_faq
|
||||
sign_in
|
||||
get '/admin/faq'
|
||||
assert_ok
|
||||
end
|
||||
|
||||
def test_update_faq
|
||||
sign_in
|
||||
original_faq = faq
|
||||
|
||||
new_faq = 'this is the new faq'
|
||||
post '/admin/faq', 'faq' => new_faq
|
||||
assert_redirected '/admin/faq'
|
||||
assert_equal new_faq, faq
|
||||
|
||||
# restore the original value
|
||||
self.faq = original_faq
|
||||
end
|
||||
|
||||
|
||||
######################
|
||||
### Admin Accounts ###
|
||||
######################
|
||||
|
||||
def test_admins
|
||||
sign_in
|
||||
get '/admin/admins'
|
||||
assert_ok
|
||||
end
|
||||
|
||||
def test_add_admin
|
||||
sign_in
|
||||
|
||||
password = 'password'
|
||||
fields = {
|
||||
'name' => 'Freddy Kruger',
|
||||
'email' => 'freddy@example.com',
|
||||
'password' => password,
|
||||
'password_confirmation' => password
|
||||
}
|
||||
post '/admin/admins', fields
|
||||
assert_redirected '/admin/admins'
|
||||
admin = Admin.fetch_by_email('freddy@example.com')
|
||||
assert admin.password == password
|
||||
assert_equal fields['name'], admin.name
|
||||
assert_equal fields['email'], admin.email
|
||||
|
||||
# passwords do not match
|
||||
fields = {
|
||||
'name' => 'Jason Vorhees',
|
||||
'email' => 'jason@example.com',
|
||||
'password' => 'my password',
|
||||
'password_confirmation' => 'not the same password'
|
||||
}
|
||||
post '/admin/admins', fields
|
||||
assert_redirected '/admin/admins'
|
||||
assert_nil Admin.fetch_by_email('jason@example.com')
|
||||
end
|
||||
|
||||
def test_delete_admin
|
||||
sign_in
|
||||
get "/admin/admins/#{@existing_admin.id}/delete"
|
||||
assert_redirected '/admin/admins'
|
||||
assert_equal 0, Admin.count
|
||||
end
|
||||
|
||||
end
|
||||
262
test/controllers/test-projects_controller.rb
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
#!/usr/bin/env ruby
|
||||
#
|
||||
# Copyright 2011 Beta Street Media
|
||||
|
||||
require 'common'
|
||||
|
||||
class ProjectsControllerTest < Stormy::Test::ControllerCase
|
||||
|
||||
include Stormy::Test::Helpers::Accounts
|
||||
include Stormy::Test::Helpers::Admins
|
||||
include Stormy::Test::Helpers::Projects
|
||||
include Stormy::Helpers::Authorization
|
||||
|
||||
def setup
|
||||
header 'User-Agent', "rack/test (#{Rack::Test::VERSION})"
|
||||
setup_accounts
|
||||
setup_projects
|
||||
sign_in
|
||||
|
||||
@updated_project_data ||= {
|
||||
:id => @existing_project.id,
|
||||
:name => 'the super amazing project'
|
||||
}
|
||||
end
|
||||
|
||||
def teardown
|
||||
teardown_projects
|
||||
teardown_accounts
|
||||
end
|
||||
|
||||
def create_other_account_and_project
|
||||
@other_account = Account.create(@account_data)
|
||||
@new_project_data['account_id'] = @other_account.id
|
||||
@other_project = Project.create(@new_project_data)
|
||||
end
|
||||
|
||||
def photo_filenames
|
||||
@photo_filenames ||= Dir[photo_file('*.jpg')]
|
||||
end
|
||||
|
||||
def add_photo(filename = photo_filenames.first)
|
||||
post '/project/add-photo', {
|
||||
:id => @existing_project.id,
|
||||
:photo => Rack::Test::UploadedFile.new(filename, 'image/jpeg')
|
||||
}
|
||||
@existing_project.reload!
|
||||
photo_id = @existing_project.photo_ids.last
|
||||
assert_response_json_ok(
|
||||
'n' => @existing_project.count_photos,
|
||||
'photo' => {
|
||||
'id' => photo_id,
|
||||
'url' => @existing_project.photo_url(photo_id)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def add_all_photos
|
||||
photo_filenames.each { |f| add_photo(f) }
|
||||
end
|
||||
|
||||
|
||||
##################
|
||||
### Projects ###
|
||||
##################
|
||||
|
||||
def test_projects
|
||||
# must be authorized
|
||||
sign_out
|
||||
get '/projects'
|
||||
assert_redirected '/sign-in'
|
||||
|
||||
# now we can get the projects page
|
||||
sign_in
|
||||
get '/projects'
|
||||
assert_ok
|
||||
end
|
||||
|
||||
def test_project
|
||||
get "/project/#{@existing_project.id}"
|
||||
assert_ok
|
||||
end
|
||||
|
||||
def test_project_without_a_name
|
||||
@existing_project.name = ''
|
||||
@existing_project.save!
|
||||
get "/project/#{@existing_project.id}"
|
||||
assert_ok
|
||||
end
|
||||
|
||||
def test_cannot_access_others_projects
|
||||
create_other_account_and_project
|
||||
get "/project/#{@other_project.id}"
|
||||
assert_redirected '/projects'
|
||||
follow_redirect!
|
||||
assert_ok
|
||||
assert last_response.body.match(/no such project/i)
|
||||
end
|
||||
|
||||
def test_update_project
|
||||
data = @updated_project_data
|
||||
post '/project/update', data
|
||||
assert_redirected "/project/#{data[:id]}"
|
||||
@existing_project.reload!
|
||||
data.each do |name, value|
|
||||
assert_equal value, @existing_project.send(name)
|
||||
end
|
||||
end
|
||||
|
||||
def test_update_project_with_invalid_fields
|
||||
expected_name = @existing_project.name
|
||||
data = {
|
||||
:id => @existing_project.id,
|
||||
:name => ''
|
||||
}
|
||||
post '/project/update', data
|
||||
assert_redirected "/project/#{data[:id]}"
|
||||
@existing_project.reload!
|
||||
assert_equal expected_name, @existing_project.name
|
||||
end
|
||||
|
||||
def test_update_project_by_admin
|
||||
setup_admins
|
||||
post '/admin/sign-in', @admin_data
|
||||
|
||||
data = @updated_project_data
|
||||
post '/project/update', data
|
||||
assert_redirected "/project/#{data[:id]}"
|
||||
|
||||
teardown_admins
|
||||
end
|
||||
|
||||
def test_cannot_update_others_projects
|
||||
create_other_account_and_project
|
||||
post '/project/update', { :id => @other_project.id }
|
||||
assert_redirected '/projects'
|
||||
follow_redirect!
|
||||
assert_ok
|
||||
assert last_response.body.match(/no such project/i)
|
||||
end
|
||||
|
||||
def test_add_photo
|
||||
# also test /uploadify which is used for photo uploads in IE
|
||||
%w[/project/add-photo /uploadify].each_with_index do |path, i|
|
||||
post path, {
|
||||
:id => @existing_project.id,
|
||||
# /project/add-photo
|
||||
:photo => Rack::Test::UploadedFile.new(photo_filenames.first, 'image/jpeg'),
|
||||
# /uploadify
|
||||
:Filedata => Rack::Test::UploadedFile.new(photo_filenames.first, 'image/jpeg')
|
||||
}
|
||||
@existing_project.reload!
|
||||
photo_id = @existing_project.photo_ids[i]
|
||||
assert_response_json_ok({
|
||||
'n' => i + 1,
|
||||
'photo' => {
|
||||
'id' => photo_id,
|
||||
'url' => @existing_project.photo_url(photo_id)
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
def test_add_photo_fails_at_photo_limit
|
||||
Project::MaxPhotos.times { add_photo }
|
||||
|
||||
post '/project/add-photo', {
|
||||
:id => @existing_project.id,
|
||||
:photo => Rack::Test::UploadedFile.new(photo_filenames.first, 'image/jpeg'),
|
||||
}
|
||||
assert_response_json_fail('limit')
|
||||
|
||||
post '/uploadify', {
|
||||
:id => @existing_project.id,
|
||||
:Filedata => Rack::Test::UploadedFile.new(photo_filenames.first, 'image/jpeg'),
|
||||
}
|
||||
assert_bad_request
|
||||
end
|
||||
|
||||
def test_add_photo_by_admin
|
||||
setup_admins
|
||||
post '/admin/sign-in', @admin_data
|
||||
|
||||
post '/project/add-photo', {
|
||||
:id => @existing_project.id,
|
||||
:photo => Rack::Test::UploadedFile.new(photo_filenames.first, 'image/jpeg'),
|
||||
}
|
||||
@existing_project.reload!
|
||||
photo_id = @existing_project.photo_ids.last
|
||||
assert_response_json_ok({
|
||||
'n' => 1,
|
||||
'photo' => {
|
||||
'id' => photo_id,
|
||||
'url' => @existing_project.photo_url(photo_id)
|
||||
}
|
||||
})
|
||||
|
||||
teardown_admins
|
||||
end
|
||||
|
||||
def test_remove_photo
|
||||
add_photo
|
||||
photo_id = @existing_project.photo_ids.last
|
||||
post '/project/remove-photo', {
|
||||
:id => @existing_project.id,
|
||||
:photo_id => photo_id
|
||||
}
|
||||
@existing_project.reload!
|
||||
assert_response_json_ok('photos' => [])
|
||||
assert_equal 0, @existing_project.count_photos
|
||||
end
|
||||
|
||||
def test_remove_photo_by_admin
|
||||
setup_admins
|
||||
post '/admin/sign-in', @admin_data
|
||||
|
||||
add_photo
|
||||
photo_id = @existing_project.photo_ids.last
|
||||
post '/project/remove-photo', {
|
||||
:id => @existing_project.id,
|
||||
:photo_id => photo_id
|
||||
}
|
||||
@existing_project.reload!
|
||||
assert_response_json_ok('photos' => [])
|
||||
assert_equal 0, @existing_project.count_photos
|
||||
|
||||
teardown_admins
|
||||
end
|
||||
|
||||
def test_reorder_photos
|
||||
add_all_photos
|
||||
@existing_project.reload!
|
||||
photo_ids = @existing_project.photo_ids
|
||||
# move the first to the end
|
||||
photo_ids.push(photo_ids.shift)
|
||||
post '/project/photo-order', {
|
||||
:id => @existing_project.id,
|
||||
:order => photo_ids
|
||||
}
|
||||
@existing_project.reload!
|
||||
assert_equal photo_ids, @existing_project.photo_ids
|
||||
end
|
||||
|
||||
def test_reorder_photos_by_admin
|
||||
setup_admins
|
||||
post '/admin/sign-in', @admin_data
|
||||
|
||||
add_all_photos
|
||||
@existing_project.reload!
|
||||
photo_ids = @existing_project.photo_ids
|
||||
# move the first to the end
|
||||
photo_ids.push(photo_ids.shift)
|
||||
post '/project/photo-order', {
|
||||
:id => @existing_project.id,
|
||||
:order => photo_ids
|
||||
}
|
||||
@existing_project.reload!
|
||||
assert_equal photo_ids, @existing_project.photo_ids
|
||||
|
||||
teardown_admins
|
||||
end
|
||||
|
||||
end
|
||||
36
test/controllers/test-public_controller.rb
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#!/usr/bin/env ruby
|
||||
#
|
||||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'common'
|
||||
|
||||
class PublicControllerTest < Stormy::Test::ControllerCase
|
||||
|
||||
def test_home
|
||||
get '/'
|
||||
assert_ok
|
||||
end
|
||||
|
||||
def test_contact
|
||||
get '/contact'
|
||||
assert_ok
|
||||
end
|
||||
|
||||
def test_contact_form
|
||||
post '/contact', { 'email' => 'sami@example.com', 'message' => 'please get back to me...' }
|
||||
assert_redirected '/contact'
|
||||
assert_equal 1, Pony.sent_mail.length
|
||||
assert mail = Pony.sent_mail.shift
|
||||
end
|
||||
|
||||
def test_terms
|
||||
get '/terms'
|
||||
assert_ok
|
||||
end
|
||||
|
||||
def test_faq
|
||||
get '/faq'
|
||||
assert_ok
|
||||
end
|
||||
|
||||
end
|
||||
16
test/fixtures/accounts.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{ "sami": {
|
||||
"first_name" : "Sami"
|
||||
, "last_name" : "Samhuri"
|
||||
, "email" : "sami@example.com"
|
||||
, "phone" : "250-216-6216"
|
||||
, "password" : "super secret"
|
||||
}
|
||||
|
||||
, "freddy": {
|
||||
"first_name" : "Freddy"
|
||||
, "last_name" : "Kruger"
|
||||
, "email" : "freddy@example.com"
|
||||
, "phone" : "(250) 555-9999"
|
||||
, "password" : "even more secreter"
|
||||
}
|
||||
}
|
||||
12
test/fixtures/admins.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{ "sami": {
|
||||
"name" : "Sami"
|
||||
, "email" : "sami@example.com"
|
||||
, "password" : "super secret"
|
||||
}
|
||||
|
||||
, "freddy": {
|
||||
"name" : "Freddy"
|
||||
, "email" : "freddy@example.com"
|
||||
, "password" : "even more secreter"
|
||||
}
|
||||
}
|
||||
14
test/fixtures/projects.json
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{ "stormy": {
|
||||
"name" : "Stormy Weather"
|
||||
}
|
||||
|
||||
, "apps-for-you" : {
|
||||
"name" : "Apps For You"
|
||||
, "funded_timestamp" : 1328475981
|
||||
}
|
||||
|
||||
, "dating-free" : {
|
||||
"name" : "Dating Free"
|
||||
, "fizzled_timestamp" : 1328475981
|
||||
}
|
||||
}
|
||||
52
test/helpers/test-accounts.rb
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
#!/usr/bin/env ruby
|
||||
#
|
||||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'common'
|
||||
|
||||
class AccountsHelperTest < Stormy::Test::HelperCase
|
||||
|
||||
include Stormy::Helpers::Accounts
|
||||
include Stormy::Test::Helpers::Accounts
|
||||
include Stormy::Test::Helpers::Projects
|
||||
|
||||
def setup
|
||||
setup_accounts
|
||||
end
|
||||
|
||||
def teardown
|
||||
teardown_accounts
|
||||
end
|
||||
|
||||
|
||||
######################
|
||||
### Password Reset ###
|
||||
######################
|
||||
|
||||
def test_send_reset_password_mail
|
||||
data = send_reset_password_mail(@existing_account.email)
|
||||
@existing_account.reload!
|
||||
assert data
|
||||
assert_equal @existing_account.first_name, data['name']
|
||||
assert_equal @existing_account.password_reset_token, data['token']
|
||||
assert_equal 1, Pony.sent_mail.length
|
||||
assert mail = Pony.sent_mail.shift
|
||||
end
|
||||
|
||||
|
||||
####################
|
||||
### Verification ###
|
||||
####################
|
||||
|
||||
def test_send_verification_mail
|
||||
send_verification_mail(@existing_account)
|
||||
assert @existing_account.email_verification_token.present?
|
||||
assert_equal 1, Pony.sent_mail.length
|
||||
assert mail = Pony.sent_mail.shift
|
||||
|
||||
send_verification_mail(@existing_account, 'custom subject')
|
||||
assert_equal 1, Pony.sent_mail.length
|
||||
assert mail = Pony.sent_mail.shift
|
||||
end
|
||||
|
||||
end
|
||||
39
test/helpers/test-admin.rb
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env ruby
|
||||
#
|
||||
# Copyright 2012 Sami Samhuri <sami@samhuri.net>
|
||||
|
||||
require 'common'
|
||||
|
||||
class AdminHelperTest < Stormy::Test::HelperCase
|
||||
|
||||
include Stormy::Helpers::Admin
|
||||
|
||||
def session
|
||||
@session ||= {}
|
||||
end
|
||||
|
||||
def test_num_accounts
|
||||
assert_equal 0, num_accounts
|
||||
end
|
||||
|
||||
def test_num_admins
|
||||
assert_equal 0, num_admins
|
||||
end
|
||||
|
||||
def test_num_projects
|
||||
assert_equal 0, num_projects
|
||||
end
|
||||
|
||||
def test_last_listing
|
||||
assert_equal '/admin', last_listing
|
||||
mark_last_listing '/admin/accounts'
|
||||
assert_equal '/admin/accounts', last_listing
|
||||
end
|
||||
|
||||
def test_mark_last_listing
|
||||
assert_equal '/admin', last_listing
|
||||
mark_last_listing '/admin/accounts'
|
||||
assert_equal '/admin/accounts', last_listing
|
||||
end
|
||||
|
||||
end
|
||||