加入了对赞功能模块,同时预留了踩功能,但暂时不使用。

This commit is contained in:
william 2013-08-08 17:01:56 +08:00
parent 4676eed9a3
commit 0b5ea87f11
63 changed files with 1430 additions and 11 deletions

View File

@ -7,6 +7,7 @@ gem "coderay", "~> 1.0.6"
gem "fastercsv", "~> 1.5.0", :platforms => [:mri_18, :mingw_18, :jruby] gem "fastercsv", "~> 1.5.0", :platforms => [:mri_18, :mingw_18, :jruby]
gem "builder", "3.0.0" gem "builder", "3.0.0"
gem 'acts-as-taggable-on' gem 'acts-as-taggable-on'
gem 'seems_rateable'
# Optional gem for LDAP authentication # Optional gem for LDAP authentication
group :ldap do group :ldap do
gem "net-ldap", "~> 0.3.1" gem "net-ldap", "~> 0.3.1"

View File

@ -98,6 +98,9 @@ GEM
rmagick (2.13.2) rmagick (2.13.2)
ruby-openid (2.1.8) ruby-openid (2.1.8)
rubyzip (0.9.9) rubyzip (0.9.9)
seems_rateable (1.0.9)
jquery-rails
rails
selenium-webdriver (2.33.0) selenium-webdriver (2.33.0)
childprocess (>= 0.2.5) childprocess (>= 0.2.5)
multi_json (~> 1.0) multi_json (~> 1.0)
@ -150,6 +153,7 @@ DEPENDENCIES
rdoc (>= 2.4.2) rdoc (>= 2.4.2)
rmagick (>= 2.0.0) rmagick (>= 2.0.0)
ruby-openid (~> 2.1.4) ruby-openid (~> 2.1.4)
seems_rateable
shoulda (~> 3.3.2) shoulda (~> 3.3.2)
sqlite3 sqlite3
yard yard

View File

@ -0,0 +1,2 @@
// Place all the behaviors and hooks related to the matching controller here.
// All this logic will automatically be available in application.js.

View File

@ -0,0 +1,225 @@
/************************************************************************
*************************************************************************
@Name : jRating - jQuery Plugin
@Revison : 3.0
@Date : 28/01/2013
@Author: ALPIXEL - (www.myjqueryplugins.com - www.alpixel.fr)
@License : Open Source - MIT License : http://www.opensource.org/licenses/mit-license.php
**************************************************************************
*************************************************************************/
(function($) {
$.fn.jRating = function(op) {
var defaults = {
/** String vars **/
bigStarsPath : '<%= image_path "seems_rateable/stars.png" %>', // path of the icon stars.png
smallStarsPath : '<%= image_path "seems_rateable/small.png" %>', // path of the icon small.png
path : '<%= SeemsRateable::Engine.routes.url_helpers.ratings_path %>',
type : 'big', // can be set to 'small' or 'big'
/** Boolean vars **/
step:false, // if true, mouseover binded star by star,
isDisabled:false,
showRateInfo: false,
canRateAgain : false,
/** Integer vars **/
length:5, // number of star to display
decimalLength : 0, // number of decimals.. Max 3, but you can complete the function 'getNote'
rateMax : 20, // maximal rate - integer from 0 to 9999 (or more)
rateInfosX : -45, // relative position in X axis of the info box when mouseover
rateInfosY : 5, // relative position in Y axis of the info box when mouseover
nbRates : 1,
/** Functions **/
onSuccess : null,
onError : null
};
if(this.length>0)
return this.each(function() {
/*vars*/
var opts = $.extend(defaults, op),
newWidth = 0,
starWidth = 0,
starHeight = 0,
bgPath = '',
hasRated = false,
globalWidth = 0,
nbOfRates = opts.nbRates;
if($(this).hasClass('jDisabled') || opts.isDisabled)
var jDisabled = true;
else
var jDisabled = false;
getStarWidth();
$(this).height(starHeight);
var average = parseFloat($(this).attr('data-average')), // get the average of all rates
idBox = parseInt($(this).attr('data-id')), // get the id of the box
kls = $(this).attr('data-kls'),
dimension = $(this).attr('data-dimension'),
widthRatingContainer = starWidth*opts.length, // Width of the Container
widthColor = average/opts.rateMax*widthRatingContainer, // Width of the color Container
quotient =
$('<div>',
{
'class' : 'jRatingColor',
css:{
width:widthColor
}
}).appendTo($(this)),
average =
$('<div>',
{
'class' : 'jRatingAverage',
css:{
width:0,
top:- starHeight
}
}).appendTo($(this)),
jstar =
$('<div>',
{
'class' : 'jStar',
css:{
width:widthRatingContainer,
height:starHeight,
top:- (starHeight*2),
background: 'url('+bgPath+') repeat-x'
}
}).appendTo($(this));
$(this).css({width: widthRatingContainer,overflow:'hidden',zIndex:1,position:'relative'});
if(!jDisabled)
$(this).unbind().bind({
mouseenter : function(e){
var realOffsetLeft = findRealLeft(this);
var relativeX = e.pageX - realOffsetLeft;
if (opts.showRateInfo)
var tooltip =
$('<p>',{
'class' : 'jRatingInfos',
html : getNote(relativeX)+' <span class="maxRate">/ '+opts.rateMax+'</span>',
css : {
top: (e.pageY + opts.rateInfosY),
left: (e.pageX + opts.rateInfosX)
}
}).appendTo('body').show();
},
mouseover : function(e){
$(this).css('cursor','pointer');
},
mouseout : function(){
$(this).css('cursor','default');
if(hasRated) average.width(globalWidth);
else average.width(0);
},
mousemove : function(e){
var realOffsetLeft = findRealLeft(this);
var relativeX = e.pageX - realOffsetLeft;
if(opts.step) newWidth = Math.floor(relativeX/starWidth)*starWidth + starWidth;
else newWidth = relativeX;
average.width(newWidth);
if (opts.showRateInfo)
$("p.jRatingInfos")
.css({
left: (e.pageX + opts.rateInfosX)
})
.html(getNote(newWidth) +' <span class="maxRate">/ '+opts.rateMax+'</span>');
},
mouseleave : function(){
$("p.jRatingInfos").remove();
},
click : function(e){
var element = this;
/*set vars*/
hasRated = true;
globalWidth = newWidth;
nbOfRates--;
if(!opts.canRateAgain || parseInt(nbOfRates) <= 0) $(this).unbind().css('cursor','default').addClass('jDisabled');
if (opts.showRateInfo) $("p.jRatingInfos").fadeOut('fast',function(){$(this).remove();});
e.preventDefault();
var rate = getNote(newWidth);
average.width(newWidth);
$.post(defaults.path,
{
idBox : idBox,
rate : rate,
kls : kls,
dimension : dimension
/** action : 'rating' **/
},
function(data) {
if(!data.error)
{
/** Here you can display an alert box,
or use the jNotify Plugin :) http://www.myqjqueryplugins.com/jNotify
exemple : */
if(opts.onSuccess) opts.onSuccess( element, rate );
}
else
{
/** Here you can display an alert box,
or use the jNotify Plugin :) http://www.myqjqueryplugins.com/jNotify
exemple : */
if(opts.onError) opts.onError( element, rate );
}
},
'json'
);
}
});
function getNote(relativeX) {
var noteBrut = parseFloat((relativeX*100/widthRatingContainer)*opts.rateMax/100);
switch(opts.decimalLength) {
case 1 :
var note = Math.round(noteBrut*10)/10;
break;
case 2 :
var note = Math.round(noteBrut*100)/100;
break;
case 3 :
var note = Math.round(noteBrut*1000)/1000;
break;
default :
var note = Math.round(noteBrut*1)/1;
}
return note;
};
function getStarWidth(){
switch(opts.type) {
case 'small' :
starWidth = 12; // width of the picture small.png
starHeight = 10; // height of the picture small.png
bgPath = opts.smallStarsPath;
break;
default :
starWidth = 23; // width of the picture stars.png
starHeight = 20; // height of the picture stars.png
bgPath = opts.bigStarsPath;
}
};
function findRealLeft(obj) {
if( !obj ) return 0;
return obj.offsetLeft + findRealLeft( obj.offsetParent );
};
});
}
})(jQuery);

