├── app ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── concerns │ │ ├── .keep │ │ └── votable.rb │ ├── court.rb │ ├── comment.rb │ ├── annotation.rb │ ├── judge.rb │ ├── suggestion.rb │ ├── vote.rb │ ├── opinion.rb │ └── user.rb ├── assets │ ├── images │ │ ├── .keep │ │ ├── favicon.ico │ │ ├── scales.png │ │ └── icons │ │ │ ├── thumb-down-button.svg │ │ │ ├── thumb-up-button.svg │ │ │ ├── thumb-down-button-selected.svg │ │ │ └── thumb-up-button-selected.svg │ ├── stylesheets │ │ ├── api │ │ │ ├── courts.scss │ │ │ ├── judges.scss │ │ │ ├── users.scss │ │ │ ├── comments.scss │ │ │ ├── opinions.scss │ │ │ ├── sessions.scss │ │ │ ├── suggestions.scss │ │ │ └── annotations.scss │ │ ├── annotation_form_detail.scss │ │ ├── application.css │ │ ├── vote.scss │ │ ├── index.scss │ │ ├── comments.scss │ │ ├── reset.scss │ │ ├── home.scss │ │ ├── suggestion_form_index.scss │ │ ├── opinion_detail.scss │ │ ├── header.scss │ │ ├── modal.scss │ │ └── opinion_create_form.scss │ └── javascripts │ │ ├── api │ │ ├── courts.coffee │ │ ├── judges.coffee │ │ ├── users.coffee │ │ ├── annotations.coffee │ │ ├── comments.coffee │ │ ├── opinions.coffee │ │ ├── sessions.coffee │ │ └── suggestions.coffee │ │ ├── annotations.coffee │ │ └── application.js ├── controllers │ ├── concerns │ │ └── .keep │ ├── static_pages_controller.rb │ ├── api │ │ ├── courts_controller.rb │ │ ├── judges_controller.rb │ │ ├── users_controller.rb │ │ ├── sessions_controller.rb │ │ ├── comments_controller.rb │ │ ├── opinions_controller.rb │ │ ├── suggestions_controller.rb │ │ └── annotations_controller.rb │ └── application_controller.rb ├── views │ ├── api │ │ ├── opinions │ │ │ ├── _opinion.json.builder │ │ │ ├── index.json.jbuilder │ │ │ └── show.json.jbuilder │ │ ├── users │ │ │ ├── _user.json.jbuilder │ │ │ └── show.json.jbuilder │ │ ├── courts │ │ │ └── index.json.jbuilder │ │ ├── judges │ │ │ └── index.json.jbuilder │ │ ├── comments │ │ │ └── show.json.jbuilder │ │ ├── suggestions │ │ │ └── show.json.jbuilder │ │ └── annotations │ │ │ └── show.json.jbuilder │ ├── static_pages │ │ └── root.html.erb │ └── layouts │ │ └── application.html.erb └── helpers │ ├── api │ ├── users_helper.rb │ ├── comments_helper.rb │ ├── courts_helper.rb │ ├── judges_helper.rb │ ├── opinions_helper.rb │ ├── sessions_helper.rb │ ├── annotations_helper.rb │ └── suggestions_helper.rb │ ├── annotations_helper.rb │ └── application_helper.rb ├── lib ├── assets │ └── .keep └── tasks │ └── .keep ├── public ├── favicon.ico ├── robots.txt ├── 500.html ├── 422.html └── 404.html ├── vendor └── assets │ ├── javascripts │ └── .keep │ └── stylesheets │ └── .keep ├── .rspec ├── frontend ├── util │ ├── selectors.js │ ├── comment_api_util.js │ ├── session_api_util.js │ ├── suggestion_api_util.js │ ├── annotation_api_util.js │ ├── annotation_format.js │ ├── date.js │ ├── vote_api_util.js │ └── opinion_api_util.js ├── actions │ ├── general_actions.js │ ├── search_actions.js │ ├── comment_actions.js │ ├── session_actions.js │ ├── suggestion_actions.js │ ├── annotation_actions.js │ ├── vote_actions.js │ └── opinion_actions.js ├── components │ ├── loader.jsx │ ├── app.jsx │ ├── header │ │ ├── thumb.jsx │ │ ├── header_container.js │ │ └── header.jsx │ ├── opinions │ │ ├── opinion_detail_panel.jsx │ │ ├── opinion_create_form_container.js │ │ ├── opinion_index_container.js │ │ ├── opinion_detail_container.js │ │ ├── opinion_index_item.jsx │ │ ├── opinion_detail_header.jsx │ │ ├── opinion_index.jsx │ │ ├── opinion_detail_body_container.js │ │ └── opinion_detail.jsx │ ├── home.jsx │ ├── comments │ │ ├── comment_index_container.js │ │ ├── comment_form_container.jsx │ │ ├── comment_index.jsx │ │ └── comment_form.jsx │ ├── suggestions │ │ ├── suggestion_form_container.js │ │ ├── suggestion_index.jsx │ │ ├── suggestion_item_container.js │ │ ├── suggestion_item.jsx │ │ └── suggestion_form.jsx │ ├── search │ │ ├── search_container.js │ │ └── search.jsx │ ├── session_form │ │ ├── modal_style.js │ │ ├── modal_session_form_container.js │ │ ├── modal_wrapper.jsx │ │ └── modal_session_form.jsx │ ├── annotations │ │ ├── annotation_form_container.js │ │ ├── annotation_detail_container.js │ │ ├── annotation_form.jsx │ │ └── annotation_detail.jsx │ ├── votes │ │ ├── vote.jsx │ │ └── vote_container.js │ └── root.jsx ├── middleware │ └── thunk.js ├── reducers │ ├── session_reducer.js │ ├── opinions_reducer.js │ ├── search_reducer.js │ ├── root_reducer.js │ ├── errors_reducer.js │ ├── annotation_detail_reducer.js │ └── opinion_detail_reducer.js ├── store │ └── store.js └── casenote.jsx ├── docs ├── wireframes │ ├── add-case.png │ ├── sign-in-up.png │ ├── home-logged-in.png │ ├── home-logged-out.png │ ├── opinion-index.png │ ├── opinion-nothing-selected.png │ ├── opinion-annotation-selected.png │ └── opinion-clear-text-selected.png ├── screenshots │ ├── add-opinion.png │ ├── home-screen.png │ ├── index-view.png │ ├── comment-view.png │ ├── session-modal.png │ ├── annotation-detail.png │ ├── opinion-detail-view.png │ ├── annotation-form-full.png │ ├── annotation-form-start.png │ ├── autocomplete-search-1.png │ └── annotation-detail-suggestion.png ├── api-endpoints.md ├── component-hierarchy.md ├── README.md ├── sample-state.md └── schema.md ├── bin ├── bundle ├── rake ├── rails ├── spring └── setup ├── db └── migrate │ ├── 20161208230047_edit_judges_table_attempt2.rb │ ├── 20161208230233_edit_judges_table_attempt3.rb │ ├── 20161208225858_edit_judges_table.rb │ ├── 20161208224424_create_judges.rb │ ├── 20161208191643_add_default_images.rb │ ├── 20161213015900_change_body_requirement_on_suggestions.rb │ ├── 20161208194805_edit_default_image_url_part_two.rb │ ├── 20161208230815_add_images_attachment_to_judges.rb │ ├── 20161209155701_add_indices.rb │ ├── 20161208192243_edit_default_image_url.rb │ ├── 20161209155436_change_court_column_in_opinions.rb │ ├── 20161208231705_create_courts.rb │ ├── 20161215172000_add_attachment_avatar_to_users.rb │ ├── 20161209035027_change_judges_column_in_users_table.rb │ ├── 20161209160536_add_attachment_image_to_opinions.rb │ ├── 20161213195414_create_comments.rb │ ├── 20161206155825_create_users.rb │ ├── 20161215011753_create_votes.rb │ ├── 20161212221933_create_suggestions.rb │ ├── 20161210194437_create_annotations.rb │ └── 20161207154724_create_opinions.rb ├── config ├── boot.rb ├── initializers │ ├── cookies_serializer.rb │ ├── session_store.rb │ ├── mime_types.rb │ ├── filter_parameter_logging.rb │ ├── backtrace_silencers.rb │ ├── assets.rb │ ├── wrap_parameters.rb │ └── inflections.rb ├── environment.rb ├── locales │ └── en.yml ├── routes.rb ├── secrets.yml ├── environments │ ├── development.rb │ ├── test.rb │ └── production.rb ├── application.rb └── database.yml ├── spec ├── controllers │ ├── annotations_controller_spec.rb │ ├── api │ │ ├── comments_controller_spec.rb │ │ ├── courts_controller_spec.rb │ │ ├── judges_controller_spec.rb │ │ ├── opinions_controller_spec.rb │ │ └── annotations_controller_spec.rb │ └── suggestions_controller_spec.rb ├── factories.rb ├── models │ ├── court_spec.rb │ ├── comment_spec.rb │ ├── vote_spec.rb │ ├── suggestion_spec.rb │ ├── judge_spec.rb │ ├── annotation_spec.rb │ └── opinion_spec.rb ├── rails_helper.rb └── spec_helper.rb ├── config.ru ├── Rakefile ├── webpack.config.js ├── .gitignore ├── webpack.config.prod.js ├── package.json ├── Gemfile └── README.md /app/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/assets/stylesheets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /app/views/api/opinions/_opinion.json.builder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/helpers/api/users_helper.rb: -------------------------------------------------------------------------------- 1 | module Api::UsersHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/annotations_helper.rb: -------------------------------------------------------------------------------- 1 | module AnnotationsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/api/comments_helper.rb: -------------------------------------------------------------------------------- 1 | module Api::CommentsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/api/courts_helper.rb: -------------------------------------------------------------------------------- 1 | module Api::CourtsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/api/judges_helper.rb: -------------------------------------------------------------------------------- 1 | module Api::JudgesHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/api/opinions_helper.rb: -------------------------------------------------------------------------------- 1 | module Api::OpinionsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/api/sessions_helper.rb: -------------------------------------------------------------------------------- 1 | module Api::SessionsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/api/annotations_helper.rb: -------------------------------------------------------------------------------- 1 | module Api::AnnotationsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/api/suggestions_helper.rb: -------------------------------------------------------------------------------- 1 | module Api::SuggestionsHelper 2 | end 3 | -------------------------------------------------------------------------------- /frontend/util/selectors.js: -------------------------------------------------------------------------------- 1 | export const toArray = (obj) => Object.values(obj); 2 | -------------------------------------------------------------------------------- /app/controllers/static_pages_controller.rb: -------------------------------------------------------------------------------- 1 | class StaticPagesController < ApplicationController 2 | end 3 | -------------------------------------------------------------------------------- /app/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/app/assets/images/favicon.ico -------------------------------------------------------------------------------- /app/assets/images/scales.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/app/assets/images/scales.png -------------------------------------------------------------------------------- /docs/wireframes/add-case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/wireframes/add-case.png -------------------------------------------------------------------------------- /docs/wireframes/sign-in-up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/wireframes/sign-in-up.png -------------------------------------------------------------------------------- /app/views/api/users/_user.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.extract! user, :id, :username 2 | json.thumb user.avatar.url(:thumb) 3 | -------------------------------------------------------------------------------- /app/views/api/users/show.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.extract! @user, :id, :username 2 | json.thumb @user.avatar.url(:thumb) 3 | -------------------------------------------------------------------------------- /docs/screenshots/add-opinion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/screenshots/add-opinion.png -------------------------------------------------------------------------------- /docs/screenshots/home-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/screenshots/home-screen.png -------------------------------------------------------------------------------- /docs/screenshots/index-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/screenshots/index-view.png -------------------------------------------------------------------------------- /docs/screenshots/comment-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/screenshots/comment-view.png -------------------------------------------------------------------------------- /docs/screenshots/session-modal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/screenshots/session-modal.png -------------------------------------------------------------------------------- /docs/wireframes/home-logged-in.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/wireframes/home-logged-in.png -------------------------------------------------------------------------------- /docs/wireframes/home-logged-out.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/wireframes/home-logged-out.png -------------------------------------------------------------------------------- /docs/wireframes/opinion-index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/wireframes/opinion-index.png -------------------------------------------------------------------------------- /app/views/api/courts/index.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.array! @courts do |court| 2 | json.id court.id 3 | json.name court.name 4 | end 5 | -------------------------------------------------------------------------------- /app/views/api/judges/index.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.array! @judges do |judge| 2 | json.id judge.id 3 | json.name judge.name 4 | end 5 | -------------------------------------------------------------------------------- /docs/screenshots/annotation-detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/screenshots/annotation-detail.png -------------------------------------------------------------------------------- /docs/screenshots/opinion-detail-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/screenshots/opinion-detail-view.png -------------------------------------------------------------------------------- /docs/screenshots/annotation-form-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/screenshots/annotation-form-full.png -------------------------------------------------------------------------------- /docs/screenshots/annotation-form-start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/screenshots/annotation-form-start.png -------------------------------------------------------------------------------- /docs/screenshots/autocomplete-search-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/screenshots/autocomplete-search-1.png -------------------------------------------------------------------------------- /docs/wireframes/opinion-nothing-selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/wireframes/opinion-nothing-selected.png -------------------------------------------------------------------------------- /docs/wireframes/opinion-annotation-selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/wireframes/opinion-annotation-selected.png -------------------------------------------------------------------------------- /docs/wireframes/opinion-clear-text-selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/wireframes/opinion-clear-text-selected.png -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /db/migrate/20161208230047_edit_judges_table_attempt2.rb: -------------------------------------------------------------------------------- 1 | class EditJudgesTableAttempt2 < ActiveRecord::Migration 2 | def change 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /docs/screenshots/annotation-detail-suggestion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amytfang/CaseNote/HEAD/docs/screenshots/annotation-detail-suggestion.png -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 2 | 3 | require 'bundler/setup' # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /spec/controllers/annotations_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe AnnotationsController, type: :controller do 4 | 5 | end 6 | -------------------------------------------------------------------------------- /spec/controllers/api/comments_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Api::CommentsController, type: :controller do 4 | 5 | end 6 | -------------------------------------------------------------------------------- /spec/controllers/api/courts_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Api::CourtsController, type: :controller do 4 | 5 | end 6 | -------------------------------------------------------------------------------- /spec/controllers/api/judges_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Api::JudgesController, type: :controller do 4 | 5 | end 6 | -------------------------------------------------------------------------------- /spec/controllers/api/opinions_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Api::OpinionsController, type: :controller do 4 | 5 | end 6 | -------------------------------------------------------------------------------- /spec/controllers/suggestions_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe SuggestionsController, type: :controller do 4 | 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20161208230233_edit_judges_table_attempt3.rb: -------------------------------------------------------------------------------- 1 | class EditJudgesTableAttempt3 < ActiveRecord::Migration 2 | def change 3 | 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /frontend/actions/general_actions.js: -------------------------------------------------------------------------------- 1 | export const CLEAR_ERRORS = "CLEAR_ERRORS"; 2 | 3 | export const clearErrors = () => ({ 4 | type: CLEAR_ERRORS 5 | }); 6 | -------------------------------------------------------------------------------- /spec/controllers/api/annotations_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Api::AnnotationsController, type: :controller do 4 | 5 | end 6 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /app/controllers/api/courts_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::CourtsController < ApplicationController 2 | def index 3 | @courts = Court.all 4 | render :index 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/controllers/api/judges_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::JudgesController < ApplicationController 2 | def index 3 | @judges = Judge.all 4 | render :index 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.action_dispatch.cookies_serializer = :json 4 | -------------------------------------------------------------------------------- /config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: '_CaseNote_session' 4 | -------------------------------------------------------------------------------- /frontend/components/loader.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Loader = () => { 4 | return ( 5 |
); 6 | }; 7 | 8 | export default Loader; 9 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /db/migrate/20161208225858_edit_judges_table.rb: -------------------------------------------------------------------------------- 1 | class EditJudgesTable < ActiveRecord::Migration 2 | def change 3 | add_column :judges, :name, :string, null: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /db/migrate/20161208224424_create_judges.rb: -------------------------------------------------------------------------------- 1 | class CreateJudges < ActiveRecord::Migration 2 | def change 3 | create_table :judges do |t| 4 | 5 | t.timestamps null: false 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20161208191643_add_default_images.rb: -------------------------------------------------------------------------------- 1 | class AddDefaultImages < ActiveRecord::Migration 2 | def change 3 | change_column :opinions, :img_url, :string, :default => "assets/images/scotus.jpg" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/assets/stylesheets/api/courts.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the api/courts controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | -------------------------------------------------------------------------------- /app/assets/stylesheets/api/judges.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the api/judges controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | -------------------------------------------------------------------------------- /app/assets/stylesheets/api/users.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the api/users controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | -------------------------------------------------------------------------------- /db/migrate/20161213015900_change_body_requirement_on_suggestions.rb: -------------------------------------------------------------------------------- 1 | class ChangeBodyRequirementOnSuggestions < ActiveRecord::Migration 2 | def change 3 | change_column_null :suggestions, :body, true, false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /frontend/middleware/thunk.js: -------------------------------------------------------------------------------- 1 | export const thunk = ({ dispatch, getState }) => (next) => (action) => { 2 | if (typeof action === "function") { 3 | return action(dispatch, getState); 4 | } 5 | return next(action); 6 | }; 7 | -------------------------------------------------------------------------------- /app/assets/stylesheets/api/comments.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the api/comments controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | -------------------------------------------------------------------------------- /app/assets/stylesheets/api/opinions.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the api/opinions controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | -------------------------------------------------------------------------------- /app/assets/stylesheets/api/sessions.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the api/sessions controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | -------------------------------------------------------------------------------- /app/assets/stylesheets/api/suggestions.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the suggestions controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | -------------------------------------------------------------------------------- /db/migrate/20161208194805_edit_default_image_url_part_two.rb: -------------------------------------------------------------------------------- 1 | class EditDefaultImageUrlPartTwo < ActiveRecord::Migration 2 | def change 3 | change_column_default :opinions, :img_url, "/assets/images/scotus.jpg" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20161208230815_add_images_attachment_to_judges.rb: -------------------------------------------------------------------------------- 1 | class AddImagesAttachmentToJudges < ActiveRecord::Migration 2 | def change 3 | change_table :judges do |t| 4 | t.attachment :image 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20161209155701_add_indices.rb: -------------------------------------------------------------------------------- 1 | class AddIndices < ActiveRecord::Migration 2 | def change 3 | add_index :opinions, :judge_id 4 | add_index :opinions, :court_id 5 | add_index :opinions, :case 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/assets/stylesheets/api/annotations.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the api/annotations controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /app/assets/javascripts/api/courts.coffee: -------------------------------------------------------------------------------- 1 | # Place all the behaviors and hooks related to the matching controller here. 2 | # All this logic will automatically be available in application.js. 3 | # You can use CoffeeScript in this file: http://coffeescript.org/ 4 | -------------------------------------------------------------------------------- /app/assets/javascripts/api/judges.coffee: -------------------------------------------------------------------------------- 1 | # Place all the behaviors and hooks related to the matching controller here. 2 | # All this logic will automatically be available in application.js. 3 | # You can use CoffeeScript in this file: http://coffeescript.org/ 4 | -------------------------------------------------------------------------------- /app/assets/javascripts/api/users.coffee: -------------------------------------------------------------------------------- 1 | # Place all the behaviors and hooks related to the matching controller here. 2 | # All this logic will automatically be available in application.js. 3 | # You can use CoffeeScript in this file: http://coffeescript.org/ 4 | -------------------------------------------------------------------------------- /db/migrate/20161208192243_edit_default_image_url.rb: -------------------------------------------------------------------------------- 1 | class EditDefaultImageUrl < ActiveRecord::Migration 2 | def change 3 | change_column_default :opinions, :img_url, from: "assets/images/scotus.jpg", to: "/assets/images/scotus.jpg" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/assets/javascripts/annotations.coffee: -------------------------------------------------------------------------------- 1 | # Place all the behaviors and hooks related to the matching controller here. 2 | # All this logic will automatically be available in application.js. 3 | # You can use CoffeeScript in this file: http://coffeescript.org/ 4 | -------------------------------------------------------------------------------- /app/assets/javascripts/api/annotations.coffee: -------------------------------------------------------------------------------- 1 | # Place all the behaviors and hooks related to the matching controller here. 2 | # All this logic will automatically be available in application.js. 3 | # You can use CoffeeScript in this file: http://coffeescript.org/ 4 | -------------------------------------------------------------------------------- /app/assets/javascripts/api/comments.coffee: -------------------------------------------------------------------------------- 1 | # Place all the behaviors and hooks related to the matching controller here. 2 | # All this logic will automatically be available in application.js. 3 | # You can use CoffeeScript in this file: http://coffeescript.org/ 4 | -------------------------------------------------------------------------------- /app/assets/javascripts/api/opinions.coffee: -------------------------------------------------------------------------------- 1 | # Place all the behaviors and hooks related to the matching controller here. 2 | # All this logic will automatically be available in application.js. 3 | # You can use CoffeeScript in this file: http://coffeescript.org/ 4 | -------------------------------------------------------------------------------- /app/assets/javascripts/api/sessions.coffee: -------------------------------------------------------------------------------- 1 | # Place all the behaviors and hooks related to the matching controller here. 2 | # All this logic will automatically be available in application.js. 3 | # You can use CoffeeScript in this file: http://coffeescript.org/ 4 | -------------------------------------------------------------------------------- /app/assets/javascripts/api/suggestions.coffee: -------------------------------------------------------------------------------- 1 | # Place all the behaviors and hooks related to the matching controller here. 2 | # All this logic will automatically be available in application.js. 3 | # You can use CoffeeScript in this file: http://coffeescript.org/ 4 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('../spring', __FILE__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | require_relative '../config/boot' 8 | require 'rake' 9 | Rake.application.run 10 | -------------------------------------------------------------------------------- /db/migrate/20161209155436_change_court_column_in_opinions.rb: -------------------------------------------------------------------------------- 1 | class ChangeCourtColumnInOpinions < ActiveRecord::Migration 2 | def change 3 | add_column :opinions, :court_id, :integer, null: false 4 | remove_column :opinions, :court, :string 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('../config/application', __FILE__) 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /app/views/static_pages/root.html.erb: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 |
11 | -------------------------------------------------------------------------------- /db/migrate/20161208231705_create_courts.rb: -------------------------------------------------------------------------------- 1 | class CreateCourts < ActiveRecord::Migration 2 | def change 3 | create_table :courts do |t| 4 | t.string :name, null: false 5 | t.string :citation, null: false 6 | 7 | t.timestamps null: false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /frontend/components/app.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HeaderContainer from './header/header_container'; 3 | 4 | const App = ({children}) => { 5 | return ( 6 |
7 | 8 | { children } 9 |
10 | ); 11 | }; 12 | 13 | export default App; 14 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('../spring', __FILE__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | APP_PATH = File.expand_path('../../config/application', __FILE__) 8 | require_relative '../config/boot' 9 | require 'rails/commands' 10 | -------------------------------------------------------------------------------- /db/migrate/20161215172000_add_attachment_avatar_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddAttachmentAvatarToUsers < ActiveRecord::Migration 2 | def self.up 3 | change_table :users do |t| 4 | t.attachment :avatar 5 | end 6 | end 7 | 8 | def self.down 9 | remove_attachment :users, :avatar 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20161209035027_change_judges_column_in_users_table.rb: -------------------------------------------------------------------------------- 1 | class ChangeJudgesColumnInUsersTable < ActiveRecord::Migration 2 | def change 3 | add_column :opinions, :judge_id, :integer, null: false 4 | remove_column :opinions, :judge, :string 5 | remove_column :opinions, :img_url, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20161209160536_add_attachment_image_to_opinions.rb: -------------------------------------------------------------------------------- 1 | class AddAttachmentImageToOpinions < ActiveRecord::Migration 2 | def self.up 3 | change_table :opinions do |t| 4 | t.attachment :image 5 | end 6 | end 7 | 8 | def self.down 9 | remove_attachment :opinions, :image 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/views/api/opinions/index.json.jbuilder: -------------------------------------------------------------------------------- 1 | @opinions.each do |opinion| 2 | json.set! opinion.id do 3 | json.extract! opinion, :id, :case, :citation, :date 4 | json.court opinion.court.name 5 | json.full_citation opinion.citation_format 6 | image = opinion.use_image 7 | json.thumb image.url(:thumb) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /frontend/components/header/thumb.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Thumb = (props) => { 4 | const thumbClass = props.currentUser ? "user-icon large" : "user-icon small"; 5 | 6 | return ( 7 |
8 | 9 |
10 | ); 11 | }; 12 | 13 | export default Thumb; 14 | -------------------------------------------------------------------------------- /frontend/util/comment_api_util.js: -------------------------------------------------------------------------------- 1 | export const createComment = (comment) => { 2 | return $.ajax({ 3 | type: "POST", 4 | url: "/api/comments", 5 | data: { comment } 6 | }); 7 | }; 8 | 9 | export const deleteComment = (id) => { 10 | return $.ajax({ 11 | type: "DELETE", 12 | url: `/api/comments/${id}` 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/reducers/session_reducer.js: -------------------------------------------------------------------------------- 1 | import { RECEIVE_CURRENT_USER } from '../actions/session_actions'; 2 | 3 | const sessionReducer = (state = null, action) => { 4 | Object.freeze(state); 5 | if (action.type === RECEIVE_CURRENT_USER) { 6 | return action.user; 7 | } else { 8 | return state; 9 | } 10 | }; 11 | 12 | export default sessionReducer; 13 | -------------------------------------------------------------------------------- /frontend/store/store.js: -------------------------------------------------------------------------------- 1 | import rootReducer from '../reducers/root_reducer'; 2 | import { createStore, applyMiddleware } from 'redux'; 3 | import { thunk } from '../middleware/thunk'; 4 | 5 | const configureStore = (preloadedState = {}) => { 6 | return createStore(rootReducer, preloadedState, applyMiddleware(thunk)); 7 | }; 8 | 9 | export default configureStore; 10 | -------------------------------------------------------------------------------- /frontend/components/opinions/opinion_detail_panel.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | //will refactor connected container once annotations are implemented 4 | 5 | const OpinionDetailPanel = (props) => ( 6 |
7 | 8 |
9 | ); 10 | 11 | export default OpinionDetailPanel; 12 | -------------------------------------------------------------------------------- /app/views/api/comments/show.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.set! @comment.id do 2 | json.extract! @comment, :id, :body, :opinion_id, :created_at 3 | json.numVotes @comment.num_votes 4 | json.userVote @comment.user_vote(current_user.id) if logged_in? 5 | json.user do 6 | json.extract! @comment.user, :id, :username 7 | json.thumb @comment.user.avatar.url(:thumb) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/models/concerns/votable.rb: -------------------------------------------------------------------------------- 1 | module Votable 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | has_many :votes, as: :votable, 6 | class_name: "Vote", 7 | dependent: :destroy 8 | end 9 | 10 | def num_votes 11 | self.votes.sum(:status) 12 | end 13 | 14 | def user_vote(user_id) 15 | vote = Vote.find_by_votable(self, user_id) 16 | vote ? vote.status : 0 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /frontend/reducers/opinions_reducer.js: -------------------------------------------------------------------------------- 1 | import { RECEIVE_ALL_OPINIONS } from '../actions/opinion_actions'; 2 | 3 | const opinionsReducer = (state = {}, action) => { 4 | Object.freeze(state); 5 | let newState = Object.assign({}, state); 6 | if (action.type === RECEIVE_ALL_OPINIONS) { 7 | return action.opinions; 8 | } else { 9 | return newState; 10 | } 11 | }; 12 | 13 | export default opinionsReducer; 14 | -------------------------------------------------------------------------------- /app/views/api/suggestions/show.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.set! @suggestion.id do 2 | json.extract! @suggestion, :id, :body, :annotation_id, :suggestion_type, :created_at 3 | json.numVotes @suggestion.num_votes 4 | json.userVote @suggestion.user_vote(current_user.id) if logged_in? 5 | json.user do 6 | json.extract! @suggestion.user, :id, :username 7 | json.image @suggestion.user.avatar.url(:thumb) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20161213195414_create_comments.rb: -------------------------------------------------------------------------------- 1 | class CreateComments < ActiveRecord::Migration 2 | def change 3 | create_table :comments do |t| 4 | t.integer :opinion_id, null: false 5 | t.integer :user_id, null: false 6 | t.text :body, null: false 7 | 8 | t.timestamps null: false 9 | end 10 | 11 | add_index :comments, :opinion_id 12 | add_index :comments, :user_id 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/controllers/api/users_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::UsersController < ApplicationController 2 | def create 3 | @user = User.new(user_params) 4 | if @user.save 5 | login!(@user) 6 | render :show 7 | else 8 | render json: @user.errors.messages, status: 422 9 | end 10 | end 11 | 12 | private 13 | 14 | def user_params 15 | params.require(:user).permit(:username, :password) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/factories.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :vote do 3 | 4 | end 5 | factory :comment do 6 | 7 | end 8 | factory :suggestion do 9 | 10 | end 11 | factory :annotation do 12 | 13 | end 14 | factory :court do 15 | 16 | end 17 | factory :judge do 18 | 19 | end 20 | factory :opinion do 21 | 22 | end 23 | factory :user do 24 | username { Faker::name.name } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /frontend/components/header/header_container.js: -------------------------------------------------------------------------------- 1 | import { logout } from '../../actions/session_actions'; 2 | import { connect } from 'react-redux'; 3 | import Header from './header'; 4 | 5 | const mapStateToProps = (state) => ({ 6 | currentUser: state.currentUser 7 | }); 8 | 9 | const mapDispatchToProps = (dispatch) => ({ 10 | logout: () => dispatch(logout()) 11 | }); 12 | 13 | export default connect(mapStateToProps, mapDispatchToProps)(Header); 14 | -------------------------------------------------------------------------------- /app/models/court.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: courts 4 | # 5 | # id :integer not null, primary key 6 | # name :string not null 7 | # citation :string not null 8 | # created_at :datetime not null 9 | # updated_at :datetime not null 10 | # 11 | 12 | class Court < ActiveRecord::Base 13 | validates :name, :citation, presence: true 14 | has_many :opinions 15 | 16 | end 17 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /db/migrate/20161206155825_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration 2 | def change 3 | create_table :users do |t| 4 | t.string :username, null: false 5 | t.string :password_digest, null: false 6 | t.string :session_token, null: false 7 | 8 | t.timestamps null: false 9 | end 10 | 11 | add_index :users, :username, unique: true 12 | add_index :users, :session_token, unique: true 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20161215011753_create_votes.rb: -------------------------------------------------------------------------------- 1 | class CreateVotes < ActiveRecord::Migration 2 | def change 3 | create_table :votes do |t| 4 | t.integer :user_id, null: false 5 | t.integer :votable_id, null: false 6 | t.string :votable_type, null: false 7 | t.integer :status, null: false 8 | 9 | t.timestamps null: false 10 | end 11 | 12 | add_index :votes, :user_id 13 | add_index :votes, :votable_id 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /frontend/util/session_api_util.js: -------------------------------------------------------------------------------- 1 | export const signup = (user) => { 2 | return $.ajax({ 3 | type: "POST", 4 | url: "/api/users", 5 | data: { user } 6 | }); 7 | }; 8 | 9 | export const login = (user) => { 10 | return $.ajax({ 11 | type: "POST", 12 | url: "/api/session", 13 | data: { user } 14 | }); 15 | }; 16 | 17 | export const logout = () => { 18 | return $.ajax({ 19 | type: "DELETE", 20 | url: "/api/session" 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /frontend/reducers/search_reducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | RECEIVE_SEARCH_RESULTS, 3 | CLEAR_SEARCH_RESULTS 4 | } from '../actions/search_actions'; 5 | 6 | const SearchReducer = (state = [], action) => { 7 | Object.freeze(state); 8 | switch(action.type) { 9 | case RECEIVE_SEARCH_RESULTS: 10 | return action.results; 11 | case CLEAR_SEARCH_RESULTS: 12 | return []; 13 | default: 14 | return state; 15 | } 16 | }; 17 | 18 | export default SearchReducer; 19 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CaseNote 5 | <%= stylesheet_link_tag 'application', media: 'all' %> 6 | <%= javascript_include_tag 'application' %> 7 | <%= csrf_meta_tags %> 8 | <%= stylesheet_link_tag "https://fonts.googleapis.com/css?family=Lato:300,400,500,600,700", media: 'all' %> 9 | <%= favicon_link_tag %> 10 | 11 | 12 | 13 | 14 | <%= yield %> 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /spec/models/court_spec.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: courts 4 | # 5 | # id :integer not null, primary key 6 | # name :string not null 7 | # citation :string not null 8 | # created_at :datetime not null 9 | # updated_at :datetime not null 10 | # 11 | 12 | require 'rails_helper' 13 | 14 | RSpec.describe Court, type: :model do 15 | pending "add some examples to (or delete) #{__FILE__}" 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20161212221933_create_suggestions.rb: -------------------------------------------------------------------------------- 1 | class CreateSuggestions < ActiveRecord::Migration 2 | def change 3 | create_table :suggestions do |t| 4 | t.integer :user_id, null: false 5 | t.integer :annotation_id, null: false 6 | t.string :suggestion_type, null: false 7 | t.text :body, null: false 8 | 9 | t.timestamps null: false 10 | end 11 | 12 | add_index :suggestions, :user_id 13 | add_index :suggestions, :annotation_id 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20161210194437_create_annotations.rb: -------------------------------------------------------------------------------- 1 | class CreateAnnotations < ActiveRecord::Migration 2 | def change 3 | create_table :annotations do |t| 4 | t.integer :start_idx, null: false 5 | t.integer :length, null: false 6 | t.text :body, null: false 7 | t.integer :opinion_id, null: false 8 | t.integer :user_id, null: false 9 | 10 | t.timestamps null: false 11 | end 12 | 13 | add_index :annotations, :opinion_id 14 | add_index :annotations, :user_id 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /frontend/components/home.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SearchContainer from './search/search_container'; 3 | 4 | class Home extends React.Component{ 5 | 6 | render () { 7 | return ( 8 |
9 |
10 | CaseNote is a crowdsourced collection 11 | of court opinions and legal knowledge. 12 |
13 | 14 |
15 | ); 16 | } 17 | } 18 | 19 | export default Home; 20 | -------------------------------------------------------------------------------- /spec/models/comment_spec.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: comments 4 | # 5 | # id :integer not null, primary key 6 | # opinion_id :integer not null 7 | # user_id :integer not null 8 | # body :text not null 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # 12 | 13 | require 'rails_helper' 14 | 15 | RSpec.describe Comment, type: :model do 16 | pending "add some examples to (or delete) #{__FILE__}" 17 | end 18 | -------------------------------------------------------------------------------- /frontend/components/opinions/opinion_create_form_container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { createOpinion } from '../../actions/opinion_actions'; 3 | import OpinionCreateForm from './opinion_create_form'; 4 | 5 | const mapStateToProps = (state) => ({ 6 | errors: state.formErrors.opinion 7 | }); 8 | 9 | const mapDispatchToProps = (dispatch) => ({ 10 | createOpinion: (opinion) => dispatch(createOpinion(opinion)) 11 | }); 12 | 13 | export default connect(mapStateToProps, mapDispatchToProps)(OpinionCreateForm); 14 | -------------------------------------------------------------------------------- /frontend/components/comments/comment_index_container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { deleteComment } from '../../actions/comment_actions'; 3 | import CommentIndex from './comment_index'; 4 | 5 | const mapStateToProps = (state, ownProps) => ({ 6 | comments: ownProps.comments, 7 | currentUser: state.currentUser 8 | }); 9 | 10 | const mapDispatchToProps = (dispatch) => ({ 11 | deleteComment: (id) => dispatch(deleteComment(id)) 12 | }); 13 | 14 | export default connect(mapStateToProps, mapDispatchToProps)(CommentIndex); 15 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added. 11 | # Rails.application.config.assets.precompile += %w( search.js ) 12 | -------------------------------------------------------------------------------- /app/models/comment.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: comments 4 | # 5 | # id :integer not null, primary key 6 | # opinion_id :integer not null 7 | # user_id :integer not null 8 | # body :text not null 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # 12 | 13 | class Comment < ActiveRecord::Base 14 | include Votable 15 | 16 | validates :user, :opinion, :body, presence: true 17 | belongs_to :user 18 | belongs_to :opinion 19 | 20 | end 21 | -------------------------------------------------------------------------------- /frontend/components/opinions/opinion_index_container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { fetchAllOpinions } from '../../actions/opinion_actions'; 3 | import OpinionIndex from './opinion_index'; 4 | import { toArray } from '../../util/selectors'; 5 | 6 | const mapStateToProps = (state) => ({ 7 | opinions: toArray(state.opinions), 8 | }); 9 | 10 | const mapDispatchToProps = (dispatch) => ({ 11 | fetchAllOpinions: () => dispatch(fetchAllOpinions()), 12 | }); 13 | 14 | export default connect(mapStateToProps, mapDispatchToProps)(OpinionIndex); 15 | -------------------------------------------------------------------------------- /frontend/util/suggestion_api_util.js: -------------------------------------------------------------------------------- 1 | export const createSuggestion = (suggestion) => { 2 | return $.ajax({ 3 | type: "POST", 4 | url: "/api/suggestions", 5 | data: { suggestion } 6 | }); 7 | }; 8 | 9 | export const editSuggestion = (suggestion) => { 10 | return $.ajax({ 11 | type: "PATCH", 12 | url: `/api/suggestions/${suggestion.id}`, 13 | data: { suggestion } 14 | }); 15 | }; 16 | 17 | export const deleteSuggestion = (id) => { 18 | return $.ajax({ 19 | type: "DELETE", 20 | url: `/api/suggestions/${id}` 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # This file loads spring without using Bundler, in order to be fast. 4 | # It gets overwritten when you run the `spring binstub` command. 5 | 6 | unless defined?(Spring) 7 | require 'rubygems' 8 | require 'bundler' 9 | 10 | lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read) 11 | if spring = lockfile.specs.detect { |spec| spec.name == "spring" } 12 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 13 | gem 'spring', spring.version 14 | require 'spring/binstub' 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/models/vote_spec.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: votes 4 | # 5 | # id :integer not null, primary key 6 | # user_id :integer not null 7 | # votable_id :integer not null 8 | # votable_type :string not null 9 | # status :integer not null 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # 13 | 14 | require 'rails_helper' 15 | 16 | RSpec.describe Vote, type: :model do 17 | pending "add some examples to (or delete) #{__FILE__}" 18 | end 19 | -------------------------------------------------------------------------------- /frontend/components/comments/comment_form_container.jsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { createComment } from '../../actions/comment_actions'; 3 | import CommentForm from './comment_form'; 4 | 5 | const mapStateToProps = (state, ownProps) => ({ 6 | currentUser: state.currentUser, 7 | errors: state.formErrors.comment, 8 | opinionId: ownProps.opinionId 9 | }); 10 | 11 | const mapDispatchToProps = (dispatch) => ({ 12 | createComment: (comment) => dispatch(createComment(comment)) 13 | }); 14 | 15 | export default connect(mapStateToProps, mapDispatchToProps)(CommentForm); 16 | -------------------------------------------------------------------------------- /frontend/components/suggestions/suggestion_form_container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { createSuggestion } from '../../actions/suggestion_actions'; 3 | import SuggestionForm from './suggestion_form'; 4 | 5 | const mapStateToProps = (state, ownProps) => ({ 6 | annotationId: ownProps.annotationId, 7 | currentUser: state.currentUser 8 | }); 9 | 10 | const mapDispatchToProps = (dispatch) => ({ 11 | createSuggestion: (suggestion) => dispatch(createSuggestion(suggestion)), 12 | }); 13 | 14 | export default connect(mapStateToProps, mapDispatchToProps)(SuggestionForm); 15 | -------------------------------------------------------------------------------- /spec/models/suggestion_spec.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: suggestions 4 | # 5 | # id :integer not null, primary key 6 | # user_id :integer not null 7 | # annotation_id :integer not null 8 | # suggestion_type :string not null 9 | # body :text 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # 13 | 14 | require 'rails_helper' 15 | 16 | RSpec.describe Suggestion, type: :model do 17 | pending "add some examples to (or delete) #{__FILE__}" 18 | end 19 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] if respond_to?(:wrap_parameters) 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /frontend/components/opinions/opinion_detail_container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { 3 | fetchSingleOpinion, 4 | clearOpinion 5 | } from '../../actions/opinion_actions'; 6 | import OpinionDetail from './opinion_detail'; 7 | 8 | const mapStateToProps = (state) => ({ 9 | opinion: state.opinionDetail 10 | }); 11 | 12 | const mapDispatchToProps = (dispatch) => ({ 13 | fetchSingleOpinion: (id) => dispatch(fetchSingleOpinion(id)), 14 | clearOpinion: () => dispatch(clearOpinion()) 15 | }); 16 | 17 | export default connect(mapStateToProps, mapDispatchToProps)(OpinionDetail); 18 | -------------------------------------------------------------------------------- /frontend/components/search/search_container.js: -------------------------------------------------------------------------------- 1 | import { 2 | searchOpinions, 3 | clearSearchResults 4 | } from '../../actions/search_actions'; 5 | import { connect } from 'react-redux'; 6 | import Search from './search'; 7 | 8 | const mapStateToProps = (state) => { 9 | return({ 10 | searchResults: state.searchResults, 11 | });}; 12 | 13 | const mapDispatchToProps = (dispatch) => ({ 14 | searchOpinions: (query) => dispatch(searchOpinions(query)), 15 | clearSearchResults: () => dispatch(clearSearchResults()) 16 | }); 17 | 18 | export default connect(mapStateToProps, mapDispatchToProps)(Search); 19 | -------------------------------------------------------------------------------- /spec/models/judge_spec.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: judges 4 | # 5 | # id :integer not null, primary key 6 | # created_at :datetime not null 7 | # updated_at :datetime not null 8 | # name :string not null 9 | # image_file_name :string 10 | # image_content_type :string 11 | # image_file_size :integer 12 | # image_updated_at :datetime 13 | # 14 | 15 | require 'rails_helper' 16 | 17 | RSpec.describe Judge, type: :model do 18 | pending "add some examples to (or delete) #{__FILE__}" 19 | end 20 | -------------------------------------------------------------------------------- /frontend/components/session_form/modal_style.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | overlay : { 3 | position : 'fixed', 4 | top : 0, 5 | left : 0, 6 | right : 0, 7 | bottom : 0, 8 | backgroundColor : 'rgba(0, 0, 0, 0.9)', 9 | }, 10 | content : { 11 | position : 'relative', 12 | background : '#eee', 13 | width : '410px', 14 | top : '20px', 15 | border : '1px solid #ccc', 16 | padding : '20px', 17 | borderRadius : '0', 18 | margin : 'auto', 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /frontend/components/opinions/opinion_index_item.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | 4 | export const OpinionIndexItem = ({opinion, index }) => { 5 | return( 6 |
  • 7 | 8 |
    { index + 1 }
    9 | 10 |
    11 | { opinion.full_citation } 12 | { opinion.court } 13 |
    14 | 15 |
  • 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/components/suggestions/suggestion_index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SuggestionItemContainer from './suggestion_item_container'; 3 | import { toArray } from '../../util/selectors'; 4 | 5 | const SuggestionIndex = (props) => { 6 | if (!props.suggestions) return null; 7 | const suggestions = toArray(props.suggestions); 8 | return ( 9 | 14 | ); 15 | }; 16 | 17 | export default SuggestionIndex; 18 | -------------------------------------------------------------------------------- /db/migrate/20161207154724_create_opinions.rb: -------------------------------------------------------------------------------- 1 | class CreateOpinions < ActiveRecord::Migration 2 | def change 3 | create_table :opinions do |t| 4 | t.string :case, null: false 5 | t.string :citation, null: false 6 | t.string :judge, null: false 7 | t.string :court, null: false 8 | t.date :date, null: false 9 | t.text :body, null: false 10 | t.integer :transcriber_id, null: false 11 | t.string :img_url 12 | 13 | t.timestamps null: false 14 | end 15 | 16 | add_index :opinions, :citation, unique: true 17 | add_index :opinions, :transcriber_id 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/models/annotation_spec.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: annotations 4 | # 5 | # id :integer not null, primary key 6 | # start_idx :integer not null 7 | # length :integer not null 8 | # body :text not null 9 | # opinion_id :integer not null 10 | # user_id :integer not null 11 | # created_at :datetime not null 12 | # updated_at :datetime not null 13 | # 14 | 15 | require 'rails_helper' 16 | 17 | RSpec.describe Annotation, type: :model do 18 | pending "add some examples to (or delete) #{__FILE__}" 19 | end 20 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = { 3 | context: __dirname, 4 | entry: './frontend/casenote.jsx', 5 | output: { 6 | path: path.join(__dirname, 'app', 'assets', 'javascripts'), 7 | filename: 'bundle.js' 8 | }, 9 | resolve: { 10 | extensions: ['', '.js', '.jsx'] 11 | }, 12 | module: { 13 | loaders: [ 14 | { 15 | test: /\.jsx?$/, 16 | exclude: /(node_modules|bower_components)/, 17 | loader: 'babel', 18 | query: { 19 | presets: ['react', 'es2015'] 20 | } 21 | } 22 | ] 23 | }, 24 | devtool: 'source-map' 25 | }; 26 | -------------------------------------------------------------------------------- /app/controllers/api/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::SessionsController < ApplicationController 2 | def create 3 | @user = User.find_by_credentials( 4 | params[:user][:username], 5 | params[:user][:password] 6 | ) 7 | 8 | if @user.nil? 9 | render json: { login: ["Invalid Credentials"] }, status: 401 10 | else 11 | login!(@user) 12 | render "api/users/show" 13 | end 14 | end 15 | 16 | def destroy 17 | @user = current_user 18 | if @user 19 | logout! 20 | render json: {} 21 | else 22 | render json: ["No one is signed in"], status: 404 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all logfiles and tempfiles. 11 | /log/* 12 | !/log/.keep 13 | /tmp 14 | 15 | node_modules/ 16 | bundle.js 17 | bundle.js.map 18 | .byebug_history 19 | .DS_Store 20 | npm-debug.log 21 | tasklist.md 22 | 23 | # Ignore application configuration 24 | /config/application.yml 25 | -------------------------------------------------------------------------------- /frontend/components/suggestions/suggestion_item_container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { editSuggestion, deleteSuggestion } from '../../actions/suggestion_actions'; 3 | import SuggestionItem from './suggestion_item'; 4 | 5 | const mapStateToProps = (state, ownProps) => ({ 6 | currentUser: state.currentUser, 7 | suggestion: ownProps.suggestion 8 | }); 9 | 10 | 11 | const mapDispatchToProps = (dispatch) => ({ 12 | editSuggestion: (suggestion) => dispatch(editSuggestion(suggestion)), 13 | deleteSuggestion: (id) => dispatch(deleteSuggestion(id)) 14 | }); 15 | 16 | export default connect(mapStateToProps, mapDispatchToProps)(SuggestionItem); 17 | -------------------------------------------------------------------------------- /frontend/casenote.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import configureStore from './store/store'; 4 | import Root from './components/root'; 5 | import Modal from 'react-modal'; 6 | 7 | // To Test 8 | 9 | document.addEventListener('DOMContentLoaded', () => { 10 | let store; 11 | if (window.currentUser) { 12 | store = configureStore({ currentUser: window.currentUser }); 13 | } else { 14 | store = configureStore(); 15 | } 16 | 17 | // To test 18 | window.store = store; 19 | 20 | Modal.setAppElement(document.body); 21 | const root = document.getElementById('root'); 22 | ReactDOM.render(, root); 23 | }); 24 | -------------------------------------------------------------------------------- /frontend/components/opinions/opinion_detail_header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const OpinionDetailHeader = (props) => { 4 | const { opinion } = props; 5 | const divStyle = { 6 | backgroundImage: `url(${opinion.image_url})` 7 | }; 8 | return( 9 |
    10 |
    11 |

    {opinion.case}

    12 |

    {opinion.citation}

    13 |

    {opinion.court}

    14 |

    {opinion.date}

    15 |

    Written By {opinion.judge}

    16 |
    17 |
    18 | ); 19 | }; 20 | 21 | export default OpinionDetailHeader; 22 | -------------------------------------------------------------------------------- /frontend/components/opinions/opinion_index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { OpinionIndexItem } from './opinion_index_item'; 3 | 4 | class OpinionIndex extends React.Component { 5 | componentDidMount() { 6 | this.props.fetchAllOpinions(); 7 | } 8 | 9 | render() { 10 | if (this.props.opinions.length === 0) return null; 11 | return ( 12 |
    13 |

    Opinions

    14 | 19 |
    20 | ); 21 | } 22 | } 23 | 24 | export default OpinionIndex; 25 | -------------------------------------------------------------------------------- /app/models/annotation.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: annotations 4 | # 5 | # id :integer not null, primary key 6 | # start_idx :integer not null 7 | # length :integer not null 8 | # body :text not null 9 | # opinion_id :integer not null 10 | # user_id :integer not null 11 | # created_at :datetime not null 12 | # updated_at :datetime not null 13 | # 14 | 15 | class Annotation < ActiveRecord::Base 16 | include Votable 17 | 18 | validates :user, :opinion, :start_idx, :length, :body, presence: true 19 | 20 | belongs_to :user 21 | belongs_to :opinion 22 | has_many :suggestions 23 | end 24 | -------------------------------------------------------------------------------- /frontend/actions/search_actions.js: -------------------------------------------------------------------------------- 1 | import * as APIUtil from '../util/opinion_api_util'; 2 | import { clearErrors } from './general_actions'; 3 | 4 | export const RECEIVE_SEARCH_RESULTS = "RECEIVE_SEARCH_RESULTS"; 5 | export const CLEAR_SEARCH_RESULTS = "CLEAR_SEARCH_RESULTS"; 6 | 7 | export const receiveSearchResults = (results) => ({ 8 | type: RECEIVE_SEARCH_RESULTS, 9 | results 10 | }); 11 | 12 | export const clearSearchResults = () => ({ 13 | type: CLEAR_SEARCH_RESULTS 14 | }); 15 | 16 | export function searchOpinions(query) { 17 | return (dispatch) => { 18 | return APIUtil.searchOpinions(query).then( 19 | (results) => dispatch(receiveSearchResults(results)) 20 | ); 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /frontend/util/annotation_api_util.js: -------------------------------------------------------------------------------- 1 | export const fetchAnnotation = (id) => { 2 | return $.ajax({ 3 | type: "GET", 4 | url: `/api/annotations/${id}` 5 | }); 6 | }; 7 | 8 | export const createAnnotation = (annotation) => { 9 | return $.ajax({ 10 | type: "POST", 11 | url: "/api/annotations", 12 | data: { annotation } 13 | }); 14 | }; 15 | 16 | export const editAnnotation = (annotation) => { 17 | return $.ajax({ 18 | type: "PATCH", 19 | url: `/api/annotations/${annotation.id}`, 20 | data: { annotation } 21 | }); 22 | }; 23 | 24 | export const deleteAnnotation = (id) => { 25 | return $.ajax({ 26 | type: "DELETE", 27 | url: `/api/annotations/${id}` 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /frontend/reducers/root_reducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import sessionReducer from './session_reducer'; 3 | import searchReducer from './search_reducer'; 4 | import errorsReducer from './errors_reducer'; 5 | import opinionsReducer from './opinions_reducer'; 6 | import opinionDetailReducer from './opinion_detail_reducer'; 7 | import annotationDetailReducer from './annotation_detail_reducer'; 8 | 9 | const rootReducer = combineReducers({ 10 | currentUser: sessionReducer, 11 | searchResults: searchReducer, 12 | formErrors: errorsReducer, 13 | opinions: opinionsReducer, 14 | opinionDetail: opinionDetailReducer, 15 | annotationDetail: annotationDetailReducer 16 | }); 17 | 18 | export default rootReducer; 19 | -------------------------------------------------------------------------------- /frontend/util/annotation_format.js: -------------------------------------------------------------------------------- 1 | import Quill from 'quill'; 2 | 3 | let Inline = Quill.import('blots/inline'); 4 | 5 | class Annotation extends Inline{ 6 | static create(value) { 7 | let node = super.create(); 8 | node.setAttribute('id', value); 9 | node.setAttribute('style','background: #e2e2e2; cursor: pointer'); 10 | node.setAttribute('onMouseOut', "this.style.background='#e2e2e2'"); 11 | node.setAttribute('onMouseOver', "this.style.background='#ffff64'"); 12 | return node; 13 | } 14 | 15 | static formats(node) { 16 | return node.getAttribute('id'); 17 | } 18 | } 19 | 20 | Annotation.blotName = "annotation_id"; 21 | Annotation.className = "opinion-annotation"; 22 | 23 | export default Annotation; 24 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require jquery 14 | //= require jquery_ujs 15 | //= require bundle 16 | //= require_tree . 17 | -------------------------------------------------------------------------------- /app/assets/stylesheets/annotation_form_detail.scss: -------------------------------------------------------------------------------- 1 | .annotation-create-form, .annotation-detail-view { 2 | float: left; 3 | position: absolute; 4 | border-left: 6px solid #99a7ee; 5 | padding: 0px 20px; 6 | font-weight: 400; 7 | } 8 | 9 | #annoForm { 10 | background: rgba(204, 204, 204, 0.2); 11 | border: 1px solid #ccc; 12 | padding: 5px; 13 | margin-bottom: 20px; 14 | height: 120px; 15 | width: 360px; 16 | overflow: auto; 17 | } 18 | 19 | #anno-editor { 20 | width: 360px; 21 | max-height: 360px; 22 | overflow: auto; 23 | margin: 10px 0px 20px 0px; 24 | } 25 | 26 | .annotation-detail-view-header { 27 | font-size: 13px; 28 | font-weight: 700; 29 | } 30 | 31 | .annotation-detail-view-header span { 32 | text-decoration: underline; 33 | } 34 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any styles 10 | * defined in the other CSS/SCSS files in this directory. It is generally better to create a new 11 | * file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /app/models/judge.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: judges 4 | # 5 | # id :integer not null, primary key 6 | # created_at :datetime not null 7 | # updated_at :datetime not null 8 | # name :string not null 9 | # image_file_name :string 10 | # image_content_type :string 11 | # image_file_size :integer 12 | # image_updated_at :datetime 13 | # 14 | 15 | class Judge < ActiveRecord::Base 16 | validates :name, presence: true 17 | has_many :opinions 18 | 19 | has_attached_file :image, styles: { large: "600x600>", thumb: "100x100>" }, default_url: "https://s3.us-east-2.amazonaws.com/casenote-assets/default.jpg" 20 | validates_attachment_content_type :image, content_type: /\Aimage\/.*\z/ 21 | 22 | end 23 | -------------------------------------------------------------------------------- /frontend/components/annotations/annotation_form_container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { 3 | createAnnotation, 4 | receiveAnnotationErrors 5 | } from '../../actions/annotation_actions'; 6 | import AnnotationForm from './annotation_form'; 7 | 8 | const mapStateToProps = (state, ownProps) => ({ 9 | range: ownProps.range, 10 | location: ownProps.location, 11 | opinionId: ownProps.opinionId, 12 | errors: state.formErrors.annotation, 13 | currentUser: state.currentUser 14 | }); 15 | 16 | const mapDispatchToProps = (dispatch) => ({ 17 | createAnnotation: (anno) => dispatch(createAnnotation(anno)), 18 | receiveAnnotationErrors: (errors) => dispatch(receiveAnnotationErrors(errors)) 19 | }); 20 | 21 | export default connect( 22 | mapStateToProps, 23 | mapDispatchToProps 24 | )(AnnotationForm); 25 | -------------------------------------------------------------------------------- /frontend/components/opinions/opinion_detail_body_container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { editOpinion, deleteOpinion } from '../../actions/opinion_actions'; 3 | import { editAnnotation } from '../../actions/annotation_actions'; 4 | import OpinionDetailBody from './opinion_detail_body'; 5 | 6 | const mapStateToProps = (state) => ({ 7 | currentUser: state.currentUser, 8 | opinion: state.opinionDetail, 9 | formErrors: state.formErrors.opinion 10 | }); 11 | 12 | const mapDispatchToProps = (dispatch) => ({ 13 | editOpinion: (opinion) => dispatch(editOpinion(opinion)), 14 | deleteOpinion: (id) => dispatch(deleteOpinion(id)), 15 | editAnnotation: (anno) => dispatch(editAnnotation(anno)) 16 | }); 17 | 18 | export default connect( 19 | mapStateToProps, 20 | mapDispatchToProps 21 | )(OpinionDetailBody); 22 | -------------------------------------------------------------------------------- /frontend/util/date.js: -------------------------------------------------------------------------------- 1 | export function timeSince(date) { 2 | date = new Date(date); 3 | 4 | let seconds = Math.floor((new Date() - date) / 1000); 5 | let interval = Math.floor(seconds / 31536000); 6 | 7 | if (interval > 1) { 8 | return interval + " years"; 9 | } 10 | interval = Math.floor(seconds / 2592000); 11 | if (interval > 1) { 12 | return interval + " months"; 13 | } 14 | interval = Math.floor(seconds / 86400); 15 | if (interval > 1) { 16 | return interval + " days"; 17 | } 18 | interval = Math.floor(seconds / 3600); 19 | if (interval > 1) { 20 | return interval + " hours"; 21 | } 22 | interval = Math.floor(seconds / 60); 23 | if (interval > 1) { 24 | return interval + " minutes"; 25 | } 26 | return Math.floor(seconds) + " seconds"; 27 | } 28 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Prevent CSRF attacks by raising an exception. 3 | # For APIs, you may want to use :null_session instead. 4 | protect_from_forgery with: :exception 5 | 6 | helper_method :logged_in?, :current_user 7 | 8 | def login!(user) 9 | @current_user = user 10 | session[:session_token] = user.reset_session_token! 11 | end 12 | 13 | def current_user 14 | @current_user ||= User.find_by(session_token: session[:session_token]) 15 | end 16 | 17 | def logout! 18 | current_user.reset_session_token! 19 | session[:session_token] = nil 20 | end 21 | 22 | def logged_in? 23 | !!current_user 24 | end 25 | 26 | def check_logged_in 27 | render json: { login: ["User is not signed in"] }, status: 401 unless logged_in? 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/models/suggestion.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: suggestions 4 | # 5 | # id :integer not null, primary key 6 | # user_id :integer not null 7 | # annotation_id :integer not null 8 | # suggestion_type :string not null 9 | # body :text 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # 13 | 14 | class Suggestion < ActiveRecord::Base 15 | include Votable 16 | 17 | validates :user, :annotation, :suggestion_type, presence: true 18 | validates :suggestion_type, inclusion: { 19 | in: %w(restate missing stretch other), 20 | message: "Invalid suggestion type" 21 | } 22 | validates_presence_of :body, :if => lambda { |o| o.suggestion_type == "other" } 23 | 24 | belongs_to :user 25 | belongs_to :annotation 26 | end 27 | -------------------------------------------------------------------------------- /app/views/api/annotations/show.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.extract! @annotation, :id, :body, :start_idx, :length, :opinion_id 2 | json.user do 3 | json.extract! @annotation.user, :id, :username 4 | json.image @annotation.user.avatar.url(:thumb) 5 | end 6 | json.suggestions do 7 | @annotation.suggestions 8 | .includes(:user) 9 | .each do |suggestion| 10 | json.set! suggestion.id do 11 | json.extract! suggestion, :id, :body, :annotation_id, :suggestion_type, :created_at 12 | json.numVotes suggestion.num_votes 13 | json.userVote suggestion.user_vote(current_user.id) if logged_in? 14 | json.user do 15 | json.extract! suggestion.user, :id, :username 16 | json.image suggestion.user.avatar.url(:thumb) 17 | end 18 | end 19 | end 20 | end 21 | json.numVotes @annotation.num_votes 22 | json.userVote @annotation.user_vote(current_user.id) if logged_in? 23 | -------------------------------------------------------------------------------- /app/models/vote.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: votes 4 | # 5 | # id :integer not null, primary key 6 | # user_id :integer not null 7 | # votable_id :integer not null 8 | # votable_type :string not null 9 | # status :integer not null 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # 13 | 14 | class Vote < ActiveRecord::Base 15 | validates :user, :votable, :status, presence: true 16 | validates :user_id, uniqueness: { scope: [:votable_type, :votable_id] } 17 | 18 | belongs_to :votable, polymorphic: true 19 | belongs_to :user, inverse_of: :votes 20 | 21 | def self.find_by_votable(votable, user_id) 22 | Vote.find_by( 23 | votable_id: votable.id, 24 | votable_type: votable.class.to_s, 25 | user_id: user_id 26 | ) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | 3 | module.exports = { 4 | context: __dirname, 5 | entry: './frontend/casenote.jsx', 6 | output: { 7 | path: "./app/assets/javascripts", 8 | filename: 'bundle.js' 9 | }, 10 | plugins:[ 11 | new webpack.DefinePlugin({ 12 | 'process.env':{ 13 | 'NODE_ENV': JSON.stringify('production') 14 | } 15 | }), 16 | new webpack.optimize.UglifyJsPlugin({ 17 | compress:{ 18 | warnings: true 19 | } 20 | }) 21 | ], 22 | resolve: { 23 | extensions: ['', '.js', '.jsx'] 24 | }, 25 | module: { 26 | loaders: [ 27 | { 28 | test: /\.jsx?$/, 29 | exclude: /(node_modules|bower_components)/, 30 | loader: 'babel', 31 | query: { 32 | presets: ['react', 'es2015'] 33 | } 34 | } 35 | ] 36 | }, 37 | devtool: 'source-map' 38 | }; 39 | -------------------------------------------------------------------------------- /spec/models/opinion_spec.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: opinions 4 | # 5 | # id :integer not null, primary key 6 | # case :string not null 7 | # citation :string not null 8 | # date :date not null 9 | # body :text not null 10 | # transcriber_id :integer not null 11 | # created_at :datetime not null 12 | # updated_at :datetime not null 13 | # judge_id :integer not null 14 | # court_id :integer not null 15 | # image_file_name :string 16 | # image_content_type :string 17 | # image_file_size :integer 18 | # image_updated_at :datetime 19 | # 20 | 21 | require 'rails_helper' 22 | 23 | RSpec.describe Opinion, type: :model do 24 | pending "add some examples to (or delete) #{__FILE__}" 25 | end 26 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | 4 | # path to your application root. 5 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 6 | 7 | Dir.chdir APP_ROOT do 8 | # This script is a starting point to setup your application. 9 | # Add necessary setup steps to this file: 10 | 11 | puts "== Installing dependencies ==" 12 | system "gem install bundler --conservative" 13 | system "bundle check || bundle install" 14 | 15 | # puts "\n== Copying sample files ==" 16 | # unless File.exist?("config/database.yml") 17 | # system "cp config/database.yml.sample config/database.yml" 18 | # end 19 | 20 | puts "\n== Preparing database ==" 21 | system "bin/rake db:setup" 22 | 23 | puts "\n== Removing old logs and tempfiles ==" 24 | system "rm -f log/*" 25 | system "rm -rf tmp/cache" 26 | 27 | puts "\n== Restarting application server ==" 28 | system "touch tmp/restart.txt" 29 | end 30 | -------------------------------------------------------------------------------- /frontend/components/votes/vote.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Vote = (props) => { 4 | const upvoteClass = (props.userVote === 1) ? 5 | "upvote-icon selected" : "upvote-icon"; 6 | const downvoteClass = (props.userVote === -1) ? 7 | "downvote-icon selected" : "downvote-icon"; 8 | let countClass; 9 | if (props.numVotes < 0) { 10 | countClass = "vote-count negative"; 11 | } else if (props.numVotes > 0){ 12 | countClass = "vote-count positive"; 13 | } else { 14 | countClass = "vote-count"; 15 | } 16 | 17 | return( 18 |
    19 |
    props.upvote(props.votableId)}>
    22 |
    {props.numVotes}
    23 |
    props.downvote(props.votableId)}>
    26 |
    27 | ); 28 | }; 29 | 30 | export default Vote; 31 | -------------------------------------------------------------------------------- /frontend/util/vote_api_util.js: -------------------------------------------------------------------------------- 1 | export const downvoteAnnotation = (id) => { 2 | return $.ajax({ 3 | type: "POST", 4 | url: `/api/annotations/${id}/downvote` 5 | }); 6 | }; 7 | 8 | export const upvoteAnnotation = (id) => { 9 | return $.ajax({ 10 | type: "POST", 11 | url: `/api/annotations/${id}/upvote` 12 | }); 13 | }; 14 | 15 | export const downvoteComment = (id) => { 16 | return $.ajax({ 17 | type: "POST", 18 | url: `/api/comments/${id}/downvote` 19 | }); 20 | }; 21 | 22 | export const upvoteComment = (id) => { 23 | return $.ajax({ 24 | type: "POST", 25 | url: `/api/comments/${id}/upvote` 26 | }); 27 | }; 28 | 29 | export const downvoteSuggestion = (id) => { 30 | return $.ajax({ 31 | type: "POST", 32 | url: `/api/suggestions/${id}/downvote` 33 | }); 34 | }; 35 | 36 | export const upvoteSuggestion = (id) => { 37 | return $.ajax({ 38 | type: "POST", 39 | url: `/api/suggestions/${id}/upvote` 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /frontend/util/opinion_api_util.js: -------------------------------------------------------------------------------- 1 | export const fetchAllOpinions = () => { 2 | return $.ajax({ 3 | type: "GET", 4 | url: "/api/opinions" 5 | }); 6 | }; 7 | 8 | export const fetchSingleOpinion = (id) => { 9 | return $.ajax({ 10 | type: "GET", 11 | url: `/api/opinions/${id}` 12 | }); 13 | }; 14 | 15 | export const createOpinion = (opinion) => { 16 | return $.ajax({ 17 | type: "POST", 18 | url: "/api/opinions", 19 | data: { opinion } 20 | }); 21 | }; 22 | 23 | export const editOpinion = (opinion) => { 24 | return $.ajax({ 25 | type: "PATCH", 26 | url: `/api/opinions/${opinion.id}`, 27 | data: { opinion } 28 | }); 29 | }; 30 | 31 | export const deleteOpinion = (id) => { 32 | return $.ajax({ 33 | type: "DELETE", 34 | url: `/api/opinions/${id}` 35 | }); 36 | }; 37 | 38 | export const searchOpinions = (query) => { 39 | return $.ajax({ 40 | type: "GET", 41 | url: '/api/opinions/search', 42 | data: {query} 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | namespace :api, defaults: { format: :json } do 3 | resources :users, only: [:create] 4 | resource :session, only: [:create, :destroy] 5 | resources :opinions, only: [:index, :show, :create, :update, :destroy] do 6 | collection do 7 | get 'search' 8 | end 9 | end 10 | resources :courts, only: [:index] 11 | resources :judges, only: [:index] 12 | 13 | resources :annotations, only: [:create, :show, :update, :destroy] do 14 | member do 15 | post "downvote" 16 | post "upvote" 17 | end 18 | end 19 | 20 | resources :suggestions, only: [:create, :update, :destroy] do 21 | member do 22 | post "downvote" 23 | post "upvote" 24 | end 25 | end 26 | 27 | resources :comments, only: [:create, :destroy] do 28 | member do 29 | post "downvote" 30 | post "upvote" 31 | end 32 | end 33 | end 34 | 35 | root "static_pages#root" 36 | end 37 | -------------------------------------------------------------------------------- /docs/api-endpoints.md: -------------------------------------------------------------------------------- 1 | # API Endpoints 2 | 3 | ## HTML API 4 | 5 | ### Root 6 | 7 | - `GET /` - loads React web app 8 | 9 | ## JSON API 10 | 11 | ### Users 12 | 13 | - `POST /api/users` 14 | - `PATCH /api/users` 15 | 16 | ### Session 17 | 18 | - `POST /api/session` 19 | - `DELETE /api/session` 20 | 21 | ### Opinions 22 | 23 | - `GET /api/opinions` 24 | - Opinion index 25 | - `GET /api/opinions/:id` 26 | - Opinion detail 27 | - `POST /api/opinions` 28 | - `PATCH /api/opinions/:id` 29 | - `DELETE /api/opinions/:id` 30 | 31 | ### Annotations 32 | 33 | - `POST /api/annotations` 34 | - `GET /api/annotations/:id` 35 | - `DELETE /api/annotations/:id` 36 | - `PATCH /api/annotations/:id` 37 | 38 | ### Suggestions 39 | 40 | - `POST /api/suggestions` 41 | - `DELETE /api/suggestions/:id` 42 | - `PATCH /api/suggestions/:id` 43 | 44 | ### Comments 45 | 46 | - `POST /api/comments` 47 | - `DELETE /api/comments/:id` 48 | - `PATCH /api/comments/:id` 49 | 50 | ### Votes 51 | 52 | - `POST /api/votes` 53 | - `DELETE /api/votes` 54 | -------------------------------------------------------------------------------- /frontend/components/root.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { Router, Route, hashHistory, IndexRoute } from 'react-router'; 4 | 5 | import App from './app'; 6 | import Home from './home'; 7 | import OpinionIndexContainer from './opinions/opinion_index_container'; 8 | import OpinionCreateFormContainer from './opinions/opinion_create_form_container'; 9 | import OpinionDetailContainer from './opinions/opinion_detail_container'; 10 | 11 | 12 | const Root = ({ store }) => ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 21 | 22 | 23 | 24 | ); 25 | 26 | export default Root; 27 | -------------------------------------------------------------------------------- /app/views/api/opinions/show.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.extract! @opinion, :id, :case, :citation, :body, :transcriber_id 2 | json.court @opinion.court.name 3 | json.judge @opinion.judge.name 4 | json.date @opinion.date.strftime("%B %d, %Y") 5 | json.transcriber @opinion.transcriber.username 6 | json.image_url @opinion.use_image.url(:large) 7 | json.annotations do 8 | @opinion.annotations 9 | .each do |anno| 10 | json.set! anno.id do 11 | json.extract! anno, :id, :start_idx, :length 12 | end 13 | end 14 | end 15 | 16 | json.comments do 17 | @opinion.comments 18 | .includes(:user, :votes) 19 | .each do |comment| 20 | json.set! comment.id do 21 | json.extract! comment, :id, :body, :opinion_id, :created_at 22 | json.numVotes comment.num_votes 23 | json.userVote comment.user_vote(current_user.id) if logged_in? 24 | json.user do 25 | json.extract! comment.user, :id, :username 26 | json.thumb comment.user.avatar.url(:thumb) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rake secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: 8279e3bff1e34bcaba3315993db451ade1b7a2b859e1f3e19a5c7388f0fdfe3a4c689ad887db0377702373e7efa25b087aa11af1f42d9d1b78ec886e783afb22 15 | 16 | test: 17 | secret_key_base: d190ed598eba0433d9c559d15e2027384f686a646e875bdd0b85c6800e81141c11538081d687b9941c027e88a2b42da7d1b669ff11f9431332d176bd721834c2 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | -------------------------------------------------------------------------------- /app/assets/stylesheets/vote.scss: -------------------------------------------------------------------------------- 1 | .vote-buttons { 2 | padding: 10px 0px 5px 0px; 3 | display: block; 4 | } 5 | 6 | .vote-buttons .vote-count { 7 | float: left; 8 | height: 16px; 9 | font-weight: 700; 10 | color: #828282; 11 | width: 30px; 12 | text-align: center; 13 | } 14 | 15 | .vote-count.negative { 16 | color: #D80027; 17 | } 18 | 19 | .vote-count.positive { 20 | color: #39c732; 21 | } 22 | 23 | .upvote-icon, .downvote-icon { 24 | height: 16px; 25 | width: 16px; 26 | cursor: pointer 27 | } 28 | 29 | .upvote-icon { 30 | background-image: image-url("icons/thumb-up-button.svg"); 31 | background-size: contain; 32 | float: left; 33 | } 34 | 35 | .downvote-icon { 36 | background-image: image-url("icons/thumb-down-button.svg"); 37 | background-size: contain; 38 | float: left; 39 | } 40 | 41 | .upvote-icon.selected, .upvote-icon:hover { 42 | background-image: image-url("icons/thumb-up-button-selected.svg"); 43 | } 44 | 45 | .downvote-icon.selected, .downvote-icon:hover { 46 | background-image: image-url("icons/thumb-down-button-selected.svg"); 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CaseNote", 3 | "version": "1.0.0", 4 | "description": "This README would normally document whatever steps are necessary to get the application up and running.", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1", 11 | "webpack": "webpack --watch", 12 | "heroku-postbuild": "webpack --config webpack.config.prod.js" 13 | }, 14 | "engines": { 15 | "node": "6.2.1", 16 | "npm": "3.9.3" 17 | }, 18 | "keywords": [], 19 | "author": "", 20 | "license": "ISC", 21 | "dependencies": { 22 | "babel-core": "^6.18.2", 23 | "babel-loader": "^6.2.8", 24 | "babel-preset-es2015": "^6.18.0", 25 | "babel-preset-react": "^6.16.0", 26 | "parchment": "^1.0.5", 27 | "quill": "^1.1.6", 28 | "quill-delta": "^3.4.3", 29 | "react": "^15.4.1", 30 | "react-dom": "^15.4.1", 31 | "react-modal": "^1.6.1", 32 | "react-redux": "^4.4.6", 33 | "react-router": "^3.0.0", 34 | "redux": "^3.6.0", 35 | "webpack": "^1.13.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/components/comments/comment_index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { toArray } from '../../util/selectors'; 3 | import { timeSince } from '../../util/date'; 4 | import VoteContainer from '../votes/vote_container'; 5 | import Thumb from '../header/thumb'; 6 | 7 | const CommentIndex = (props) => { 8 | if (!props.comments) return null; 9 | const comments = toArray(props.comments); 10 | return ( 11 | 28 | ); 29 | }; 30 | 31 | export default CommentIndex; 32 | -------------------------------------------------------------------------------- /app/assets/stylesheets/index.scss: -------------------------------------------------------------------------------- 1 | .opinions-index { 2 | max-width: 900px; 3 | margin: 0 auto; 4 | display: block; 5 | } 6 | 7 | .opinions-index h3 { 8 | font-size: 12px; 9 | padding: 15px 0px; 10 | font-weight: 700; 11 | text-transform: uppercase; 12 | border-bottom: 4px solid #000; 13 | } 14 | 15 | .opinion-index-item { 16 | border-bottom: 1px solid #ccc; 17 | } 18 | 19 | .opinion-index-item a div { 20 | font-weight: 700; 21 | float: left; 22 | font-size: 24px; 23 | padding: 10px 16px; 24 | margin: 15px; 25 | border-radius: 50%; 26 | border: 4px solid #000; 27 | } 28 | 29 | .opinion-index-item:nth-child(n+10) a div { 30 | padding: 10px 9px; 31 | } 32 | 33 | .opinion-index-item img { 34 | float: left; 35 | height: 87px; 36 | width: 87px; 37 | object-fit: cover; 38 | } 39 | 40 | .opinion-index-item .cite, .opinion-index-item:nth-child(n+10) .cite { 41 | border: 0; 42 | padding: 18px; 43 | margin: 0; 44 | 45 | } 46 | 47 | .opinion-index-item .full-cite { 48 | font-weight: 700; 49 | font-size: 24px; 50 | display: block; 51 | } 52 | 53 | .opinion-index-item .court-cite { 54 | font-size: 18px; 55 | display: block; 56 | } 57 | -------------------------------------------------------------------------------- /frontend/actions/comment_actions.js: -------------------------------------------------------------------------------- 1 | import * as APIUtil from '../util/comment_api_util'; 2 | import { clearErrors } from './general_actions'; 3 | 4 | export const RECEIVE_COMMENT = "RECEIVE_COMMENT"; 5 | export const RECEIVE_COMMENT_ERRORS = "RECEIVE_COMMENT_ERRORS"; 6 | export const REMOVE_COMMENT = "REMOVE_COMMENT"; 7 | 8 | export const receiveComment = (comment) => ({ 9 | type: RECEIVE_COMMENT, 10 | comment 11 | }); 12 | 13 | export const receiveCommentErrors = (errors) => ({ 14 | type: RECEIVE_COMMENT_ERRORS, 15 | errors 16 | }); 17 | 18 | export const removeComment = (id) => ({ 19 | type: REMOVE_COMMENT, 20 | id 21 | }); 22 | 23 | export function createComment(comment) { 24 | return (dispatch) => { 25 | return APIUtil.createComment(comment).then( 26 | (com) => dispatch(receiveComment(com)), 27 | (errors) => dispatch(receiveCommentErrors(errors)) 28 | ); 29 | }; 30 | } 31 | 32 | export function deleteComment(id) { 33 | return (dispatch) => { 34 | return APIUtil.deleteComment(id).then( 35 | (comment) => dispatch(removeComment(comment)), 36 | (errors) => dispatch(receiveCommentErrors(errors)) 37 | ); 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /frontend/components/opinions/opinion_detail.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import OpinionDetailHeader from './opinion_detail_header'; 3 | import OpinionDetailBodyContainer from './opinion_detail_body_container'; 4 | import OpinionDetailPanel from './opinion_detail_panel'; 5 | import Loader from '../loader'; 6 | 7 | class OpinionDetail extends React.Component { 8 | componentDidMount() { 9 | this.props.fetchSingleOpinion(this.props.params.opinionId); 10 | } 11 | 12 | componentWillReceiveProps(nextProps) { 13 | if (this.props.params.opinionId !== nextProps.params.opinionId) 14 | this.props.fetchSingleOpinion(nextProps.params.opinionId); 15 | } 16 | 17 | componentWillUnmount() { 18 | this.props.clearOpinion(); 19 | } 20 | 21 | render() { 22 | const { opinion } = this.props; 23 | if (Object.getOwnPropertyNames(opinion).length === 0) { 24 | return ; 25 | } else { 26 | return ( 27 |
    28 | 29 | 30 |
    31 | ); 32 | } 33 | } 34 | 35 | } 36 | 37 | export default OpinionDetail; 38 | -------------------------------------------------------------------------------- /app/assets/images/icons/thumb-down-button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /frontend/components/votes/vote_container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import Vote from './vote'; 3 | import { 4 | downvoteAnnotation, 5 | upvoteAnnotation, 6 | downvoteSuggestion, 7 | upvoteSuggestion, 8 | downvoteComment, 9 | upvoteComment 10 | } from '../../actions/vote_actions'; 11 | 12 | const mapStateToProps = (state, ownProps) => ({ 13 | numVotes: ownProps.numVotes, 14 | userVote: ownProps.userVote, 15 | votableId: ownProps.votableId 16 | }); 17 | 18 | const mapDispatchToProps = (dispatch, ownProps) => { 19 | switch(ownProps.votableType) { 20 | case "Annotation": 21 | return { 22 | downvote: (id) => dispatch(downvoteAnnotation(id)), 23 | upvote: (id) => dispatch(upvoteAnnotation(id)) 24 | }; 25 | case "Suggestion": 26 | return { 27 | downvote: (id) => dispatch(downvoteSuggestion(id)), 28 | upvote: (id) => dispatch(upvoteSuggestion(id)) 29 | }; 30 | case "Comment": 31 | return { 32 | downvote: (id) => dispatch(downvoteComment(id)), 33 | upvote: (id) => dispatch(upvoteComment(id)) 34 | }; 35 | } 36 | }; 37 | 38 | export default connect(mapStateToProps, mapDispatchToProps)(Vote); 39 | -------------------------------------------------------------------------------- /app/assets/images/icons/thumb-up-button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /app/assets/images/icons/thumb-down-button-selected.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /app/assets/images/icons/thumb-up-button-selected.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /app/assets/stylesheets/comments.scss: -------------------------------------------------------------------------------- 1 | .opinion-comment-section { 2 | background: rgba(204,204,204,.2); 3 | padding: 10px; 4 | margin: 20px 0px; 5 | } 6 | 7 | .opinion-comment-section textarea { 8 | font-size: 16px; 9 | float: left; 10 | width: 470px; 11 | background: #fff; 12 | padding: 5px; 13 | border: 1px solid #ccc; 14 | margin-bottom: 10px; 15 | } 16 | 17 | .opinion-comment-section button { 18 | margin-bottom: 10px; 19 | float: left; 20 | } 21 | 22 | .opinion-comment-section .comment-form { 23 | padding-bottom: 10px; 24 | } 25 | 26 | .full-mode textarea { 27 | height: 80px; 28 | width: 530px; 29 | } 30 | 31 | .opinion-comment-section .comment-index { 32 | border-top: 1px solid #99a7ee; 33 | } 34 | 35 | .comment-index .comment-item { 36 | padding: 10px 0px; 37 | border-bottom: 1px solid #99a7ee; 38 | } 39 | 40 | .comment-item header { 41 | margin-bottom: 10px; 42 | } 43 | 44 | .comment-item h4 { 45 | float: left; 46 | font-weight: 700; 47 | } 48 | 49 | .comment-item header > span { 50 | float: right; 51 | font-size: 12px; 52 | color: #aaa; 53 | } 54 | 55 | .comment-form .user-icon { 56 | margin: 12px 5px; 57 | } 58 | 59 | .full-mode.comment-form .user-icon { 60 | display: none; 61 | } 62 | -------------------------------------------------------------------------------- /frontend/components/annotations/annotation_detail_container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { 3 | fetchAnnotation, 4 | editAnnotation, 5 | deleteAnnotation, 6 | clearAnnotation 7 | } from '../../actions/annotation_actions'; 8 | import { fetchSingleOpinion } from '../../actions/opinion_actions'; 9 | import AnnotationDetail from './annotation_detail'; 10 | 11 | const mapStateToProps = (state, ownProps) => { 12 | const annotationId = (ownProps.annotationId) ? ownProps.annotationId : state.annotationDetail.id ; 13 | return ({ 14 | annotationId: ownProps.annotationId, 15 | currentUser: state.currentUser, 16 | annotationDetail: state.annotationDetail, 17 | locationY: ownProps.location, 18 | }); 19 | }; 20 | 21 | const mapDispatchToProps = (dispatch) => ({ 22 | fetchAnnotation: (id) => dispatch(fetchAnnotation(id)), 23 | editAnnotation: (annotation) => dispatch(editAnnotation(annotation)), 24 | deleteAnnotation: (id) => dispatch(deleteAnnotation(id)), 25 | fetchSingleOpinion: (id) => dispatch(fetchSingleOpinion(id)), 26 | clearAnnotation: () => dispatch(clearAnnotation()) 27 | }); 28 | 29 | export default connect( 30 | mapStateToProps, 31 | mapDispatchToProps 32 | )(AnnotationDetail); 33 | -------------------------------------------------------------------------------- /docs/component-hierarchy.md: -------------------------------------------------------------------------------- 1 | ## Component Hierarchy 2 | 3 | **AuthFormContainer** 4 | - AuthForm 5 | 6 | **AppContainer** 7 | - Header 8 | - Nav 9 | - Footer 10 | 11 | **HomeContainer** 12 | - TopOpinions 13 | - LatestOpinions 14 | 15 | **OpinionIndexContainer** 16 | - OpinionIndex 17 | + OpinionIndexItem 18 | 19 | **OpinionDetailContainer** 20 | - OpinionHeader 21 | - OpinionText 22 | - AnnotationContainer 23 | - CommentForm 24 | - CommentIndex 25 | + CommentDetail 26 | - Upvote 27 | 28 | **AnnotationContainer** 29 | - AnnotationDetail 30 | + AnnotationHeader 31 | - AnnotationBody 32 | - AnnotationEditForm 33 | - UpvoteDetail 34 | - SuggestionForm 35 | - SuggestionIndex 36 | + SuggestionDetail 37 | - Upvote 38 | 39 | **AnnotationFormContainer** 40 | - AnnotationForm 41 | 42 | **OpinionFormContainer** 43 | - OpinionForm 44 | 45 | ## Routes 46 | 47 | |Path | Component | 48 | |-------|-------------| 49 | | "/" | "AppContainer" | 50 | | "/home" | "HomeContainer" | 51 | | "/index" | "OpinionIndexContainer" | 52 | | "/opinions/:opinionID" | "OpinionDetailContainer" | 53 | | "/opinions/:opinionID/annotations/:annotationID" | "AnnotationContainer" | 54 | | "/opinions/:opinionId/annotations/new" | "AnnotationFormContainer" | 55 | | "/new" | "OpinionFormContainer" | 56 | -------------------------------------------------------------------------------- /app/assets/stylesheets/reset.scss: -------------------------------------------------------------------------------- 1 | body, h1, p, h2, h3, h4, h5, li, ul, ol, a, div, input, span, textarea { 2 | padding: 0; 3 | margin: 0; 4 | border: 0; 5 | color: inherit; 6 | text-decoration: inherit; 7 | text-align: inherit; 8 | vertical-align: inherit; 9 | box-sizing: inherit; 10 | font: inherit; 11 | background: transparent; 12 | } 13 | 14 | ul, ol, li { 15 | list-style: none; 16 | } 17 | 18 | body { 19 | font-family: 'Lato', sans-serif; 20 | background: #eee; 21 | } 22 | 23 | img { 24 | display: block; 25 | width: 100%; 26 | height: auto; 27 | } 28 | 29 | .group:after { 30 | content: ""; 31 | clear: both; 32 | display: block; 33 | } 34 | 35 | .gradient:before { 36 | content: ""; 37 | position: absolute; 38 | height: 100%; 39 | width: 100%; 40 | top: 0; 41 | left: 0; 42 | background-image: linear-gradient(to bottom,rgba(0,0,0,.2) 30%,#000 100%); 43 | z-index: 0; 44 | } 45 | 46 | a { 47 | cursor: pointer; 48 | } 49 | 50 | *:focus { 51 | outline: none; 52 | } 53 | 54 | .loader { 55 | margin: 0 auto; 56 | margin-top: 20px; 57 | border: 8px solid #ccc; 58 | border-top: 8px solid #aaa; 59 | border-radius: 50%; 60 | width: 50px; 61 | height: 50px; 62 | animation: spin 2s linear infinite; 63 | } 64 | 65 | @keyframes spin { 66 | 0% { transform: rotate(0deg); } 67 | 100% { transform: rotate(360deg); } 68 | } 69 | -------------------------------------------------------------------------------- /app/controllers/api/comments_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::CommentsController < ApplicationController 2 | before_action :check_logged_in, only: [:create, :destroy, :downvote, :upvote] 3 | def create 4 | @comment = Comment.new(comment_params) 5 | @comment.user_id = current_user.id 6 | if @comment.save 7 | render :show 8 | else 9 | render json: @comment.errors.messages, status: 422 10 | end 11 | end 12 | 13 | def destroy 14 | @comment = Comment.find(params[:id]) 15 | if @comment.destroy 16 | render json: @comment.id 17 | else 18 | render json: @comment.errors.messages, status: 422 19 | end 20 | end 21 | 22 | def downvote; vote(-1); end 23 | def upvote; vote(1); end 24 | 25 | private 26 | 27 | def comment_params 28 | params.require(:comment).permit(:opinion_id, :body) 29 | end 30 | 31 | def vote(direction) 32 | @comment = Comment.find(params[:id]) 33 | @vote = Vote.find_by_votable(@comment, current_user.id) 34 | 35 | if @vote 36 | if @vote.status == direction 37 | @vote.destroy 38 | render json: 0 39 | else 40 | @vote.update(status: direction) 41 | render json: @vote.status 42 | end 43 | else 44 | @comment.votes.create!(user_id: current_user.id, status: direction) 45 | render json: direction 46 | end 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /app/assets/stylesheets/home.scss: -------------------------------------------------------------------------------- 1 | .landing-page { 2 | height: 100vh; 3 | width:100%; 4 | position: relative; 5 | background-color: #ffff64; 6 | background-image: image_url("scales.png"); 7 | background-size: 20%; 8 | background-repeat: no-repeat; 9 | overflow: hidden; 10 | background-position: 90% 90%; 11 | } 12 | 13 | .landing-page .landing-page-greeting { 14 | position: absolute; 15 | top: 10%; 16 | left: 0; 17 | right: 0; 18 | margin-left: auto; 19 | margin-right: auto; 20 | width: 1000px; 21 | font-size: 48px; 22 | font-weight: 700; 23 | white-space: pre-wrap; 24 | text-align: center; 25 | } 26 | 27 | .search-container { 28 | position: absolute; 29 | top: 32%; 30 | left: 0; 31 | right: 0; 32 | margin-left: auto; 33 | margin-right: auto; 34 | width: 900px; 35 | } 36 | 37 | .search-container input { 38 | background-color: #fff; 39 | padding: 10px; 40 | font-size: 18px; 41 | width: 100%; 42 | } 43 | 44 | .search-result-list { 45 | max-height: 250px; 46 | } 47 | 48 | .search-result-list a, .no-results { 49 | padding: 10px; 50 | background: #fff; 51 | width: 100%; 52 | display: block; 53 | border-top: 1px solid #aaa; 54 | font-size: 18px; 55 | } 56 | 57 | .search-result-case { 58 | font-style: italic; 59 | } 60 | 61 | .search-result-name { 62 | font-size: 18px; 63 | } 64 | 65 | .search-result-list span { 66 | padding-right: 5px 67 | } 68 | -------------------------------------------------------------------------------- /frontend/reducers/errors_reducer.js: -------------------------------------------------------------------------------- 1 | import { merge } from 'lodash'; 2 | import { 3 | RECEIVE_SIGNUP_ERRORS, 4 | RECEIVE_LOGIN_ERRORS, 5 | RECEIVE_CURRENT_USER 6 | } from '../actions/session_actions'; 7 | import { 8 | RECEIVE_ALL_OPINIONS, 9 | RECEIVE_SINGLE_OPINION, 10 | RECEIVE_OPINION_ERRORS 11 | } from '../actions/opinion_actions'; 12 | import { 13 | RECEIVE_ANNOTATION, 14 | RECEIVE_ANNOTATION_ERRORS 15 | } from '../actions/annotation_actions'; 16 | 17 | const defaultState = { 18 | signup: { }, 19 | signin: { }, 20 | opinion: { }, 21 | annotation: { }, 22 | suggestion: { }, 23 | comment: { }, 24 | }; 25 | 26 | const errorsReducer = (state = defaultState, action) => { 27 | Object.freeze(state); 28 | let newState = merge({}, state); 29 | switch(action.type) { 30 | case RECEIVE_LOGIN_ERRORS: 31 | newState["signin"] = action.errors; 32 | return newState; 33 | case RECEIVE_SIGNUP_ERRORS: 34 | newState["signup"] = action.errors; 35 | return newState; 36 | case RECEIVE_OPINION_ERRORS: 37 | newState["opinion"] = action.errors; 38 | return newState; 39 | case RECEIVE_ANNOTATION_ERRORS: 40 | newState["annotation"] = action.errors; 41 | return newState; 42 | case RECEIVE_ANNOTATION: 43 | case RECEIVE_CURRENT_USER: 44 | case RECEIVE_ALL_OPINIONS: 45 | case RECEIVE_SINGLE_OPINION: 46 | return merge({}, defaultState); 47 | default: 48 | return newState; 49 | } 50 | }; 51 | 52 | export default errorsReducer; 53 | -------------------------------------------------------------------------------- /frontend/actions/session_actions.js: -------------------------------------------------------------------------------- 1 | import * as APIUtil from '../util/session_api_util'; 2 | import { clearErrors } from './general_actions'; 3 | 4 | export const RECEIVE_CURRENT_USER = "RECEIVE_CURRENT_USER"; 5 | export const RECEIVE_SIGNUP_ERRORS = "RECEIVE_SIGNUP_ERRORS"; 6 | export const RECEIVE_LOGIN_ERRORS = "RECEIVE_LOGIN_ERRORS"; 7 | 8 | export const receiveCurrentUser = (user) => ({ 9 | type: RECEIVE_CURRENT_USER, 10 | user 11 | }); 12 | 13 | export const receiveSignupErrors = (errors) => ({ 14 | type: RECEIVE_SIGNUP_ERRORS, 15 | errors 16 | }); 17 | 18 | export const receiveLoginErrors = (errors) => ({ 19 | type: RECEIVE_LOGIN_ERRORS, 20 | errors 21 | }); 22 | 23 | export function signup(user) { 24 | return (dispatch) => { 25 | return APIUtil.signup(user).then( 26 | (currentUser) => dispatch(receiveCurrentUser(currentUser)), 27 | (errors) => dispatch(receiveSignupErrors(errors.responseJSON)) 28 | ); 29 | }; 30 | } 31 | 32 | export function login(user) { 33 | return (dispatch) => { 34 | return APIUtil.login(user).then( 35 | (currentUser) => dispatch(receiveCurrentUser(currentUser)), 36 | (errors) => dispatch(receiveLoginErrors(errors.responseJSON)) 37 | ); 38 | }; 39 | } 40 | 41 | export function logout() { 42 | return (dispatch) => { 43 | return APIUtil.logout().then( 44 | () => { 45 | dispatch(receiveCurrentUser(null)); 46 | location.reload(); 47 | }, 48 | (errors) => dispatch(receiveLoginErrors(errors.responseJSON)) 49 | ); 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /frontend/actions/suggestion_actions.js: -------------------------------------------------------------------------------- 1 | import * as APIUtil from '../util/suggestion_api_util'; 2 | import { clearErrors } from './general_actions'; 3 | 4 | export const RECEIVE_SUGGESTION = "RECEIVE_SUGGESTION"; 5 | export const RECEIVE_SUGGESTION_ERRORS = "RECEIVE_SUGGESTION_ERRORS"; 6 | export const REMOVE_SUGGESTION = "REMOVE_SUGGESTION"; 7 | 8 | export const receiveSuggestion = (suggestion) => ({ 9 | type: RECEIVE_SUGGESTION, 10 | suggestion 11 | }); 12 | 13 | export const receiveSuggestionErrors = (errors) => ({ 14 | type: RECEIVE_SUGGESTION_ERRORS, 15 | errors 16 | }); 17 | 18 | export const removeSuggestion = (id) => ({ 19 | type: REMOVE_SUGGESTION, 20 | id 21 | }); 22 | 23 | 24 | export function createSuggestion(suggestion) { 25 | return (dispatch) => { 26 | return APIUtil.createSuggestion(suggestion).then( 27 | (sugg) => dispatch(receiveSuggestion(sugg)), 28 | (errors) => dispatch(receiveSuggestionErrors(errors)) 29 | ); 30 | }; 31 | } 32 | 33 | export function editSuggestion(suggestion) { 34 | return (dispatch) => { 35 | return APIUtil.editSuggestion(suggestion).then( 36 | (sugg) => dispatch(receiveSuggestion(sugg)), 37 | (errors) => dispatch(receiveSuggestionErrors(errors)) 38 | ); 39 | }; 40 | } 41 | 42 | export function deleteSuggestion(id) { 43 | return (dispatch) => { 44 | return APIUtil.deleteSuggestion(id).then( 45 | (suggestion) => dispatch(removeSuggestion(suggestion)), 46 | (errors) => dispatch(receiveSuggestionErrors(errors)) 47 | ); 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /frontend/components/session_form/modal_session_form_container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import ModalSessionForm from './modal_session_form'; 3 | import { login, signup } from '../../actions/session_actions'; 4 | import { fetchAnnotation } from '../../actions/annotation_actions'; 5 | import { fetchSingleOpinion } from '../../actions/opinion_actions'; 6 | 7 | const mapStateToProps = (state) => { 8 | const loggedIn = state.currentUser === null ? false : true; 9 | const updateAnnotationDetail = 10 | (Object.getOwnPropertyNames(state.annotationDetail).length === 0) ? 11 | false : state.annotationDetail.id; 12 | const updateOpinionDetail = 13 | (Object.getOwnPropertyNames(state.opinionDetail).length === 0) ? 14 | false: state.opinionDetail.id; 15 | return { 16 | logged_in: loggedIn, 17 | formErrors: state.formErrors, 18 | updateOpinionDetail, 19 | updateAnnotationDetail 20 | }; 21 | }; 22 | 23 | const mapDispatchToProps = (dispatch, ownProps) => { 24 | let processForm; 25 | if (ownProps.formType === "signin") { 26 | processForm = (user) => dispatch(login(user)); 27 | } else { 28 | processForm = (user) => dispatch(signup(user)); 29 | } 30 | 31 | return { 32 | formType: ownProps.formType, 33 | signin: (user) => dispatch(login(user)), 34 | processForm, 35 | fetchAnnotation: (id) => dispatch(fetchAnnotation(id)), 36 | fetchSingleOpinion: (id) => dispatch(fetchSingleOpinion(id)) 37 | }; 38 | }; 39 | 40 | export default connect(mapStateToProps, mapDispatchToProps)(ModalSessionForm); 41 | -------------------------------------------------------------------------------- /app/controllers/api/opinions_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::OpinionsController < ApplicationController 2 | before_action :check_logged_in, only: [:create, :update, :destroy] 3 | 4 | def index 5 | @opinions = Opinion.all.includes(:court, :judge) 6 | render :index 7 | end 8 | 9 | def create 10 | @opinion = Opinion.new(opinion_params) 11 | @opinion.image = URI.parse(opinion_params[:image]) if opinion_params[:image] 12 | @opinion.transcriber_id = current_user.id if logged_in? 13 | if @opinion.save 14 | render :show 15 | else 16 | render json: @opinion.errors.messages, status: 422 17 | end 18 | end 19 | 20 | def show 21 | @opinion = Opinion.where(id: params[:id]).includes(:court, :judge, :transcriber, :annotations, comments: [:user, votes: [:user]]).first 22 | render :show 23 | end 24 | 25 | def update 26 | @opinion = Opinion.find(params[:id]) 27 | if @opinion.update(opinion_params) 28 | render :show 29 | else 30 | render json: @opinon.errors.messages, status: 422 31 | end 32 | end 33 | 34 | def destroy 35 | @opinion = Opinion.find(params[:id]) 36 | if @opinion.destroy 37 | render json: {} 38 | else 39 | render json: @opinion.errors.messages, status: 422 40 | end 41 | end 42 | 43 | def search 44 | @opinions = Opinion.search(params[:query]) 45 | render json: @opinions.to_json 46 | end 47 | 48 | private 49 | 50 | def opinion_params 51 | params.require(:opinion).permit(:case, :citation, :judge_id, :court_id, :date, :body, :image) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /app/controllers/api/suggestions_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::SuggestionsController < ApplicationController 2 | before_action :check_logged_in, only: [:create, :update, :destroy, :downvote, :upvote] 3 | def create 4 | @suggestion = Suggestion.new(suggestion_params) 5 | @suggestion.user_id = current_user.id 6 | if @suggestion.save 7 | render :show 8 | else 9 | render json: @suggestion.errors.messages, status: 422 10 | end 11 | end 12 | 13 | def update 14 | @suggestion = Suggestion.find(params[:id]) 15 | if @suggestion.update(suggestion_params) 16 | render :show 17 | else 18 | render json: @suggestion.errors.messages, status: 422 19 | end 20 | end 21 | 22 | def destroy 23 | @suggestion = Suggestion.find(params[:id]) 24 | if @suggestion.destroy 25 | render json: @suggestion.id 26 | else 27 | render json: @suggestion.errors.messages, status: 422 28 | end 29 | end 30 | 31 | def downvote; vote(-1); end 32 | def upvote; vote(1); end 33 | 34 | private 35 | 36 | def suggestion_params 37 | params.require(:suggestion).permit(:annotation_id, :suggestion_type, :body) 38 | end 39 | 40 | def vote(direction) 41 | @suggestion = Suggestion.find(params[:id]) 42 | @vote = Vote.find_by_votable(@suggestion, current_user.id) 43 | 44 | if @vote 45 | if @vote.status == direction 46 | @vote.destroy 47 | render json: 0 48 | else 49 | @vote.update(status: direction) 50 | render json: @vote.status 51 | end 52 | else 53 | @suggestion.votes.create!(user_id: current_user.id, status: direction) 54 | render json: direction 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
    60 |
    61 |

    We're sorry, but something went wrong.

    62 |
    63 |

    If you are the application owner check the logs for more information.

    64 |
    65 | 66 | 67 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports and disable caching. 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send. 17 | config.action_mailer.raise_delivery_errors = false 18 | 19 | # Print deprecation notices to the Rails logger. 20 | config.active_support.deprecation = :log 21 | 22 | # Raise an error on page load if there are pending migrations. 23 | config.active_record.migration_error = :page_load 24 | 25 | # Debug mode disables concatenation and preprocessing of assets. 26 | # This option may cause significant delays in view rendering with a large 27 | # number of complex assets. 28 | config.assets.debug = true 29 | 30 | # Asset digests allow you to set far-future HTTP expiration dates on all assets, 31 | # yet still be able to expire them through the digest params. 32 | config.assets.digest = true 33 | 34 | # Adds additional error checking when serving assets at runtime. 35 | # Checks for improperly declared sprockets dependencies. 36 | # Raises helpful error messages. 37 | config.assets.raise_runtime_errors = true 38 | 39 | # Raises error for missing translations 40 | # config.action_view.raise_on_missing_translations = true 41 | end 42 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
    60 |
    61 |

    The change you wanted was rejected.

    62 |

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

    63 |
    64 |

    If you are the application owner check the logs for more information.

    65 |
    66 | 67 | 68 | -------------------------------------------------------------------------------- /app/controllers/api/annotations_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::AnnotationsController < ApplicationController 2 | before_action :check_logged_in, only: [:create, :update, :destroy, :downvote, :upvote] 3 | 4 | def create 5 | @annotation = Annotation.new(annotation_params) 6 | @annotation.user_id = current_user.id if logged_in? 7 | if @annotation.save 8 | render :show 9 | else 10 | render json: @annotation.errors.messages, status: 422 11 | end 12 | end 13 | 14 | def show 15 | @annotation = Annotation.where(id: params[:id]).includes(:user).first 16 | render :show 17 | end 18 | 19 | def update 20 | @annotation = Annotation.find(params[:id]) 21 | if @annotation.update(annotation_params) 22 | render :show 23 | else 24 | render json: @annotation.errors.messages, status: 422 25 | end 26 | end 27 | 28 | def destroy 29 | @annotation = Annotation.find(params[:id]) 30 | if @annotation.destroy 31 | render json: @annotation.id 32 | else 33 | render json: @annotation.errors.messages, status: 422 34 | end 35 | end 36 | 37 | def downvote; vote(-1); end 38 | def upvote; vote(1); end 39 | 40 | private 41 | 42 | def annotation_params 43 | params.require(:annotation).permit(:body, :start_idx, :length, :opinion_id) 44 | end 45 | 46 | def vote(direction) 47 | @annotation = Annotation.find(params[:id]) 48 | @vote = Vote.find_by_votable(@annotation, current_user.id) 49 | 50 | if @vote 51 | if @vote.status == direction 52 | @vote.destroy 53 | render json: 0 54 | else 55 | @vote.update(status: direction) 56 | render json: @vote.status 57 | end 58 | else 59 | @annotation.votes.create!(user_id: current_user.id, status: direction) 60 | render json: direction 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
    60 |
    61 |

    The page you were looking for doesn't exist.

    62 |

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

    63 |
    64 |

    If you are the application owner check the logs for more information.

    65 |
    66 | 67 | 68 | -------------------------------------------------------------------------------- /frontend/components/session_form/modal_wrapper.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Modal from 'react-modal'; 3 | import ModalStyle from './modal_style'; 4 | import ModalSessionFormContainer from './modal_session_form_container'; 5 | 6 | class ModalWrapper extends React.Component { 7 | constructor(props){ 8 | super(props); 9 | this.state = { formType: 'signin' }; 10 | this.changeFormType = this.changeFormType.bind(this); 11 | } 12 | 13 | componentWillReceiveProps(newProps) { 14 | if (this.props.formType !== newProps.formType) { 15 | this.setState({ formType: newProps.formType }); 16 | } 17 | } 18 | 19 | changeFormType(newType) { 20 | this.setState({ formType: newType }); 21 | } 22 | 23 | render() { 24 | let otherOptionLink; 25 | if (this.state.formType) { 26 | let linkType = this.state.formType === 'signin' ? 27 | 'Create An Account' : 'Already Have an Account? Sign In Here'; 28 | let linkURL = this.state.formType === 'signin' ? 'signup' : 'signin'; 29 | otherOptionLink = 30 | { linkType } 31 | ; 32 | } else { 33 | otherOptionLink = null; 34 | } 35 | 36 | let { isOpen, onRequestClose } = this.props; 37 | 38 | return ( 39 | 45 | 46 | X 47 |
    e.stopPropagation() }> 48 | 52 | { otherOptionLink } 53 |
    54 |
    55 | ); 56 | 57 | } 58 | } 59 | 60 | export default ModalWrapper; 61 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure static file server for tests with Cache-Control for performance. 16 | config.serve_static_files = true 17 | config.static_cache_control = 'public, max-age=3600' 18 | 19 | # Show full error reports and disable caching. 20 | config.consider_all_requests_local = true 21 | config.action_controller.perform_caching = false 22 | 23 | # Raise exceptions instead of rendering exception templates. 24 | config.action_dispatch.show_exceptions = false 25 | 26 | # Disable request forgery protection in test environment. 27 | config.action_controller.allow_forgery_protection = false 28 | 29 | # Tell Action Mailer not to deliver emails to the real world. 30 | # The :test delivery method accumulates sent emails in the 31 | # ActionMailer::Base.deliveries array. 32 | config.action_mailer.delivery_method = :test 33 | 34 | # Randomize the order test cases are executed. 35 | config.active_support.test_order = :random 36 | 37 | # Print deprecation notices to the stderr. 38 | config.active_support.deprecation = :stderr 39 | 40 | # Raises error for missing translations 41 | # config.action_view.raise_on_missing_translations = true 42 | end 43 | -------------------------------------------------------------------------------- /frontend/reducers/annotation_detail_reducer.js: -------------------------------------------------------------------------------- 1 | import { RECEIVE_ANNOTATION, 2 | CLEAR_ANNOTATION 3 | } from '../actions/annotation_actions'; 4 | import { RECEIVE_SUGGESTION, 5 | REMOVE_SUGGESTION 6 | } from '../actions/suggestion_actions'; 7 | import { RECEIVE_ANNOTATION_VOTE, RECEIVE_SUGGESTION_VOTE } from '../actions/vote_actions'; 8 | import { merge } from 'lodash'; 9 | 10 | const annotationDetailReducer = (state = {}, action) => { 11 | Object.freeze(state); 12 | let newState = merge({}, state); 13 | switch(action.type) { 14 | case RECEIVE_ANNOTATION: 15 | return action.annotation; 16 | case CLEAR_ANNOTATION: 17 | return {}; 18 | case RECEIVE_SUGGESTION: 19 | if (newState.hasOwnProperty("suggestions")) { 20 | Object.assign(newState.suggestions, action.suggestion); 21 | } else { 22 | newState["suggestions"] = action.suggestion; 23 | } 24 | return newState; 25 | case REMOVE_SUGGESTION: 26 | delete newState.suggestions[action.id]; 27 | return newState; 28 | case RECEIVE_ANNOTATION_VOTE: 29 | const oldAnnUserVote = newState.userVote; 30 | if (!oldAnnUserVote || oldAnnUserVote !== action.userVote) { 31 | newState.numVotes += action.userVote - oldAnnUserVote; 32 | newState.userVote = action.userVote; 33 | return newState; 34 | } else { 35 | return newState; 36 | } 37 | case RECEIVE_SUGGESTION_VOTE: 38 | const oldSugUserVote = newState.suggestions[action.id].userVote; 39 | if (!oldSugUserVote || oldSugUserVote !== action.userVote) { 40 | newState.suggestions[action.id].numVotes += action.userVote - oldSugUserVote; 41 | newState.suggestions[action.id].userVote = action.userVote; 42 | return newState; 43 | } else { 44 | return newState; 45 | } 46 | default: 47 | return newState; 48 | } 49 | }; 50 | 51 | export default annotationDetailReducer; 52 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | 4 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' 5 | gem 'rails', '4.2.7.1' 6 | # Use postgresql as the database for Active Record 7 | gem 'pg', '~> 0.15' 8 | # Use SCSS for stylesheets 9 | gem 'sass-rails', '~> 5.0' 10 | # Use Uglifier as compressor for JavaScript assets 11 | gem 'uglifier', '>= 1.3.0' 12 | # Use CoffeeScript for .coffee assets and views 13 | gem 'coffee-rails', '~> 4.1.0' 14 | # See https://github.com/rails/execjs#readme for more supported runtimes 15 | # gem 'therubyracer', platforms: :ruby 16 | 17 | # Use jquery as the JavaScript library 18 | gem 'jquery-rails' 19 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder 20 | gem 'jbuilder', '~> 2.0' 21 | # bundle exec rake doc:rails generates the API under doc/api. 22 | gem 'sdoc', '~> 0.4.0', group: :doc 23 | 24 | # Use ActiveModel has_secure_password 25 | gem 'bcrypt', '~> 3.1.7' 26 | 27 | gem 'paperclip' 28 | gem 'aws-sdk' 29 | gem "figaro" 30 | 31 | # Use Unicorn as the app server 32 | # gem 'unicorn' 33 | 34 | # Use Capistrano for deployment 35 | # gem 'capistrano-rails', group: :development 36 | group :production do 37 | gem 'rails_12factor' 38 | end 39 | 40 | group :test do 41 | gem 'faker' 42 | gem 'capybara' 43 | gem 'guard-rspec' 44 | gem 'launchy' 45 | end 46 | 47 | group :development, :test do 48 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console 49 | gem 'byebug' 50 | gem 'better_errors' 51 | gem 'binding_of_caller' 52 | gem 'pry-rails' 53 | gem 'annotate' 54 | gem 'factory_girl_rails' 55 | gem 'rspec-rails' 56 | end 57 | 58 | group :development do 59 | # Access an IRB console on exception pages or by using <%= console %> in views 60 | gem 'web-console', '~> 2.0' 61 | 62 | # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring 63 | gem 'spring' 64 | end 65 | -------------------------------------------------------------------------------- /app/assets/stylesheets/suggestion_form_index.scss: -------------------------------------------------------------------------------- 1 | .suggestions-container { 2 | margin: 20px 0px; 3 | background-color: rgba(204,204,204,.2); 4 | padding: 10px; 5 | } 6 | 7 | .suggestion-form { 8 | margin-bottom: 10px; 9 | } 10 | 11 | .suggestions-container textarea { 12 | display: block; 13 | background: #fff; 14 | border: 1px solid #aaa; 15 | width: 328px; 16 | padding: 5px; 17 | } 18 | 19 | .suggestion-form-radios { 20 | display: flex; 21 | flex-wrap: wrap; 22 | justify-content: space-between; 23 | margin-bottom: 10px; 24 | } 25 | 26 | .suggestion-form-radio-option { 27 | width: 170px; 28 | font-size: 16px; 29 | } 30 | 31 | .suggestion-form-radio-option input { 32 | width: 20px; 33 | height: 20px; 34 | margin: 5px; 35 | float: left; 36 | } 37 | 38 | .suggestion-form-radio-option label { 39 | padding: 5px 0px; 40 | float: left; 41 | } 42 | 43 | .suggestion-form > button { 44 | margin-top: 10px; 45 | background: rgba(204,204,204,.2) 46 | } 47 | 48 | .suggestion-index { 49 | border-top: 1px solid #99a7ee; 50 | } 51 | 52 | .suggestion-item { 53 | padding: 5px 0px 10px 0px; 54 | font-size: 16px; 55 | border-bottom: 1px solid #99a7ee; 56 | } 57 | 58 | .suggestion-header { 59 | display: block; 60 | } 61 | 62 | .suggestion-header-left { 63 | float: left; 64 | width: 220px; 65 | } 66 | 67 | .suggestion-header h4 { 68 | font-weight: 700; 69 | } 70 | 71 | .suggestion-header h5, .suggestion-header > span { 72 | font-size: 14px; 73 | } 74 | 75 | .suggestion-header > span { 76 | float: right; 77 | } 78 | 79 | .suggestion-item p { 80 | padding: 10px 0px; 81 | } 82 | 83 | .suggestion-user-links span { 84 | font-size: 12px; 85 | color: #99a7ee; 86 | margin-right: 5px; 87 | cursor: pointer; 88 | text-decoration: underline; 89 | } 90 | 91 | .suggestion-item button { 92 | margin-top: 10px; 93 | } 94 | 95 | button.suggestion-sign-in { 96 | margin-bottom: 10px; 97 | background: transparent; 98 | } 99 | -------------------------------------------------------------------------------- /frontend/actions/annotation_actions.js: -------------------------------------------------------------------------------- 1 | import * as APIUtil from '../util/annotation_api_util'; 2 | import { clearErrors } from './general_actions'; 3 | 4 | export const RECEIVE_ANNOTATION = "RECEIVE_ANNOTATION"; 5 | export const RECEIVE_ANNOTATION_ERRORS = "RECEIVE_ANNOTATION_ERRORS"; 6 | export const REMOVE_ANNOTATION = "REMOVE_ANNOTATION"; 7 | export const CLEAR_ANNOTATION = "CLEAR_ANNOTATION"; 8 | 9 | export const receiveAnnotation = (annotation) => ({ 10 | type: RECEIVE_ANNOTATION, 11 | annotation 12 | }); 13 | 14 | export const receiveAnnotationErrors = (errors) => ({ 15 | type: RECEIVE_ANNOTATION_ERRORS, 16 | errors 17 | }); 18 | 19 | export const removeAnnotation = (id) => ({ 20 | type: REMOVE_ANNOTATION, 21 | id 22 | }); 23 | 24 | export const clearAnnotation = () => ({ 25 | type: CLEAR_ANNOTATION, 26 | }); 27 | 28 | export function fetchAnnotation(id) { 29 | return (dispatch) => { 30 | return APIUtil.fetchAnnotation(id).then( 31 | (annotation) => dispatch(receiveAnnotation(annotation)), 32 | (errors) => dispatch(receiveAnnotationErrors(errors)) 33 | ); 34 | }; 35 | } 36 | 37 | export function createAnnotation(annotation) { 38 | return (dispatch) => { 39 | return APIUtil.createAnnotation(annotation).then( 40 | (anno) => dispatch(receiveAnnotation(anno)), 41 | (errors) => dispatch(receiveAnnotationErrors(errors)) 42 | ); 43 | }; 44 | } 45 | 46 | export function editAnnotation(annotation) { 47 | return (dispatch) => { 48 | return APIUtil.editAnnotation(annotation).then( 49 | (anno) => dispatch(receiveAnnotation(anno)), 50 | (errors) => dispatch(receiveAnnotationErrors(errors)) 51 | ); 52 | }; 53 | } 54 | 55 | export function deleteAnnotation(id) { 56 | return (dispatch) => { 57 | return APIUtil.deleteAnnotation(id).then( 58 | (annotation) => dispatch(removeAnnotation(annotation)), 59 | (errors) => dispatch(receiveAnnotationErrors(errors)) 60 | ); 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /app/assets/stylesheets/opinion_detail.scss: -------------------------------------------------------------------------------- 1 | .opinion-detail-header { 2 | height: 280px; 3 | background-color: #aaa; 4 | background-size: cover; 5 | background-position: center; 6 | position: relative; 7 | } 8 | 9 | .opinion-detail-header-info { 10 | width: 1000px; 11 | margin: 0 auto; 12 | padding: 36px 0px 24px 0px; 13 | position: relative; 14 | z-index: 1 15 | } 16 | 17 | .opinion-detail-header-info h2 { 18 | color: #ffff64; 19 | width: 550px; 20 | font-size: 48px; 21 | font-weight: 600; 22 | } 23 | 24 | .opinion-detail-header-info h3 { 25 | color: #fff; 26 | font-size: 20px; 27 | font-weight: 600; 28 | padding: 10px 0px; 29 | } 30 | 31 | .opinion-detail-header-info h4 { 32 | color: #aaa; 33 | font-size: 16px; 34 | margin-bottom: 2px; 35 | } 36 | 37 | .opinion-detail-main { 38 | max-width: 960px; 39 | min-height: 250px; 40 | margin: 0 auto; 41 | display: flex; 42 | 43 | padding: 20px; 44 | background-color: #fff; 45 | } 46 | 47 | #edit-editor { 48 | width: 540px; 49 | font-size: 20px; 50 | line-height: 1.6; 51 | margin-bottom: 20px 52 | } 53 | 54 | .opinion-detail-main-panel { 55 | width: 400px; 56 | padding-left: 20px; 57 | position: relative; 58 | display: flex 59 | } 60 | 61 | .opinion-detail-main-panel-img { 62 | position: absolute; 63 | top: -280px; 64 | max-width: 339px; 65 | max-height: 339px; 66 | border: 1px solid #fff; 67 | overflow: hidden; 68 | } 69 | 70 | .opinion-detail-main-panel-img img { 71 | max-width: 100%; 72 | max-height: 100%; 73 | } 74 | 75 | .edit-mode { 76 | border: 1px solid #ccc; 77 | padding: 5px; 78 | background-color: rgba(204, 204, 204, 0.2); 79 | margin-bottom: 20px; 80 | } 81 | 82 | .opinion-detail-main button { 83 | font: inherit; 84 | font-size: 16px; 85 | font-weight: 700; 86 | border: 2px solid #000; 87 | color: #000; 88 | padding: 4px 8px; 89 | cursor: pointer; 90 | background: transparent; 91 | margin-right: 10px; 92 | 93 | } 94 | 95 | .hidden-edit-button { 96 | display: hidden; 97 | } 98 | -------------------------------------------------------------------------------- /frontend/reducers/opinion_detail_reducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | RECEIVE_SINGLE_OPINION, 3 | CLEAR_OPINION 4 | } from '../actions/opinion_actions'; 5 | import { RECEIVE_COMMENT, REMOVE_COMMENT } from '../actions/comment_actions'; 6 | import { RECEIVE_ANNOTATION, 7 | REMOVE_ANNOTATION 8 | } from '../actions/annotation_actions'; 9 | import { RECEIVE_COMMENT_VOTE } from '../actions/vote_actions'; 10 | import { merge } from 'lodash'; 11 | 12 | const opinionDetailReducer = (state = {}, action) => { 13 | Object.freeze(state); 14 | let newState = merge({}, state); 15 | switch(action.type) { 16 | case RECEIVE_SINGLE_OPINION: 17 | return action.opinion; 18 | case RECEIVE_ANNOTATION: 19 | const { annotation } = action; 20 | const newAnno = { [annotation.id]: { 21 | id: annotation.id, 22 | start_idx: annotation.start_idx, 23 | length: annotation.length 24 | }}; 25 | if (newState.hasOwnProperty("annotations")) { 26 | Object.assign(newState.annotations, newAnno); 27 | } else { 28 | newState["annotations"] = newAnno; 29 | } 30 | return newState; 31 | case REMOVE_ANNOTATION: 32 | delete newState.annotations[action.id]; 33 | return newState; 34 | case RECEIVE_COMMENT: 35 | if (newState.hasOwnProperty("comments")) { 36 | Object.assign(newState.comments, action.comment); 37 | } else { 38 | newState["comments"] = action.comment; 39 | } 40 | return newState; 41 | case RECEIVE_COMMENT_VOTE: 42 | const oldUserVote = newState.comments[action.id].userVote; 43 | if (!oldUserVote || oldUserVote !== action.userVote) { 44 | newState.comments[action.id].numVotes += action.userVote - oldUserVote; 45 | newState.comments[action.id].userVote = action.userVote; 46 | return newState; 47 | } else { 48 | return newState; 49 | } 50 | case CLEAR_OPINION: 51 | return {}; 52 | case REMOVE_COMMENT: 53 | default: 54 | return newState; 55 | } 56 | }; 57 | 58 | export default opinionDetailReducer; 59 | -------------------------------------------------------------------------------- /frontend/actions/vote_actions.js: -------------------------------------------------------------------------------- 1 | import * as APIUtil from '../util/vote_api_util'; 2 | import { clearErrors } from './general_actions'; 3 | 4 | export const RECEIVE_ANNOTATION_VOTE = "RECEIVE_ANNOTATION_VOTE"; 5 | export const RECEIVE_SUGGESTION_VOTE = "RECEIVE_SUGGESTION_VOTE"; 6 | export const RECEIVE_COMMENT_VOTE = "RECEIVE_COMMENT_VOTE"; 7 | 8 | export const receiveAnnotationVote = (userVote, id) => ({ 9 | type: RECEIVE_ANNOTATION_VOTE, 10 | userVote, 11 | id 12 | }); 13 | 14 | export const receiveSuggestionVote = (userVote, id) => ({ 15 | type: RECEIVE_SUGGESTION_VOTE, 16 | userVote, 17 | id 18 | }); 19 | 20 | export const receiveCommentVote = (userVote, id) => ({ 21 | type: RECEIVE_COMMENT_VOTE, 22 | userVote, 23 | id 24 | }); 25 | 26 | export function downvoteAnnotation(id) { 27 | return (dispatch) => { 28 | return APIUtil.downvoteAnnotation(id).then( 29 | (userVote) => dispatch(receiveAnnotationVote(userVote, id)) 30 | ); 31 | }; 32 | } 33 | 34 | export function upvoteAnnotation(id) { 35 | return (dispatch) => { 36 | return APIUtil.upvoteAnnotation(id).then( 37 | (userVote) => dispatch(receiveAnnotationVote(userVote, id)) 38 | ); 39 | }; 40 | } 41 | 42 | export function downvoteSuggestion(id) { 43 | return (dispatch) => { 44 | return APIUtil.downvoteSuggestion(id).then( 45 | (userVote) => dispatch(receiveSuggestionVote(userVote, id)) 46 | ); 47 | }; 48 | } 49 | 50 | export function upvoteSuggestion(id) { 51 | return (dispatch) => { 52 | return APIUtil.upvoteSuggestion(id).then( 53 | (userVote) => dispatch(receiveSuggestionVote(userVote, id)) 54 | ); 55 | }; 56 | } 57 | 58 | export function downvoteComment(id) { 59 | return (dispatch) => { 60 | return APIUtil.downvoteComment(id).then( 61 | (userVote) => dispatch(receiveCommentVote(userVote, id)) 62 | ); 63 | }; 64 | } 65 | 66 | export function upvoteComment(id) { 67 | return (dispatch) => { 68 | return APIUtil.upvoteComment(id).then( 69 | (userVote) => dispatch(receiveCommentVote(userVote, id)) 70 | ); 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /app/models/opinion.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: opinions 4 | # 5 | # id :integer not null, primary key 6 | # case :string not null 7 | # citation :string not null 8 | # date :date not null 9 | # body :text not null 10 | # transcriber_id :integer not null 11 | # created_at :datetime not null 12 | # updated_at :datetime not null 13 | # judge_id :integer not null 14 | # court_id :integer not null 15 | # image_file_name :string 16 | # image_content_type :string 17 | # image_file_size :integer 18 | # image_updated_at :datetime 19 | # 20 | 21 | class Opinion < ActiveRecord::Base 22 | validates :case, :citation, :judge, :court, :date, :body, :transcriber_id, presence: true 23 | 24 | belongs_to :judge 25 | belongs_to :court 26 | belongs_to :transcriber, 27 | class_name: 'User', 28 | primary_key: :id, 29 | foreign_key: :transcriber_id 30 | 31 | has_many :annotations 32 | has_many :comments 33 | 34 | has_attached_file :image, styles: { large: "600x600>", thumb: "100x100>" }, default_url: "https://s3.us-east-2.amazonaws.com/casenote-assets/default.jpg" 35 | validates_attachment_content_type :image, content_type: /\Aimage\/.*\z/ 36 | 37 | def self.search(term) 38 | Opinion 39 | .select(:id, :case, :citation, "judges.name") 40 | .joins(:judge) 41 | .where("lower(opinions.case) LIKE ? OR lower(judges.name) LIKE ? OR lower(opinions.citation) LIKE ?", "#{term.downcase}%", "#{term.downcase}%", "#{term.downcase}%") 42 | end 43 | 44 | def citation_format 45 | self.case + ", " + self.citation + " (#{self.court.citation} #{self.date.year})" 46 | end 47 | 48 | def use_image 49 | default_url = "https://s3.us-east-2.amazonaws.com/casenote-assets/default.jpg" 50 | if self.image.url == default_url 51 | judge.image.url == default_url ? self.image : judge.image 52 | else 53 | self.image 54 | end 55 | end 56 | 57 | end 58 | -------------------------------------------------------------------------------- /frontend/components/search/search.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | 4 | class Search extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | 8 | this.state = { query: "" }; 9 | this.handleChange = this.handleChange.bind(this); 10 | } 11 | 12 | handleChange(e) { 13 | if (e.currentTarget.value === "") { 14 | this.setState({ query: e.currentTarget.value }, () => { 15 | this.props.clearSearchResults(); 16 | }); 17 | } else { 18 | this.setState({ query: e.currentTarget.value }, () => { 19 | this.props.searchOpinions( this.state.query ); 20 | }); 21 | } 22 | } 23 | 24 | resultList() { 25 | let { searchResults } = this.props; 26 | 27 | if (searchResults.length === 0 && this.state.query.length > 2) { 28 | return ( 29 | 32 | ); 33 | } else if (!searchResults || searchResults.length === 0) { 34 | return null; 35 | } else { 36 | return ( 37 | 47 | ); 48 | } 49 | } 50 | 51 | render() { 52 | return ( 53 |
    54 |
    55 | 61 |
    62 | { this.resultList() } 63 |
    64 | ); 65 | } 66 | } 67 | 68 | export default Search; 69 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: users 4 | # 5 | # id :integer not null, primary key 6 | # username :string not null 7 | # password_digest :string not null 8 | # session_token :string not null 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # avatar_file_name :string 12 | # avatar_content_type :string 13 | # avatar_file_size :integer 14 | # avatar_updated_at :datetime 15 | # 16 | 17 | class User < ActiveRecord::Base 18 | validates :username, :session_token, :password_digest, presence: true 19 | validates :username, :session_token, uniqueness: true 20 | validates :password, length: { minimum: 6, allow_nil: true } 21 | 22 | attr_reader :password 23 | 24 | after_initialize :ensure_session_token 25 | 26 | has_many :opinions, 27 | class_name: 'Opinion', 28 | foreign_key: :transcriber_id, 29 | primary_key: :id 30 | 31 | has_many :annotations 32 | has_many :suggestions 33 | has_many :comments 34 | has_many :votes, inverse_of: :user 35 | 36 | has_attached_file :avatar, styles: { 37 | large: "600x600>", thumb: "100x100#" 38 | }, default_url: "https://s3.us-east-2.amazonaws.com/casenote-assets/user_default_:style.jpg" 39 | 40 | validates_attachment_content_type( 41 | :avatar, 42 | content_type: /\Aimage\/.*\Z/ 43 | ) 44 | 45 | def self.find_by_credentials(username, password) 46 | user = User.find_by(username: username) 47 | user && user.is_password?(password) ? user : nil 48 | end 49 | 50 | def password=(password) 51 | @password = password 52 | self.password_digest = BCrypt::Password.create(password) 53 | end 54 | 55 | def is_password?(password) 56 | BCrypt::Password.new(self.password_digest).is_password?(password) 57 | end 58 | 59 | def self.generate_session_token 60 | SecureRandom::urlsafe_base64(16) 61 | end 62 | 63 | def ensure_session_token 64 | self.session_token ||= self.class.generate_session_token 65 | end 66 | 67 | def reset_session_token! 68 | self.session_token = self.class.generate_session_token 69 | self.save 70 | self.session_token 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # CaseNote 2 | 3 | [Heroku link][heroku] 4 | 5 | [Trello link][trello] 6 | 7 | [heroku]: https://casenote.herokuapp.com/ 8 | [trello]: https://trello.com/b/lP3W0MMC/casenote 9 | 10 | ## Minimum Viable Product 11 | 12 | CaseNote is a web application inspired by Genius built using Ruby on Rails and React/Redux. By the end of Week 9, this app will, at a minimum, satisfy the following criteria with smooth, bug-free navigation, adequate seed data and sufficient CSS styling: 13 | 14 | - [ ] New account creation, login, and guest/demo login 15 | - [ ] Production README 16 | - [ ] Hosting on Heroku 17 | - [ ] Opinions 18 | - [ ] Annotations 19 | - [ ] Comments 20 | - [ ] Upvotes 21 | 22 | ## Design Docs 23 | * [View Wireframes][wireframes] 24 | * [React Components][components] 25 | * [API endpoints][api-endpoints] 26 | * [DB schema][schema] 27 | * [Sample State][sample-state] 28 | 29 | [wireframes]: wireframes 30 | [components]: component-hierarchy.md 31 | [sample-state]: sample-state.md 32 | [api-endpoints]: api-endpoints.md 33 | [schema]: schema.md 34 | 35 | ## Implementation Timeline 36 | 37 | ### Phase 1: Backend setup and Front End User Authentication (2 days) 38 | 39 | **Objective:** Functioning rails project with front-end Authentication 40 | 41 | ### Phase 2: Opinion Model, API, and components (2 days) 42 | 43 | **Objective:** Opinions can be created, read, edited and destroyed through the API. 44 | 45 | ### Phase 3: Annotations (2 day) 46 | 47 | **Objective:** Annotations belong to Opinions and can be created, read, edited and destroyed through the API. 48 | 49 | ### Phase 4: Comments (1 day) 50 | 51 | **Objective:** Comments belong to Opinions and Annotations and can be created, read, edited, and destroyed though the API 52 | 53 | ### Phase 5: Upvotes (1 day) 54 | 55 | **Objective:** Annotations and Comments can be upvoted or downvoted by registered users 56 | 57 | ### Phase 6: Styling (1 day) 58 | 59 | **objective:** Create header, footer, and other styling features 60 | 61 | ### Bonus Features (TBD) 62 | - [ ] Tags for Opinions 63 | - [ ] Search Opinions by case name, judge, and contents 64 | - [ ] User profiles 65 | - [ ] Complex styling of opinions to include footnotes and generation of links to related cases 66 | - [ ] Video/audio playback 67 | -------------------------------------------------------------------------------- /app/assets/stylesheets/header.scss: -------------------------------------------------------------------------------- 1 | .top-header { 2 | background-color: #ffff64; 3 | position: relative; 4 | } 5 | 6 | .header-logo { 7 | color: #ffff64; 8 | margin: 0 auto; 9 | font-size: 20px; 10 | font-weight: 500; 11 | text-shadow: -3px 3px 0px black; 12 | padding: 6px 10px 10px 10px; 13 | width: 400px; 14 | margin: 0 auto; 15 | text-align: center; 16 | text-transform: uppercase; 17 | letter-spacing: 4px; 18 | vertical-align: middle; 19 | } 20 | 21 | .header-user-links { 22 | position: absolute; 23 | right: 0; 24 | top: 0 25 | } 26 | 27 | .header-user-links li { 28 | float: left; 29 | height: 40px; 30 | } 31 | 32 | .header-user-name { 33 | padding: 14px 10px 13px 10px; 34 | font-size: 11px; 35 | text-transform: uppercase; 36 | font-weight: 500; 37 | float:left; 38 | } 39 | 40 | .header-user-links a { 41 | padding: 14px 10px 8px 10px; 42 | font-size: 11px; 43 | text-transform: uppercase; 44 | font-weight: 500; 45 | border-bottom: 5px solid #ffff64; 46 | float:left; 47 | -webkit-transition: .2s border-bottom; 48 | transition: .2s border-bottom; 49 | } 50 | 51 | .header-user-links a:hover { 52 | border-bottom: 5px solid #ff0fab; 53 | } 54 | 55 | .header-nav { 56 | background-color: #000; 57 | margin: 0 auto; 58 | text-align: center; 59 | color: #fff; 60 | text-transform: uppercase; 61 | height: 35px; 62 | position: relative; 63 | } 64 | 65 | .header-nav li { 66 | display: inline-block; 67 | } 68 | 69 | .header-nav a { 70 | position: relative; 71 | top: 6px; 72 | font-size: 12px; 73 | padding: 10px 20px; 74 | letter-spacing: 1px; 75 | font-weight: 300; 76 | } 77 | 78 | .header-nav a:hover { 79 | color: #99a7ee; 80 | } 81 | 82 | .header-user-links .icon-list-item { 83 | padding: 0; 84 | } 85 | 86 | .user-icon { 87 | overflow: hidden; 88 | border-radius: 50%; 89 | border: 1px solid #fff; 90 | margin: 7px; 91 | float:left; 92 | } 93 | 94 | .user-icon.large img { 95 | width: 24px; 96 | height: 24px; 97 | } 98 | 99 | .user-icon.small img { 100 | width: 16px; 101 | height: 16px; 102 | } 103 | 104 | .user-icon.small { 105 | display: inline; 106 | margin: 0px 10px 0px 0px; 107 | } 108 | -------------------------------------------------------------------------------- /docs/sample-state.md: -------------------------------------------------------------------------------- 1 | ```js 2 | { 3 | currentUser: { 4 | id: 1, 5 | username: "user1" 6 | }, 7 | 8 | formErrors: { 9 | signup: { errors: [] }, 10 | signin: { errors: [] }, 11 | opinion: { errors: [] }, 12 | annotation: { errors: [] }, 13 | suggestion: { errors: [] }, 14 | comment: { errors: [] }, 15 | }, 16 | 17 | opinions: [ 18 | { 19 | id: 1, 20 | case: "Marbury v. Madison", 21 | citation: "5 U.S. 137", 22 | year: 1803 23 | }, 24 | 25 | { 26 | id: 2, 27 | case: "Loving v. Virginia", 28 | citation: "388 U.S. 1, 1", 29 | year: 1967 30 | } 31 | ], 32 | 33 | opinionDetail: { 34 | id: 2, 35 | case: "Loving v. Virginia", 36 | citation: "388 U.S. 1", 37 | date: "June 12, 1967", 38 | court: "U.S. Supreme Court", 39 | body: "This case presents a constitutional question never addressed by this Court: whether a statutory scheme adopted by the State of Virginia to prevent marriages between persons solely on the basis of racial classifications violates the Equal Protection and Due Process Clauses of the Fourteenth Amendment. For reasons which seem to us to reflect the central meaning of those constitutional commands, we conclude that these statutes cannot stand consistently with the Fourteenth Amendment. . . .", 40 | comments: { 41 | 1: { 42 | id: 1, 43 | body: "Love this case!" 44 | user: "user2", 45 | votes: 1, 46 | created: "2 hours ago" 47 | } 48 | }, 49 | annotations: { 50 | 1: { 51 | id: 1, 52 | start: 34, 53 | end: 50, 54 | }, 55 | 2: { 56 | id: 2, 57 | start: 45, 58 | end: 80 59 | } 60 | } 61 | } 62 | 63 | annotationDetail: { 64 | id: 1, 65 | start: 34, 66 | end: 50, 67 | author: { id: 3, username: "user3" } 68 | annotated_content: "we conclude that these statutes cannot stand consistently with the Fourteenth Amendment", 69 | body: "this is a landmark case", 70 | votes: 6, 71 | created: "1 hour ago" 72 | suggestions: { 73 | 1: { 74 | user: "user4", 75 | type: "other", 76 | body: "this needs more detail", 77 | votes: 1, 78 | created: "50 minutes ago" 79 | } 80 | } 81 | } 82 | } 83 | 84 | ``` 85 | -------------------------------------------------------------------------------- /frontend/actions/opinion_actions.js: -------------------------------------------------------------------------------- 1 | import * as APIUtil from '../util/opinion_api_util'; 2 | import { clearErrors } from './general_actions'; 3 | import { clearSearchResults } from './search_actions'; 4 | import { withRouter } from 'react-router'; 5 | 6 | export const RECEIVE_ALL_OPINIONS = "RECEIVE_ALL_OPINIONS"; 7 | export const RECEIVE_SINGLE_OPINION = "RECEIVE_SINGLE_OPINION"; 8 | export const RECEIVE_OPINION_ERRORS = "RECEIVE_OPINION_ERRORS"; 9 | export const CLEAR_OPINION = "CLEAR_OPINION"; 10 | 11 | export const receiveAllOpinions = (opinions) => ({ 12 | type: RECEIVE_ALL_OPINIONS, 13 | opinions 14 | }); 15 | 16 | export const receiveSingleOpinion = (opinion) => ({ 17 | type: RECEIVE_SINGLE_OPINION, 18 | opinion 19 | }); 20 | 21 | export const receiveOpinionErrors = (errors) => ({ 22 | type: RECEIVE_OPINION_ERRORS, 23 | errors 24 | }); 25 | 26 | export const clearOpinion = () => ({ 27 | type: CLEAR_OPINION, 28 | }); 29 | 30 | export function fetchAllOpinions() { 31 | return (dispatch) => { 32 | return APIUtil.fetchAllOpinions().then( 33 | (opinions) => { 34 | dispatch(clearSearchResults()); 35 | dispatch(receiveAllOpinions(opinions)); 36 | }, 37 | (errors) => dispatch(receiveOpinionErrors(errors.responseJSON)) 38 | ); 39 | }; 40 | } 41 | 42 | export function fetchSingleOpinion(id) { 43 | return (dispatch) => { 44 | return APIUtil.fetchSingleOpinion(id).then( 45 | (opinion) => { 46 | dispatch(clearSearchResults()); 47 | dispatch(receiveSingleOpinion(opinion)); 48 | }, 49 | (errors) => dispatch(receiveOpinionErrors(errors.responseJSON)) 50 | ); 51 | }; 52 | } 53 | 54 | export function createOpinion(opinion) { 55 | return (dispatch) => { 56 | return APIUtil.createOpinion(opinion).then( 57 | (op) => dispatch(receiveSingleOpinion(op)), 58 | (errors) => dispatch(receiveOpinionErrors(errors.responseJSON)) 59 | ); 60 | }; 61 | } 62 | 63 | export function editOpinion(opinion) { 64 | return (dispatch) => { 65 | return APIUtil.editOpinion(opinion).then( 66 | (op) => dispatch(receiveSingleOpinion(op)), 67 | (errors) => dispatch(receiveOpinionErrors(errors.responseJSON)) 68 | ); 69 | }; 70 | } 71 | 72 | export function deleteOpinion(id) { 73 | return (dispatch) => { 74 | return APIUtil.deleteOpinion(id); 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | require "active_model/railtie" 6 | require "active_job/railtie" 7 | require "active_record/railtie" 8 | require "action_controller/railtie" 9 | require "action_mailer/railtie" 10 | require "action_view/railtie" 11 | require "sprockets/railtie" 12 | # require "rails/test_unit/railtie" 13 | 14 | # Require the gems listed in Gemfile, including any gems 15 | # you've limited to :test, :development, or :production. 16 | Bundler.require(*Rails.groups) 17 | 18 | module CaseNote 19 | class Application < Rails::Application 20 | # Settings in config/environments/* take precedence over those specified here. 21 | # Application configuration should go into files in config/initializers 22 | # -- all .rb files in that directory are automatically loaded. 23 | 24 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 25 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 26 | # config.time_zone = 'Central Time (US & Canada)' 27 | 28 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 29 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 30 | # config.i18n.default_locale = :de 31 | 32 | # Do not swallow errors in after_commit/after_rollback callbacks. 33 | config.active_record.raise_in_transactional_callbacks = true 34 | 35 | config.generators do |g| 36 | g.test_framework :rspec, 37 | :fixtures => false, 38 | :view_specs => false, 39 | :helper_specs => false, 40 | :routing_specs => false, 41 | :controller_specs => true, 42 | :request_specs => false 43 | g.fixture_replacement :factory_girl, :dir => "spec/factories" 44 | end 45 | 46 | config.paperclip_defaults = { 47 | storage: :s3, 48 | s3_protocol: 'http', 49 | s3_region: ENV['AWS_REGION'], 50 | url: 's3_domain_url', 51 | path: 'images/:class/:id.:style.:extension', 52 | s3_host_name: 's3.us-east-2.amazonaws.com', 53 | s3_credentials: { 54 | bucket: ENV['AWS_BUCKET'], #these values safely stored in application.yml thanks to figaro! 55 | access_key_id: ENV['AWS_ACCESS_KEY_ID'], 56 | secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'] 57 | } 58 | } 59 | 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /frontend/components/comments/comment_form.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ModalWrapper from '../session_form/modal_wrapper'; 3 | import Thumb from '../header/thumb'; 4 | 5 | class CommentForm extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = { 9 | formClass: "", 10 | body: "", 11 | modalOn: false 12 | }; 13 | 14 | this.handleSubmit = this.handleSubmit.bind(this); 15 | this.showFullForm = this.showFullForm.bind(this); 16 | this.update = this.update.bind(this); 17 | this.handleLogIn = this.handleLogIn.bind(this); 18 | this.modalOff = this.modalOff.bind(this); 19 | } 20 | 21 | handleSubmit(e) { 22 | e.preventDefault(); 23 | const comment = { 24 | body: this.state.body, 25 | opinion_id: this.props.opinionId 26 | }; 27 | 28 | this.props.createComment(comment).then(() => { 29 | this.setState({ formClass: "", body: ""}); 30 | }); 31 | 32 | } 33 | 34 | showFullForm(e) { 35 | e.preventDefault(); 36 | this.setState({ formClass: "full-mode" }); 37 | } 38 | 39 | modalOff() { 40 | this.setState({ modalOn: false }); 41 | } 42 | 43 | handleLogIn(e) { 44 | e.preventDefault(); 45 | this.setState({ modalOn: true }); 46 | } 47 | 48 | 49 | update(e) { 50 | this.setState({ body: e.target.value }); 51 | } 52 | 53 | render() { 54 | 55 | const button = this.state.formClass === "" ? null : ; 56 | if (!this.props.currentUser) { 57 | return ( 58 |
    59 | 60 | 64 |
    ); 65 | } else { 66 | const formClasses = `comment-form group ${this.state.formClass}`; 67 | 68 | return( 69 |
    70 |
    71 | 72 | 77 |
    78 | { button } 79 |
    80 | ); 81 | } 82 | } 83 | } 84 | 85 | export default CommentForm; 86 | -------------------------------------------------------------------------------- /docs/schema.md: -------------------------------------------------------------------------------- 1 | # Schema Information 2 | 3 | ## users 4 | column name | data type | details 5 | ----------------|-----------|----------------------- 6 | id | integer | not null, primary key 7 | username | string | not null, indexed, unique 8 | password_digest | string | not null 9 | session_token | string | not null, indexed, unique 10 | 11 | ## opinions 12 | column name | data type | details 13 | ------------|-----------|----------------------- 14 | id | integer | not null, primary key 15 | case | string | not null 16 | citation | string | not null 17 | judge | string | not null 18 | court | string | not null 19 | date | date | not null 20 | body | text | not null 21 | transcriber_id | integer | not null, foreign key (references users), indexed 22 | 23 | ## annotations 24 | column name | data type | details 25 | ------------|-----------|----------------------- 26 | id | integer | not null, primary key 27 | start_idx | integer | not null 28 | end_idx | integer | not null 29 | body | text | not null 30 | annotated_content | text | not null 31 | opinion_id | integer | not null, foreign key (references opinions), indexed 32 | author_id | integer | not null, foreign key (references users), indexed 33 | 34 | ## suggestions 35 | column name | data type | details 36 | ------------|-----------|----------------------- 37 | id | integer | not null, primary key 38 | type | string | not null 39 | body | text | not null 40 | annotation_id | integer | not null, foreign key (references annotations), indexed 41 | 42 | ## comments 43 | column name | data type | details 44 | ------------|-----------|----------------------- 45 | id | integer | not null, primary key 46 | body | text | not null 47 | opinion_id | integer | not null, foreign key (references opinions), indexed 48 | author_id | integer | not null, foreign key (references users), indexed 49 | 50 | ## votes 51 | column name | data type | details 52 | ------------|-----------|----------------------- 53 | id | integer | not null, primary key 54 | count | integer | not null, either 1 or -1 55 | user_id | integer | not null, foreign key (references users), indexed 56 | votable_id | integer | not null, foreign key (references opinions, annotations, or comments), indexed 57 | votable_type | string | not null, either "opinion", "annotation", or "comment" 58 | -------------------------------------------------------------------------------- /app/assets/stylesheets/modal.scss: -------------------------------------------------------------------------------- 1 | span.modal-close { 2 | position: fixed; 3 | cursor: pointer; 4 | right: 10px; 5 | top: 10px; 6 | color: #fff; 7 | font-size: 36px; 8 | } 9 | 10 | .ReactModal__Overlay { 11 | position: relative; 12 | z-index: 9; 13 | } 14 | 15 | .ReactModal__Overlay h2 { 16 | color: #99a7ee; 17 | text-transform: uppercase; 18 | font-size: 14px; 19 | padding-bottom: 10px; 20 | font-weight: bold; 21 | letter-spacing: 1px; 22 | border-bottom: 2px solid #99a7ee; 23 | margin-bottom: 10px; 24 | } 25 | 26 | .ReactModal__Overlay input { 27 | margin: 5px 0px 10px 0px; 28 | border: 2px solid #ccc; 29 | width: 396px; 30 | font-size: 16px; 31 | padding: 5px; 32 | background: #fff; 33 | } 34 | 35 | .ReactModal__Overlay input[type="submit"] { 36 | width: auto; 37 | background: transparent; 38 | border: 2px solid #000; 39 | color: #000; 40 | text-align: center; 41 | padding: 5px 10px; 42 | margin-bottom: 10px; 43 | } 44 | 45 | .ReactModal__Overlay a { 46 | color: #aaa; 47 | text-transform: uppercase; 48 | font-size: 14px; 49 | text-decoration: underline; 50 | font-weight: bold; 51 | letter-spacing: 1px; 52 | margin: 10px 0px; 53 | cursor: pointer; 54 | } 55 | 56 | .modal-session-form { 57 | position: relative; 58 | z-index: 10; 59 | } 60 | 61 | .username-error, .password-error { 62 | position: absolute; 63 | font-size: 14px; 64 | padding: 5px; 65 | background: #fff; 66 | border: 2px solid red; 67 | transform: translateY(-5px); 68 | width: 170px; 69 | right: 10px; 70 | } 71 | 72 | .username-error:after, .username-error:before, .password-error:after, .password-error:before { 73 | bottom: 100%; 74 | left: 50%; 75 | border: solid transparent; 76 | content: " "; 77 | height: 0; 78 | width: 0; 79 | position: absolute; 80 | pointer-events: none; 81 | } 82 | 83 | .username-error:after, .password-error:after { 84 | border-color: rgba(255, 255, 255, 0); 85 | border-bottom-color: #fff; 86 | border-width: 5px; 87 | margin-left: -5px; 88 | } 89 | 90 | .username-error:before, .password-error:before { 91 | border-color: rgba(204, 204, 204, 0); 92 | border-bottom-color: red; 93 | border-width: 7px; 94 | margin-left: -7px; 95 | } 96 | 97 | .login-error { 98 | position: absolute; 99 | font-size: 14px; 100 | padding: 5px; 101 | background: #fff; 102 | border: 2px solid red; 103 | white-space: nowrap; 104 | left: 100px; 105 | bottom: 30px; 106 | } 107 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # This file is copied to spec/ when you run 'rails generate rspec:install' 2 | ENV['RAILS_ENV'] ||= 'test' 3 | require File.expand_path('../../config/environment', __FILE__) 4 | # Prevent database truncation if the environment is production 5 | abort("The Rails environment is running in production mode!") if Rails.env.production? 6 | require 'spec_helper' 7 | require 'rspec/rails' 8 | # Add additional requires below this line. Rails is not loaded until this point! 9 | 10 | # Requires supporting ruby files with custom matchers and macros, etc, in 11 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are 12 | # run as spec files by default. This means that files in spec/support that end 13 | # in _spec.rb will both be required and run as specs, causing the specs to be 14 | # run twice. It is recommended that you do not name files matching this glob to 15 | # end with _spec.rb. You can configure this pattern with the --pattern 16 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`. 17 | # 18 | # The following line is provided for convenience purposes. It has the downside 19 | # of increasing the boot-up time by auto-requiring all files in the support 20 | # directory. Alternatively, in the individual `*_spec.rb` files, manually 21 | # require only the support files necessary. 22 | # 23 | # Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } 24 | 25 | # Checks for pending migration and applies them before tests are run. 26 | # If you are not using ActiveRecord, you can remove this line. 27 | ActiveRecord::Migration.maintain_test_schema! 28 | 29 | RSpec.configure do |config| 30 | # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures 31 | config.fixture_path = "#{::Rails.root}/spec/fixtures" 32 | 33 | # If you're not using ActiveRecord, or you'd prefer not to run each of your 34 | # examples within a transaction, remove the following line or assign false 35 | # instead of true. 36 | config.use_transactional_fixtures = true 37 | 38 | # RSpec Rails can automatically mix in different behaviours to your tests 39 | # based on their file location, for example enabling you to call `get` and 40 | # `post` in specs under `spec/controllers`. 41 | # 42 | # You can disable this behaviour by removing the line below, and instead 43 | # explicitly tag your specs with their type, e.g.: 44 | # 45 | # RSpec.describe UsersController, :type => :controller do 46 | # # ... 47 | # end 48 | # 49 | # The different available types are documented in the features, such as in 50 | # https://relishapp.com/rspec/rspec-rails/docs 51 | config.infer_spec_type_from_file_location! 52 | 53 | # Filter lines from Rails gems in backtraces. 54 | config.filter_rails_from_backtrace! 55 | # arbitrary gems may also be filtered via: 56 | # config.filter_gems_from_backtrace("gem name") 57 | end 58 | -------------------------------------------------------------------------------- /frontend/components/header/header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, withRouter } from 'react-router'; 3 | import Thumb from './thumb'; 4 | import ModalWrapper from '../session_form/modal_wrapper'; 5 | 6 | class Header extends React.Component { 7 | constructor() { 8 | super(); 9 | 10 | this.state = { modalOn: false, formType: null }; 11 | this.handleSessionClick = this.handleSessionClick.bind(this); 12 | this.modalOff = this.modalOff.bind(this); 13 | this.checkLoggedIn = this.checkLoggedIn.bind(this); 14 | this.modalOff = this.modalOff.bind(this); 15 | this.changeFormType = this.changeFormType.bind(this); 16 | } 17 | 18 | handleSessionClick(e) { 19 | e.preventDefault(); 20 | this.setState({ modalOn: true, formType: e.currentTarget.name }); 21 | } 22 | 23 | modalOff() { 24 | this.setState({ modalOn: false }); 25 | } 26 | 27 | changeFormType(newType) { 28 | this.setState({ formType: newType }); 29 | } 30 | 31 | checkLoggedIn(e) { 32 | if (this.props.currentUser) { 33 | this.props.router.push("/new"); 34 | } else { 35 | this.setState({ modalOn: true, formType: "signin"}); 36 | } 37 | } 38 | 39 | render() { 40 | const { currentUser, logout } = this.props; 41 | let userLinks; 42 | 43 | if (currentUser) { 44 | userLinks = ( 45 | 52 | ); 53 | } else { 54 | userLinks = ( 55 | 68 | ); 69 | } 70 | 71 | return ( 72 |
    73 |
    74 |

    CaseNote

    75 | { userLinks } 76 |
    77 | 78 |
      79 |
    • Home
    • 80 |
    • All Opinions
    • 81 |
    • Add Opinion
    • 82 |
    83 | 84 | 88 | 89 |
    90 | ); 91 | } 92 | } 93 | 94 | 95 | export default withRouter(Header); 96 | -------------------------------------------------------------------------------- /frontend/components/annotations/annotation_form.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Quill from 'quill'; 3 | import ModalWrapper from '../session_form/modal_wrapper'; 4 | 5 | class AnnotationForm extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = { annotateMode: false, modalOn: false }; 9 | 10 | this.handleSubmit = this.handleSubmit.bind(this); 11 | this.startAnnotateMode = this.startAnnotateMode.bind(this); 12 | this.handleLogIn = this.handleLogIn.bind(this); 13 | this.modalOff = this.modalOff.bind(this); 14 | this.focusCursor = this.focusCursor.bind(this); 15 | } 16 | 17 | componentDidUpdate() { 18 | if (document.getElementById("annoForm") && typeof(this.quill) === 'undefined') { 19 | this.quill = new Quill('#annoForm'); 20 | } 21 | } 22 | 23 | componentWillUnmount() { 24 | this.props.clearPriorRange(); 25 | } 26 | 27 | handleSubmit(e) { 28 | e.preventDefault(); 29 | this.props.clearPriorRange(); 30 | if (this.quill.getText() === "\n") { 31 | this.props.receiveAnnotationErrors({"body": ["can't be blank"]}); 32 | } else { 33 | const annotation = { 34 | body: JSON.stringify(this.quill.getContents()), 35 | start_idx: this.props.range.index, 36 | length: this.props.range.length, 37 | opinion_id: this.props.opinionId 38 | }; 39 | this.props.createAnnotation(annotation).then((anno) => { 40 | this.setState({ annotateMode: false, modalOn: false }); 41 | this.props.setPanel("annoDetail"); 42 | }); 43 | } 44 | } 45 | 46 | startAnnotateMode(e) { 47 | e.preventDefault(); 48 | this.setState({ annotateMode: true }); 49 | } 50 | 51 | modalOff() { 52 | this.setState({ modalOn: false }); 53 | } 54 | 55 | handleLogIn(e) { 56 | e.preventDefault(); 57 | this.setState({ modalOn: true }); 58 | } 59 | 60 | focusCursor() { 61 | this.quill.focus(); 62 | } 63 | 64 | render() { 65 | const { location, currentUser } = this.props; 66 | 67 | const initialAsk = (currentUser) ? 68 | : 70 | (
    71 | 73 | 77 |
    ); 78 | 79 | const showForm = ( 80 |
    81 |
    82 |
    83 | 84 |
    85 | ); 86 | 87 | const panelDisplay = this.state.annotateMode ? showForm : initialAsk ; 88 | 89 | const divStyle = { 90 | top: `${ location }px` 91 | }; 92 | 93 | return( 94 |
    95 | { panelDisplay } 96 |
    97 | ); 98 | } 99 | } 100 | 101 | export default AnnotationForm; 102 | -------------------------------------------------------------------------------- /frontend/components/session_form/modal_session_form.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | 4 | class ModalSessionForm extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | this.state = { username: "", password: "" }; 8 | 9 | this.handleSubmit = this.handleSubmit.bind(this); 10 | this.handleChange = this.handleChange.bind(this); 11 | this.guestLogin = this.guestLogin.bind(this); 12 | } 13 | 14 | handleSubmit(e) { 15 | e.preventDefault(); 16 | e.stopPropagation(); 17 | const user = Object.assign({}, this.state); 18 | this.props.processForm(user).then( 19 | () => { 20 | if (this.props.updateAnnotationDetail) 21 | this.props.fetchAnnotation(this.props.updateAnnotationDetail); 22 | if (this.props.updateOpinionDetail) 23 | this.props.fetchSingleOpinion(this.props.updateOpinionDetail); 24 | this.props.modalOff(); 25 | } 26 | ); 27 | } 28 | 29 | handleChange(field) { 30 | return (e) => this.setState({ [field]: e.currentTarget.value }); 31 | } 32 | 33 | guestLogin() { 34 | this.props 35 | .signin({ username: "guest", password: "password"}) 36 | .then(() => { 37 | if (this.props.updateAnnotationDetail) 38 | this.props.fetchAnnotation(this.props.updateAnnotationDetail); 39 | if (this.props.updateOpinionDetail) 40 | this.props.fetchSingleOpinion(this.props.updateOpinionDetail); 41 | this.props.modalOff(); 42 | }); 43 | 44 | } 45 | 46 | render() { 47 | const formType = this.props.formType; 48 | const formTitle = formType === 'signin' ? 49 | 'Sign In to' : 'Sign Up for'; 50 | 51 | const errors = this.props.formErrors[formType]; 52 | const usernameErrors = (errors.username) ? 53 |
    54 | Username { errors.username[0] } 55 |
    : null; 56 | const passwordErrors = (errors.password) ? 57 |
    58 | Password { errors.password[0] } 59 |
    : null; 60 | const credentialErrors = (errors.login) ? 61 |
    { errors.login[0] }
    : null; 62 | 63 | const guest = Login as Guest; 64 | 65 | return( 66 |
    67 |

    {formTitle} CaseNote

    68 | 69 |
    70 | 74 | 75 | { usernameErrors } 76 | 77 |
    78 | 79 | 83 | 84 | { passwordErrors } 85 | 86 | 87 | 88 | 89 | { credentialErrors } 90 |
    91 | 92 | { guest } 93 |
    94 | ); 95 | } 96 | } 97 | 98 | export default ModalSessionForm; 99 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | # PostgreSQL. Versions 8.2 and up are supported. 2 | # 3 | # Install the pg driver: 4 | # gem install pg 5 | # On OS X with Homebrew: 6 | # gem install pg -- --with-pg-config=/usr/local/bin/pg_config 7 | # On OS X with MacPorts: 8 | # gem install pg -- --with-pg-config=/opt/local/lib/postgresql84/bin/pg_config 9 | # On Windows: 10 | # gem install pg 11 | # Choose the win32 build. 12 | # Install PostgreSQL and put its /bin directory on your path. 13 | # 14 | # Configure Using Gemfile 15 | # gem 'pg' 16 | # 17 | default: &default 18 | adapter: postgresql 19 | encoding: unicode 20 | # For details on connection pooling, see rails configuration guide 21 | # http://guides.rubyonrails.org/configuring.html#database-pooling 22 | pool: 5 23 | 24 | development: 25 | <<: *default 26 | database: CaseNote_development 27 | 28 | # The specified database role being used to connect to postgres. 29 | # To create additional roles in postgres see `$ createuser --help`. 30 | # When left blank, postgres will use the default role. This is 31 | # the same name as the operating system user that initialized the database. 32 | #username: CaseNote 33 | 34 | # The password associated with the postgres role (username). 35 | #password: 36 | 37 | # Connect on a TCP socket. Omitted by default since the client uses a 38 | # domain socket that doesn't need configuration. Windows does not have 39 | # domain sockets, so uncomment these lines. 40 | #host: localhost 41 | 42 | # The TCP port the server listens on. Defaults to 5432. 43 | # If your server runs on a different port number, change accordingly. 44 | #port: 5432 45 | 46 | # Schema search path. The server defaults to $user,public 47 | #schema_search_path: myapp,sharedapp,public 48 | 49 | # Minimum log levels, in increasing order: 50 | # debug5, debug4, debug3, debug2, debug1, 51 | # log, notice, warning, error, fatal, and panic 52 | # Defaults to warning. 53 | #min_messages: notice 54 | 55 | # Warning: The database defined as "test" will be erased and 56 | # re-generated from your development database when you run "rake". 57 | # Do not set this db to the same as development or production. 58 | test: 59 | <<: *default 60 | database: CaseNote_test 61 | 62 | # As with config/secrets.yml, you never want to store sensitive information, 63 | # like your database password, in your source code. If your source code is 64 | # ever seen by anyone, they now have access to your database. 65 | # 66 | # Instead, provide the password as a unix environment variable when you boot 67 | # the app. Read http://guides.rubyonrails.org/configuring.html#configuring-a-database 68 | # for a full rundown on how to provide these environment variables in a 69 | # production deployment. 70 | # 71 | # On Heroku and other platform providers, you may have a full connection URL 72 | # available as an environment variable. For example: 73 | # 74 | # DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" 75 | # 76 | # You can use this database configuration with: 77 | # 78 | # production: 79 | # url: <%= ENV['DATABASE_URL'] %> 80 | # 81 | production: 82 | <<: *default 83 | database: CaseNote_production 84 | username: CaseNote 85 | password: <%= ENV['CASENOTE_DATABASE_PASSWORD'] %> 86 | -------------------------------------------------------------------------------- /frontend/components/suggestions/suggestion_item.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { timeSince } from '../../util/date'; 3 | import VoteContainer from '../votes/vote_container'; 4 | import Thumb from '../header/thumb'; 5 | 6 | const SUGGESTION_TYPES = { 7 | missing: "missing something", 8 | restate: "restating the content", 9 | stretch: "a stretch", 10 | other: "other" 11 | }; 12 | 13 | class SuggestionItem extends React.Component { 14 | constructor (props) { 15 | super(props); 16 | 17 | this.state = { editMode: false, body: this.props.suggestion.body }; 18 | 19 | this.handleDelete = this.handleDelete.bind(this); 20 | this.handleEdit = this.handleEdit.bind(this); 21 | this.hideEdit = this.hideEdit.bind(this); 22 | this.showEdit = this.showEdit.bind(this); 23 | } 24 | 25 | handleDelete(e) { 26 | e.preventDefault(); 27 | const suggestionId = this.props.suggestion.id; 28 | this.props.deleteSuggestion(suggestionId); 29 | } 30 | 31 | handleEdit(e) { 32 | e.preventDefault(); 33 | const suggestion = { id: this.props.suggestion.id , body: this.state.body}; 34 | this.props.editSuggestion(suggestion).then(this.hideEdit); 35 | } 36 | 37 | hideEdit() { 38 | this.setState({editMode: false}); 39 | } 40 | 41 | showEdit() { 42 | this.setState({editMode: true}); 43 | } 44 | 45 | links() { 46 | const { currentUser, suggestion } = this.props; 47 | 48 | if (currentUser === null || 49 | currentUser.id !== suggestion.user.id || 50 | this.state.editMode){ 51 | return null; 52 | } else { 53 | return
    54 | Edit 55 | Delete 56 |
    ; 57 | } 58 | } 59 | 60 | update(property) { 61 | return e => this.setState({ [property]: e.target.value }); 62 | } 63 | 64 | render() { 65 | const { suggestion } = this.props; 66 | 67 | const body = this.state.editMode ? 68 |
    69 |