View File

@ -0,0 +1,25 @@
$(document).ready(function(){
$(".rateable").jRating({
//default options displayed below ->
rateMax: 5, //Maximal rate
length : 5, //Number of stars
//decimalLength : 0, //Number of decimals in the rate
//type : 'big', //Big or small
//step : true, //If set to true, filling of the stars is done star by star (step by step).
//isDisabled: false, //Set true to display static rating
//showRateInfo:false, //Rate info panel, set true to display
//rateInfosX : 45, //In pixel - Absolute left position of the information box during mousemove.
//rateInfosY : 5, //In pixel - Absolute top position of the information box during mousemove.
path : '<%= SeemsRateable::Engine.routes.url_helpers.ratings_path %>',
onSuccess : function(element, rate){
//something like ->
//alert('success');
$('<span class="text-success"><small style="display:inline-block;">Thanks for rating!</small></span>').insertAfter(element)
},
onError : function(element, rate) {
$('<span class="text-error"><small style="display:inline-block;">You have already rated!</small></span>').insertAfter(element)
}
});
});

View File

@ -0,0 +1,4 @@
/*
Place all the styles related to the matching controller here.
They will automatically be included in application.css.
*/

View File

@ -95,6 +95,9 @@ class ApplicationController < ActionController::Base
# Returns the current user or nil if no user is logged in # Returns the current user or nil if no user is logged in
# and starts a session if needed # and starts a session if needed
def current_user
find_current_user
end
def find_current_user def find_current_user
user = nil user = nil
unless api_request? unless api_request?

View File

@ -0,0 +1,68 @@
class PraiseTreadController < ApplicationController
def praise_plus
@obj = nil
if request.get?
@obj = params[:obj] # 传的是对象最后变成id了
#首先创建或更新praise_tread 表
@pt = PraiseTread.find_by_user_id_and_praise_tread_object_id(User.current.id,@obj)
@pt = @pt.nil? ? PraiseTread.new : @pt
@pt.user_id = User.current.id
@pt.praise_tread_object_id = @obj.to_i
@pt.praise_tread_object_type = User.find_by_id(@obj).class.name.underscore
@pt.praise_or_tread = 1
@pt.save
#再创建或更新praise_tread_cache表
@ptc = PraiseTreadCache.find_by_object_id(@obj)
@ptc = @ptc.nil? ? PraiseTreadCache.new : @ptc
@ptc.object_id = @obj.to_i
@ptc.object_type = User.find_by_id(@obj).class.name.underscore
@ptc.plus(1)
@ptc.save
end
@obj = User.find_by_id(@obj)
respond_to do |format|
format.html
format.js
end
end
def praise_minus
@obj = nil
if request.get?
@obj = params[:obj] # 传的是对象最后变成id了
#首先更新praise_tread 表 删除关注记录
@pt = PraiseTread.find_by_user_id_and_praise_tread_object_id_and_praise_tread_object_type(User.current.id,@obj,"user")
@pt.delete
#再更新praise_tread_cache表 使相应的记录减1 当为0时删除
@ptc = PraiseTreadCache.find_by_object_id(@obj)
@ptc.minus(1)
if @ptc.praise_num == 0
@ptc.delete
end
end
@obj = User.find_by_id(@obj)
respond_to do |format|
format.html
format.js
end
end
def tread_plus
end
def tread_minus
respond_to do |format|
format.html
format.js
end
end
end

View File

@ -26,11 +26,7 @@ module ApplicationHelper
include GravatarHelper::PublicMethods include GravatarHelper::PublicMethods
include Redmine::Pagination::Helper include Redmine::Pagination::Helper
include AvatarHelper include AvatarHelper
include PraiseTreadHelper
### added by william
include ActsAsTaggableOn::TagsHelper
# include WatchersHelper
extend Forwardable extend Forwardable
def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter

View File

@ -0,0 +1,23 @@
module PraiseTreadHelper
#added by william
def is_praise_or_tread(object,user_id)
@obj_type = object.class.name.underscore
@obj_id = object.id
@is_praise = PraiseTread.find_by_sql("select * from praise_treads where user_id=#{user_id} and " +
"praise_tread_object_type='#{@obj_type}' and praise_tread_object_id=#{@obj_id} ")
return @is_praise
end
#end
def get_praise_num(object)
@obj_type = object.class.name.underscore
@obj_id = object.id
@record = PraiseTreadCache.find_by_object_id_and_object_type(@obj_id,@obj_type)
if @record
return @record.praise_num
else
return 0
end
end
end

View File

@ -0,0 +1,4 @@
class PraiseTread < ActiveRecord::Base
attr_accessible :user_id,:praise_tread_object_id,:praise_tread_object_type,:praise_or_tread
end

View File

@ -0,0 +1,11 @@
class PraiseTreadCache < ActiveRecord::Base
attr_accessible :object_id,:object_type,:praise_num,:tread_num
def plus(num)
self.update_attribute(:praise_num, self.praise_num.to_i + num)
end
def minus(num)
self.update_attribute(:praise_num, self.praise_num.to_i - num)
end
end

View File

@ -103,7 +103,7 @@ class User < Principal
acts_as_customizable acts_as_customizable
############################added by william ############################added by william
acts_as_taggable acts_as_taggable
seems_rateable
############################# added by liuping 关注 ############################# added by liuping 关注
acts_as_watchable acts_as_watchable

View File

@ -9,7 +9,11 @@
<%= favicon %> <%= favicon %>
<%= stylesheet_link_tag 'jquery/jquery-ui-1.9.2', 'application', :media => 'all' %> <%= stylesheet_link_tag 'jquery/jquery-ui-1.9.2', 'application', :media => 'all' %>
<%= stylesheet_link_tag 'rtl', :media => 'all' if l(:direction) == 'rtl' %> <%= stylesheet_link_tag 'rtl', :media => 'all' if l(:direction) == 'rtl' %>
<!-- added by william -->
<%= seems_rateable_stylesheet %>
<!-- end -->
<%= javascript_heads %> <%= javascript_heads %>
<%= heads_for_theme %> <%= heads_for_theme %>
<%= call_hook :view_layouts_base_html_head %> <%= call_hook :view_layouts_base_html_head %>
<!-- page specific tags --> <!-- page specific tags -->
@ -44,6 +48,11 @@
</table></td> </table></td>
</tr> </tr>
</table> </table>
<div id="praise_tread">
<%= render :partial => "/praise_tread/praise_tread",:locals => {:obj => @user,:show_flag => false,:user_id => User.current.id}%>
</div>
<div> <div>
<%= l(:label_user_watcher) %> (<strong class="font_small_watch"><%= User.watched_by(@user.id).count %></strong>) &nbsp; <%= l(:label_user_watcher) %> (<strong class="font_small_watch"><%= User.watched_by(@user.id).count %></strong>) &nbsp;
<%= l(:label_user_fans) %> (<strong class="font_small_watch"><%= @user.watcher_users.count %></strong>) <%= l(:label_user_fans) %> (<strong class="font_small_watch"><%= @user.watcher_users.count %></strong>)

View File

View File

@ -0,0 +1,17 @@
<div id="praise">
<% if is_praise_or_tread(obj,user_id).size > 0 %>
<%= image_tag("/images/praise.png") %>
<%= link_to "取消贊",:controller=>"praise_tread",:action=>"praise_minus",:remote=>true,:obj => obj %>
(<%= get_praise_num(obj)%>)
<% else %>
<%= image_tag("/images/tread.png") %>
<%= link_to "贊",:controller=>"praise_tread",:action=>"praise_plus",:remote=>true,:obj => obj %>
(<%= get_praise_num(obj)%>)
<% end %>
</div>
<% if show_flag %>
<div id="tread">
<%= link_to image_tag("/images/tread.png"),:controller=>"praise_tread",
:action=>"tread_minus",:remote=>true,:obj => obj %>踩
</div>
<% end %>

View File

View File

@ -0,0 +1,3 @@
$('#praise_tread').html('<%= j(
render :partial => "/praise_tread/praise_tread",:locals => {:obj => @obj,:show_flag => false,:user_id => User.current.id}
)%>');

View File

@ -0,0 +1,3 @@
$('#praise_tread').html('<%= j(
render :partial => "/praise_tread/praise_tread",:locals => {:obj => @obj,:show_flag => false,:user_id => User.current.id}
)%>');

View File

@ -0,0 +1,4 @@
#SeemsRateable engine Initializer
#Configure owner class of the given ratings
SeemsRateable::Engine.config.owner_class = "User"

View File

@ -18,10 +18,12 @@
RedmineApp::Application.routes.draw do RedmineApp::Application.routes.draw do
resources :shares resources :shares
get "tags/index" get "tags/index"
get "tags/show" get "tags/show"
get "praise_tread/praise_plus"
get "praise_tread/praise_minus"
get "praise_tread/tread_minus"
root :to => 'welcome#index', :as => 'home' root :to => 'welcome#index', :as => 'home'
@ -95,7 +97,7 @@ RedmineApp::Application.routes.draw do
match 'users/:id/memberships/:membership_id', :to => 'users#edit_membership', :via => :put, :as => 'user_membership' match 'users/:id/memberships/:membership_id', :to => 'users#edit_membership', :via => :put, :as => 'user_membership'
match 'users/:id/memberships/:membership_id', :to => 'users#destroy_membership', :via => :delete match 'users/:id/memberships/:membership_id', :to => 'users#destroy_membership', :via => :delete
match 'users/:id/memberships', :to => 'users#edit_membership', :via => :post, :as => 'user_memberships' match 'users/:id/memberships', :to => 'users#edit_membership', :via => :post, :as => 'user_memberships'
################# added by william ################# added by william
match 'users/tag_save', :to => 'users#tag_save', :via => :post, :as => 'tag' match 'users/tag_save', :to => 'users#tag_save', :via => :post, :as => 'tag'
post 'watchers/watch', :to => 'watchers#watch', :as => 'watch' post 'watchers/watch', :to => 'watchers#watch', :as => 'watch'
@ -422,8 +424,12 @@ RedmineApp::Application.routes.draw do
match 'bids/:id', :controller => 'bids', :action => 'show', :as => 'respond' match 'bids/:id', :controller => 'bids', :action => 'show', :as => 'respond'
########### added by liuping ########### added by liuping
match 'tags/add_tag',:to => 'tags#add_tag',:as=>"add_tag" match 'tags/add_tag',:to => 'tags#add_tag',:as=>"add_tag"
match 'tags/delete_tag',:to => 'tags#delete_tag',:as=>"add_tag" match 'tags/delete_tag',:to => 'tags#delete_tag',:as=>"add_tag"
match 'parise_tread/praise_plus',:to => 'parise_tread#praise_plus',:as=>"praise"
match 'parise_tread/tread_minus',:to => 'parise_tread#tread_minus',:as=>"tread"
end end

View File

@ -0,0 +1,18 @@
class CreateSeemsRateableRates < ActiveRecord::Migration
def self.up
create_table :seems_rateable_rates do |t|
t.belongs_to :rater
t.belongs_to :rateable, :polymorphic => true
t.float :stars, :null => false
t.integer :rater_id, :limit => 8
t.integer :rateable_id
t.string :rateable_type
t.string :dimension
t.timestamps
end
end
def self.down
drop_table :rates
end
end

View File

@ -0,0 +1,17 @@
class CreateSeemsRateableCachedRatings < ActiveRecord::Migration
def self.up
create_table :seems_rateable_cached_ratings do |t|
t.belongs_to :cacheable, :polymorphic => true
t.float :avg, :null => false
t.integer :cnt, :null => false
t.string :dimension
t.integer :cacheable_id, :limit => 8
t.string :cacheable_type
t.timestamps
end
end
def self.down
drop_table :cached_ratings
end
end

View File

@ -0,0 +1,15 @@
class CreatePraiseTreads < ActiveRecord::Migration
def self.up
create_table :praise_treads do |t|
t.column :user_id,:integer,:null => false
t.column :praise_tread_object_id,:integer
t.column :praise_tread_object_type,:string
t.column :praise_or_tread,:integer
t.timestamps
end
end
def self.down
drop_table :praise_treads
end
end

View File

@ -0,0 +1,16 @@
class CreatePraiseTreadCaches < ActiveRecord::Migration
def self.up
create_table :praise_tread_caches do |t|
t.column :object_id,:integer,:null => false
t.column :object_type,:string
t.column :praise_num,:integer
t.column :tread_num,:integer
t.timestamps
end
end
def self.down
drop_table :praise_tread_caches
end
end

View File

@ -11,7 +11,7 @@
# #
# It's strongly recommended to check this file into your version control system. # It's strongly recommended to check this file into your version control system.
ActiveRecord::Schema.define(:version => 20130805131602) do ActiveRecord::Schema.define(:version => 20130807021309) do
create_table "a_user_watchers", :force => true do |t| create_table "a_user_watchers", :force => true do |t|
t.string "name" t.string "name"
@ -432,6 +432,24 @@ ActiveRecord::Schema.define(:version => 20130805131602) do
t.string "salt", :null => false t.string "salt", :null => false
end end
create_table "praise_tread_caches", :force => true do |t|
t.integer "object_id", :null => false
t.string "object_type"
t.integer "praise_num"
t.integer "tread_num"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
end
create_table "praise_treads", :force => true do |t|
t.integer "user_id", :null => false
t.integer "praise_tread_object_id"
t.string "praise_tread_object_type"
t.integer "praise_or_tread"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
end
create_table "project_tags", :force => true do |t| create_table "project_tags", :force => true do |t|
t.integer "project_id" t.integer "project_id"
t.integer "tag_id" t.integer "tag_id"
@ -507,6 +525,26 @@ ActiveRecord::Schema.define(:version => 20130805131602) do
t.string "issues_visibility", :limit => 30, :default => "default", :null => false t.string "issues_visibility", :limit => 30, :default => "default", :null => false
end end
create_table "seems_rateable_cached_ratings", :force => true do |t|
t.integer "cacheable_id", :limit => 8
t.string "cacheable_type"
t.float "avg", :null => false
t.integer "cnt", :null => false
t.string "dimension"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
end
create_table "seems_rateable_rates", :force => true do |t|
t.integer "rater_id", :limit => 8
t.integer "rateable_id"
t.string "rateable_type"
t.float "stars", :null => false
t.string "dimension"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
end
create_table "settings", :force => true do |t| create_table "settings", :force => true do |t|
t.string "name", :default => "", :null => false t.string "name", :default => "", :null => false
t.text "value" t.text "value"
@ -522,6 +560,7 @@ ActiveRecord::Schema.define(:version => 20130805131602) do
t.string "url" t.string "url"
t.datetime "created_at", :null => false t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false t.datetime "updated_at", :null => false
t.integer "project_id"
end end
create_table "students", :force => true do |t| create_table "students", :force => true do |t|

View File

@ -0,0 +1,21 @@
*.gem
*.rbc
.bundle
.config
.yardoc
Gemfile.lock
InstalledFiles
_yardoc
coverage
doc/
lib/bundler/man
pkg
rdoc
spec/reports
test/tmp
test/version_tmp
tmp
.project
.rvmrc
spec
test

View File

@ -0,0 +1,23 @@
source "https://rubygems.org"
# Declare your gem's dependencies in seems_rateable.gemspec.
# Bundler will treat runtime dependencies like base dependencies, and
# development dependencies will be added by default to the :development group.
gemspec
# Declare any dependencies that are still in development here instead of in
# your gemspec. These might include edge Rails or gems from your path or
# Git. Remember to move these dependencies to your gemspec before releasing
# your gem to rubygems.org.
# To use debugger
# gem 'debugger'
group :development do
gem 'sqlite3'
gem 'jquery-rails'
gem 'twitter-bootstrap-rails'
gem 'sorcery'
end

View File

@ -0,0 +1,20 @@
Copyright 2013 YOURNAME
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.

View File

@ -0,0 +1,111 @@
# SeemsRateable
Star rating gem for Rails application using jQuery plugin <a href="http://www.myjqueryplugins.com/jquery-plugin/jrating">jRating</a>
## Demo
<a href="http://rateable.herokuapp.com/">Demo</a> application, requires to sign up before rating
## Instructions
### Installation
Add this line to your application's Gemfile:
gem 'seems_rateable'
And then execute:
$ bundle
Or install it yourself as:
$ gem install seems_rateable
### Generation
$ rails generate seems_rateable:install
Generator creates migration files, javascript files and initializer
### Prepare
Require javascript files by adding this line to application.js
#application.js
//= require_directory ./rateable
Add <code>seems_rateable</code> to routes.rb file
Include stylesheet adding <code><%= seems_rateable_stylesheet %></code> to your layout header
Also make sure you have an existing <code>current_user</code> helper method
Don't forget to run
$ rake db:migrate
To prepare model add <code> seems_rateable </code> to your rateable model file. You can also pass a hash of options to
customize the functionality
<ul>
<li><code>:dimensions</code>Array of dimensions e.g <code>:dimensions => [:quality, :quantity]</code> </li>
<li><code>:allow_update</code>Allowing user to re-rate his own ratings, default set to false e.g <code>:allow_update=> true</code></li>
</ul>
class Post < ActiveRecord::Base
seems_rateable :allow_update => true, :dimensions => [:quality, :length]
end
To access object's rates use <code>rates</code> method, to get dimension rates pass an argument eg :
@object.rates
@object.rates(:quality)
@object.rates(:quantity)
This also applies to cached average rating e.g
@object.average
@object.average(:quality)
@object.average(:quantity)
And to object's raters e.g
@object.raters
@object.raters(:quality)
@object.raters(:quantity)
To track user's given ratings add <code>seems_rateable_rater</code> to your rater model.
If your rater class is not "User"(e.g "Client" or "Customer") change configuration in initializer generated by this engine.
Now you can access user's ratings by <code>@user.ratings_given</code>
### Usage
To display star rating use helper method <code>rating_for</code> in your view
#index.html.erb
rating_for @post
rating_for @post, :dimension => :quality, :class => 'post', :id => 'list'
rating_for @post, :static => true
You can specify these options :
<ul>
<li><code>:dimension</code>The dimension of the object</li>
<li><code>:static</code>Set to true to display static star rating, default false</li>
<li><code>:class</code>Class of the div, default set to 'rateable'</li>
<li><code>:id</code>ID of the div e.g <code>:id => "info"</code>, default nil</li>
</ul>
To edit the javascript options locate rateable.js file in /app/assets/javascripts/rateable/.
The javascript options are explained directly in the file
## Contributing
1. Fork it
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create new Pull Request

View File

@ -0,0 +1,32 @@
begin
require 'bundler/setup'
rescue LoadError
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
end
require 'rdoc/task'
RDoc::Task.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = 'SeemsRateable'
rdoc.options << '--line-numbers'
rdoc.rdoc_files.include('README.rdoc')
rdoc.rdoc_files.include('lib/**/*.rb')
end
APP_RAKEFILE = File.expand_path("../spec/dummy/Rakefile", __FILE__)
load 'rails/tasks/engine.rake'
Bundler::GemHelper.install_tasks
APP_RAKEFILE = File.expand_path("../spec/dummy/Rakefile", __FILE__)
load 'rails/tasks/engine.rake'
Dir[File.join(File.dirname(__FILE__), 'tasks/**/*.rake')].each {|f| load f }
require 'rspec/core'
require 'rspec/core/rake_task'
task :default => :spec

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 B

View File

@ -0,0 +1,15 @@
// This is a manifest file that'll be compiled into application.js, which will include all the files
// listed below.
//
// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
// or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path.
//
// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
// compiled file.
//
// Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details
// about supported directives.
//= require_tree .

View File

@ -0,0 +1,62 @@
/*
* This is a manifest file that'll be compiled into application.css, which will include all the files
* listed below.
*
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
* or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path.
*
* You're free to add application-wide styles to this file and they'll appear at the top of the
* compiled file, but it's generally better to create a new file per style scope.
*
*= require_self
*= require_tree .
*/
/*********************/
/** jRating CSS **/
/*********************/
/**Div containing the color of the stars */
.jRatingAverage {
background-color:#f62929;
position:relative;
top:0;
left:0;
z-index:2;
height:100%;
}
.jRatingColor {
background-color:#FFD400; /* bgcolor of the stars*/
position:relative;
top:0;
left:0;
z-index:2;
height:100%;
}
/** Div containing the stars **/
.jStar {
position:relative;
left:0;
z-index:3;
}
/** P containing the rate informations **/
p.jRatingInfos {
position: absolute;
z-index:9999;
background: transparent url('bg_jRatingInfos.png') no-repeat;
color: #CACACA;
display: none;
width: 91px;
height: 29px;
font-size:16px;
text-align:center;
padding-top:5px;
}
p.jRatingInfos span.maxRate {
color:#c9c9c9;
font-size:14px;
}

View File

@ -0,0 +1,4 @@
module SeemsRateable
class ApplicationController < ActionController::Base
end
end

View File

@ -0,0 +1,17 @@
require_dependency "seems_rateable/application_controller"
module SeemsRateable
class RatingsController < ::ApplicationController
def create
raise NoCurrentUserInstanceError unless current_user
obj = params[:kls].classify.constantize.find(params[:idBox])
begin
obj.rate(params[:rate].to_i, current_user.id, params[:dimension])
render :json => true
rescue Errors::AlreadyRatedError
render :json => {:error => true}
end
end
end
end

View File

@ -0,0 +1,4 @@
module SeemsRateable
module ApplicationHelper
end
end

View File

@ -0,0 +1,4 @@
module SeemsRateable
module RatingsHelper
end
end

View File

@ -0,0 +1,5 @@
module SeemsRateable
class CachedRating < ActiveRecord::Base
belongs_to :cacheable, :polymorphic => true
end
end

View File

@ -0,0 +1,6 @@
module SeemsRateable
class Rate < ActiveRecord::Base
belongs_to :rater, :class_name => SeemsRateable::Engine.config.owner_class
belongs_to :rateable, :polymorphic => true
end
end

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>SeemsRateable</title>
<%= stylesheet_link_tag "seems_rateable/application", media: "all" %>
<%= javascript_include_tag "seems_rateable/application" %>
<%= csrf_meta_tags %>
</head>
<body>
<%= yield %>
</body>
</html>

View File

@ -0,0 +1,8 @@
#!/usr/bin/env ruby1.9.1
# This command will automatically be run when you run "rails" with Rails 4 gems installed from the root of your application.
ENGINE_ROOT = File.expand_path('../..', __FILE__)
ENGINE_PATH = File.expand_path('../../lib/seems_rateable/engine', __FILE__)
require 'rails/all'
require 'rails/engine/commands'

View File

@ -0,0 +1,3 @@
SeemsRateable::Engine.routes.draw do
resources :ratings, :only => :create
end

View File

@ -0,0 +1,39 @@
require 'rails/generators/migration'
require 'fileutils'
module SeemsRateable
module Generators
class InstallGenerator < ::Rails::Generators::Base
include Rails::Generators::Migration
source_root File.expand_path('../templates', __FILE__)
def self.next_migration_number(path)
unless @prev_migration_nr
@prev_migration_nr = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
else
@prev_migration_nr += 1
end
@prev_migration_nr.to_s
end
desc "generating migration files"
def copy_migrations
migration_template "rates_migration.rb", "db/migrate/create_seems_rateable_rates.rb"
migration_template "cached_ratings_migration.rb", "db/migrate/create_seems_rateable_cached_ratings.rb"
end
desc "generating initializer"
def copy_initializer
template "initializer.rb", "config/initializers/seems_rateable.rb"
end
desc "generating javascript files"
def copy_javascript_asset
Dir.mkdir "app/assets/javascripts/rateable" unless File.directory?("app/assets/javascripts/rateable")
copy_file "rateable.js.erb", "app/assets/javascripts/rateable/rateable.js.erb" unless File.exists?("app/assets/javascripts/rateable/rateable.js.erb")
copy_file "jRating.js.erb", "app/assets/javascripts/rateable/jRating.js.erb" unless File.exists?("app/assets/javascripts/rateable/jRating.js.erb")
end
end
end
end

View File

@ -0,0 +1,17 @@
class CreateSeemsRateableCachedRatings < ActiveRecord::Migration
def self.up
create_table :seems_rateable_cached_ratings do |t|
t.belongs_to :cacheable, :polymorphic => true
t.float :avg, :null => false
t.integer :cnt, :null => false
t.string :dimension
t.integer :cacheable_id, :limit => 8
t.string :cacheable_type
t.timestamps
end
end
def self.down
drop_table :cached_ratings
end
end

View File

@ -0,0 +1,4 @@
#SeemsRateable engine Initializer
#Configure owner class of the given ratings
SeemsRateable::Engine.config.owner_class = "User"

View File

@ -0,0 +1,225 @@
/************************************************************************
*************************************************************************
@Name : jRating - jQuery Plugin
@Revison : 3.0
@Date : 28/01/2013
@Author: ALPIXEL - (www.myjqueryplugins.com - www.alpixel.fr)
@License : Open Source - MIT License : http://www.opensource.org/licenses/mit-license.php
**************************************************************************
*************************************************************************/
(function($) {
$.fn.jRating = function(op) {
var defaults = {
/** String vars **/
bigStarsPath : '<%= image_path "seems_rateable/stars.png" %>', // path of the icon stars.png
smallStarsPath : '<%= image_path "seems_rateable/small.png" %>', // path of the icon small.png
path : '<%= SeemsRateable::Engine.routes.url_helpers.ratings_path %>',
type : 'big', // can be set to 'small' or 'big'
/** Boolean vars **/
step:false, // if true, mouseover binded star by star,
isDisabled:false,
showRateInfo: false,
canRateAgain : false,
/** Integer vars **/
length:5, // number of star to display
decimalLength : 0, // number of decimals.. Max 3, but you can complete the function 'getNote'
rateMax : 20, // maximal rate - integer from 0 to 9999 (or more)
rateInfosX : -45, // relative position in X axis of the info box when mouseover
rateInfosY : 5, // relative position in Y axis of the info box when mouseover
nbRates : 1,
/** Functions **/
onSuccess : null,
onError : null
};
if(this.length>0)
return this.each(function() {
/*vars*/
var opts = $.extend(defaults, op),
newWidth = 0,
starWidth = 0,
starHeight = 0,
bgPath = '',
hasRated = false,
globalWidth = 0,
nbOfRates = opts.nbRates;
if($(this).hasClass('jDisabled') || opts.isDisabled)
var jDisabled = true;
else
var jDisabled = false;
getStarWidth();
$(this).height(starHeight);
var average = parseFloat($(this).attr('data-average')), // get the average of all rates
idBox = parseInt($(this).attr('data-id')), // get the id of the box
kls = $(this).attr('data-kls'),
dimension = $(this).attr('data-dimension'),
widthRatingContainer = starWidth*opts.length, // Width of the Container
widthColor = average/opts.rateMax*widthRatingContainer, // Width of the color Container
quotient =
$('<div>',
{
'class' : 'jRatingColor',
css:{
width:widthColor
}
}).appendTo($(this)),
average =
$('<div>',
{
'class' : 'jRatingAverage',
css:{
width:0,
top:- starHeight
}
}).appendTo($(this)),
jstar =
$('<div>',
{
'class' : 'jStar',
css:{
width:widthRatingContainer,
height:starHeight,
top:- (starHeight*2),
background: 'url('+bgPath+') repeat-x'
}
}).appendTo($(this));
$(this).css({width: widthRatingContainer,overflow:'hidden',zIndex:1,position:'relative'});
if(!jDisabled)
$(this).unbind().bind({
mouseenter : function(e){
var realOffsetLeft = findRealLeft(this);
var relativeX = e.pageX - realOffsetLeft;
if (opts.showRateInfo)
var tooltip =
$('<p>',{
'class' : 'jRatingInfos',
html : getNote(relativeX)+' <span class="maxRate">/ '+opts.rateMax+'</span>',
css : {
top: (e.pageY + opts.rateInfosY),
left: (e.pageX + opts.rateInfosX)
}
}).appendTo('body').show();
},
mouseover : function(e){
$(this).css('cursor','pointer');
},
mouseout : function(){
$(this).css('cursor','default');
if(hasRated) average.width(globalWidth);
else average.width(0);
},
mousemove : function(e){
var realOffsetLeft = findRealLeft(this);
var relativeX = e.pageX - realOffsetLeft;
if(opts.step) newWidth = Math.floor(relativeX/starWidth)*starWidth + starWidth;
else newWidth = relativeX;
average.width(newWidth);
if (opts.showRateInfo)
$("p.jRatingInfos")
.css({
left: (e.pageX + opts.rateInfosX)
})
.html(getNote(newWidth) +' <span class="maxRate">/ '+opts.rateMax+'</span>');
},
mouseleave : function(){
$("p.jRatingInfos").remove();
},
click : function(e){
var element = this;
/*set vars*/
hasRated = true;
globalWidth = newWidth;
nbOfRates--;
if(!opts.canRateAgain || parseInt(nbOfRates) <= 0) $(this).unbind().css('cursor','default').addClass('jDisabled');
if (opts.showRateInfo) $("p.jRatingInfos").fadeOut('fast',function(){$(this).remove();});
e.preventDefault();
var rate = getNote(newWidth);
average.width(newWidth);
$.post(defaults.path,
{
idBox : idBox,
rate : rate,
kls : kls,
dimension : dimension
/** action : 'rating' **/
},
function(data) {
if(!data.error)
{
/** Here you can display an alert box,
or use the jNotify Plugin :) http://www.myqjqueryplugins.com/jNotify
exemple : */
if(opts.onSuccess) opts.onSuccess( element, rate );
}
else
{
/** Here you can display an alert box,
or use the jNotify Plugin :) http://www.myqjqueryplugins.com/jNotify
exemple : */
if(opts.onError) opts.onError( element, rate );
}
},
'json'
);
}
});
function getNote(relativeX) {
var noteBrut = parseFloat((relativeX*100/widthRatingContainer)*opts.rateMax/100);
switch(opts.decimalLength) {
case 1 :
var note = Math.round(noteBrut*10)/10;
break;
case 2 :
var note = Math.round(noteBrut*100)/100;
break;
case 3 :
var note = Math.round(noteBrut*1000)/1000;
break;
default :
var note = Math.round(noteBrut*1)/1;
}
return note;
};
function getStarWidth(){
switch(opts.type) {
case 'small' :
starWidth = 12; // width of the picture small.png
starHeight = 10; // height of the picture small.png
bgPath = opts.smallStarsPath;
break;
default :
starWidth = 23; // width of the picture stars.png
starHeight = 20; // height of the picture stars.png
bgPath = opts.bigStarsPath;
}
};
function findRealLeft(obj) {
if( !obj ) return 0;
return obj.offsetLeft + findRealLeft( obj.offsetParent );
};
});
}
})(jQuery);

View File

@ -0,0 +1,25 @@
$(document).ready(function(){
$(".rateable").jRating({
//default options displayed below ->
rateMax: 5, //Maximal rate
length : 5, //Number of stars
//decimalLength : 0, //Number of decimals in the rate
//type : 'big', //Big or small
//step : true, //If set to true, filling of the stars is done star by star (step by step).
//isDisabled: false, //Set true to display static rating
//showRateInfo:false, //Rate info panel, set true to display
//rateInfosX : 45, //In pixel - Absolute left position of the information box during mousemove.
//rateInfosY : 5, //In pixel - Absolute top position of the information box during mousemove.
path : '<%= SeemsRateable::Engine.routes.url_helpers.ratings_path %>',
onSuccess : function(element, rate){
//something like ->
//alert('success');
$('<span class="text-success"><small style="display:inline-block;">Thanks for rating!</small></span>').insertAfter(element)
},
onError : function(element, rate) {
$('<span class="text-error"><small style="display:inline-block;">You have already rated!</small></span>').insertAfter(element)
}
});
});

View File

@ -0,0 +1,18 @@
class CreateSeemsRateableRates < ActiveRecord::Migration
def self.up
create_table :seems_rateable_rates do |t|
t.belongs_to :rater
t.belongs_to :rateable, :polymorphic => true
t.float :stars, :null => false
t.integer :rater_id, :limit => 8
t.integer :rateable_id
t.string :rateable_type
t.string :dimension
t.timestamps
end
end
def self.down
drop_table :rates
end
end

View File

@ -0,0 +1,14 @@
begin
require 'rails'
rescue LoadError
end
require "seems_rateable/engine"
require "seems_rateable/errors"
require "seems_rateable/helpers"
require "seems_rateable/model"
require "seems_rateable/routes"
require "seems_rateable/version"
module SeemsRateable
end

View File

@ -0,0 +1,17 @@
module SeemsRateable
class Engine < ::Rails::Engine
isolate_namespace SeemsRateable
config.generators do |g|
g.test_framework :rspec, :fixture => false
g.fixture_replacement :factory_girl, :dir => 'spec/factories'
end
initializer :seems_rateable do
ActiveRecord::Base.send :include, SeemsRateable::Model
ActionView::Base.send :include, SeemsRateable::Helpers
ActionDispatch::Routing::Mapper.send :include, SeemsRateable::Routes
end
end
end

View File

@ -0,0 +1,21 @@
module SeemsRateable
module Errors
class InvalidRateableObjectError < StandardError
def to_s
"Stated object is not rateable. Add 'seems_rateable' to your object's class model."
end
end
class NoCurrentUserInstanceError < StandardError
def to_s
"User instance current_user is not available."
end
end
class AlreadyRatedError < StandardError
def to_s
"User has already rated an object."
end
end
end
end

View File

@ -0,0 +1,27 @@
module SeemsRateable
module Helpers
def rating_for(obj, opts={})
raise Errors::InvalidRateableObjectError unless obj.class.respond_to?(:rateable?)
options = {
:dimension => nil,
:static => false,
:class => 'rateable',
:id => nil
}.update(opts)
content_tag :div, "", "data-average" => obj.average(options[:dimension]) ? obj.average(options[:dimension]).avg : 0, :id => options[:id],
:class => "#{options[:class]}#{jdisabled?(options[:static])}",
"data-id" => obj.id, "data-kls" => obj.class.name, "data-dimension" => options[:dimension]
end
def seems_rateable_stylesheet
stylesheet_link_tag "seems_rateable/application", media: "all", "data-turbolinks-track" => true
end
private
def jdisabled?(option)
" jDisabled" if option || !current_user
end
end
end

View File

@ -0,0 +1,111 @@
require 'active_support/concern'
module SeemsRateable
module Model
extend ActiveSupport::Concern
def rate(stars, user_id, dimension=nil)
if !has_rated?(user_id, dimension)
self.rates.create do |r|
r.stars = stars
r.rater_id = user_id
end
update_overall_average_rating(stars, dimension)
elsif has_rated?(user_id, dimension) && can_update?
update_users_rating(stars, user_id, dimension)
else
raise Errors::AlreadyRatedError
end
end
def update_overall_average_rating(stars, dimension=nil)
if average(dimension).nil?
CachedRating.create do |r|
r.avg = stars
r.dimension = dimension
r.cacheable_id = self.id
r.cacheable_type = self.class.name
r.cnt = 1
end
else
r = average(dimension)
r.avg = (r.avg * r.cnt + stars) / (r.cnt+1)
r.cnt += 1
r.save!
end
end
def update_users_rating(stars, user_id, dimension=nil)
obj = rates(dimension).where(:rater_id => user_id).first
current_record = average(dimension)
current_record.avg = (current_record.avg*current_record.cnt - obj.stars + stars) / (current_record.cnt)
current_record.save!
obj.stars = stars
obj.save!
end
def average(dimension=nil)
if dimension.nil?
self.send "rate_average_without_dimension"
else
self.send "#{dimension}_average"
end
end
def rates(dimension=nil)
if dimension.nil?
self.send "rates_without_dimension"
else
self.send "#{dimension}_rates"
end
end
def raters(dimension=nil)
if dimension.nil?
self.send "raters_without_dimension"
else
self.send "#{dimension}_raters"
end
end
def has_rated?(user_id, dimension=nil)
record = self.rates(dimension).where(:rater_id => user_id)
record.empty? ? false : true
end
def can_update?
self.class.can_update?
end
module ClassMethods
def seems_rateable(opts={})
#has_many :rates_without_dimension, -> { where(dimension: nil) }, :as => :rateable, :class_name => SeemsRateable::Rate, :dependent => :destroy
has_many :rates_without_dimension, :conditions => { dimension: nil }, :as => :rateable, :class_name => SeemsRateable::Rate, :dependent => :destroy
has_many :raters_without_dimension, :through => :rates_without_dimension, :source => :rater
has_one :rate_average_without_dimension, :conditions => { dimension: nil }, :as => :cacheable, :class_name => SeemsRateable::CachedRating, :dependent => :destroy
@permission = opts[:allow_update] ? true : false
def self.can_update?
@permission
end
def self.rateable?
true
end
if opts[:dimensions].is_a?(Array)
opts[:dimensions].each do |dimension|
has_many :"#{dimension}_rates", :conditions => { dimension: dimension.to_s }, :dependent => :destroy, :class_name => SeemsRateable::Rate, :as => :rateable
has_many :"#{dimension}_raters", :through => :"#{dimension}_rates", :source => :rater
has_one :"#{dimension}_average", :conditions => { dimension: dimension.to_s }, :as => :cacheable, :class_name => SeemsRateable::CachedRating, :dependent => :destroy
end
end
end
def seems_rateable_rater
has_many :ratings_given, :class_name => SeemsRateable::Rate, :foreign_key => :rater_id
end
end
end
end

View File

@ -0,0 +1,7 @@
module SeemsRateable
module Routes
def seems_rateable
mount SeemsRateable::Engine => '/rateable', :as => :rateable
end
end
end

View File

@ -0,0 +1,3 @@
module SeemsRateable
VERSION = "1.0.9"
end

View File

@ -0,0 +1,4 @@
# desc "Explaining what the task does"
# task :seems_rateable do
# # Task goes here
# end

View File

@ -0,0 +1,25 @@
$:.push File.expand_path("../lib", __FILE__)
# Maintain your gem's version:
require "seems_rateable/version"
# Describe your gem and declare its dependencies:
Gem::Specification.new do |s|
s.name = "seems_rateable"
s.version = SeemsRateable::VERSION
s.authors = ["Peter Toth"]
s.email = ["proximin@gmail.com"]
s.homepage = "http://rateable.herokuapp.com"
s.summary = "Star Rating Engine"
s.description = "Star rating engine using jQuery plugin jRating for Rails applications"
s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"]
s.add_dependency "rails"
s.add_dependency "jquery-rails"
s.add_development_dependency "sqlite3"
s.add_development_dependency 'rspec-rails'
s.add_development_dependency 'capybara'
s.add_development_dependency 'factory_girl_rails'
end

BIN
public/images/praise.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
public/images/tread.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB