├── 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 |
10 | { suggestions.map(suggestion => ) }
13 |
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 |
15 | { this.props.opinions.map((op, idx) =>
16 |
17 | )}
18 |
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 |
12 | { comments.map((comment) =>
13 |
14 |
15 |
16 | { comment.user.username }
17 | { timeSince(comment.created_at) } ago
18 |
19 | { comment.body }
20 |
25 |
26 | )}
27 |
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 |
30 | No Results Found
31 |
32 | );
33 | } else if (!searchResults || searchResults.length === 0) {
34 | return null;
35 | } else {
36 | return (
37 |
38 | { this.props.searchResults.map((opinion) =>
39 |
40 |
41 | { opinion.case },
42 | { opinion.citation },
43 | { opinion.name }
44 |
45 | ) }
46 |
47 | );
48 | }
49 | }
50 |
51 | render() {
52 | return (
53 |
54 |
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 : Submit ;
56 | if (!this.props.currentUser) {
57 | return (
58 |
59 | Sign In to Leave a Comment
60 |
64 |
);
65 | } else {
66 | const formClasses = `comment-form group ${this.state.formClass}`;
67 |
68 | return(
69 |
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 |
46 |
47 |
48 |
49 | { currentUser.username }
50 | Sign Out
51 |
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 |
69 | Start the CaseNote Annotation :
70 | (
71 |
72 | Sign In to Start Annotating
73 |
77 |
);
78 |
79 | const showForm = (
80 |
81 |
82 |
83 | Save
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 |
72 | Submit
73 | : { suggestion.body }
;
74 |
75 | return (
76 |
77 |
78 |
90 |
91 | { body }
92 |
93 |
98 | { this.links() }
99 |
100 | );
101 | }
102 | }
103 |
104 | export default SuggestionItem;
105 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/opinion_create_form.scss:
--------------------------------------------------------------------------------
1 | .opinion-create-page {
2 | background-color: #000;
3 | }
4 |
5 | .opinion-create-form {
6 | max-width: 1000px;
7 | margin: 0 auto;
8 | color: #fff;
9 | }
10 |
11 | .opinion-create-form-header {
12 | border-bottom: 1px solid #565656;
13 | padding: 20px 0px 5px 0px;
14 | display: flex;
15 | flex-direction: row;
16 | flex-wrap: wrap;
17 | align-items: flex-end;
18 | justify-content: space-between;
19 | }
20 |
21 | .opinion-create-form-header h2 {
22 | font-size: 36px;
23 | font-weight: 600;
24 |
25 | }
26 |
27 | .opinion-create-form-header div {
28 | vertical-align: bottom;
29 | text-align: right;
30 | }
31 |
32 | .opinion-create-form-fields {
33 | padding: 20px 0px;
34 | display: flex;
35 | flex-wrap: wrap;
36 | justify-content: space-around;
37 | align-items: flex-start;
38 | }
39 |
40 | .opinion-create-form-fields label {
41 | width: 475px;
42 | height: 60px;
43 | font-size: 12px;
44 | text-transform: uppercase;
45 | color: #565656;
46 | margin-bottom: 10px;
47 | position: relative;
48 | }
49 |
50 | .opinion-create-form-fields label input {
51 | margin-top: 5px;
52 | width: 465px;
53 | background-color: #eee;
54 | font-size: 16px;
55 | padding: 5px;
56 | border-radius: 3px;
57 | }
58 |
59 | .opinion-create-form-fields .opinion-create-form-dropdown {
60 | margin-top: 5px;
61 | width: 475px;
62 | font-size: 16px;
63 | border-radius: 0;
64 | padding: 10px 5px;
65 | }
66 |
67 | .opinion-create-form-fields label input[type="date"] {
68 | padding-top: 0;
69 | }
70 |
71 | .opinion-create-form-fields .opinion-create-form-editor {
72 | width: 980px;
73 | height: 500px;
74 | padding-bottom: 20px;
75 | }
76 |
77 | #editor {
78 | overflow: auto;
79 | height: 450px;
80 | border-radius: 3px;
81 | margin-top: 5px;
82 | padding: 5px;
83 | font-size: 16px;
84 | text-transform: none;
85 | background-color: #fff;
86 | }
87 |
88 | .opinion-create-form-submit {
89 | display: block;
90 | border-top: 1px solid #565656;
91 | padding: 20px 0px;
92 | }
93 |
94 | .opinion-create-form-submit button {
95 | border-radius: 2px;
96 | font: inherit;
97 | border: 1px solid #565656;
98 | background: #353535;
99 | color: #ccc;
100 | padding: 5px 10px;
101 | cursor: pointer
102 | }
103 |
104 | .opinion-create-form-submit button:hover {
105 | background: #4f4f4f;
106 | }
107 |
108 | .create-form-errors {
109 | text-transform: none;
110 | position: absolute;
111 | font-size: 14px;
112 | padding: 5px;
113 | background: #fff;
114 | border: 2px solid red;
115 | transform: translateY(-5px);
116 | width: 170px;
117 | right: 10px;
118 | }
119 |
120 | .create-form-errors:after, .create-form-errors:before {
121 | bottom: 100%;
122 | left: 50%;
123 | border: solid transparent;
124 | content: " ";
125 | height: 0;
126 | width: 0;
127 | position: absolute;
128 | pointer-events: none;
129 | }
130 |
131 | .create-form-errors:after {
132 | border-color: rgba(255, 255, 255, 0);
133 | border-bottom-color: #fff;
134 | border-width: 5px;
135 | margin-left: -5px;
136 | }
137 |
138 | .create-form-errors:before {
139 | border-color: rgba(204, 204, 204, 0);
140 | border-bottom-color: red;
141 | border-width: 7px;
142 | margin-left: -7px;
143 | }
144 |
145 | .ql-clipboard {
146 | line-height: .1;
147 | }
148 |
--------------------------------------------------------------------------------
/config/environments/production.rb:
--------------------------------------------------------------------------------
1 | Rails.application.configure do
2 | # Settings specified here will take precedence over those in config/application.rb.
3 |
4 | # Code is not reloaded between requests.
5 | config.cache_classes = true
6 |
7 | # Eager load code on boot. This eager loads most of Rails and
8 | # your application in memory, allowing both threaded web servers
9 | # and those relying on copy on write to perform better.
10 | # Rake tasks automatically ignore this option for performance.
11 | config.eager_load = true
12 |
13 | # Full error reports are disabled and caching is turned on.
14 | config.consider_all_requests_local = false
15 | config.action_controller.perform_caching = true
16 |
17 | # Enable Rack::Cache to put a simple HTTP cache in front of your application
18 | # Add `rack-cache` to your Gemfile before enabling this.
19 | # For large-scale production use, consider using a caching reverse proxy like
20 | # NGINX, varnish or squid.
21 | # config.action_dispatch.rack_cache = true
22 |
23 | # Disable serving static files from the `/public` folder by default since
24 | # Apache or NGINX already handles this.
25 | config.serve_static_files = ENV['RAILS_SERVE_STATIC_FILES'].present?
26 |
27 | # Compress JavaScripts and CSS.
28 | config.assets.js_compressor = :uglifier
29 | # config.assets.css_compressor = :sass
30 |
31 | # Do not fallback to assets pipeline if a precompiled asset is missed.
32 | config.assets.compile = false
33 |
34 | # Asset digests allow you to set far-future HTTP expiration dates on all assets,
35 | # yet still be able to expire them through the digest params.
36 | config.assets.digest = true
37 |
38 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb
39 |
40 | # Specifies the header that your server uses for sending files.
41 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache
42 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
43 |
44 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
45 | # config.force_ssl = true
46 |
47 | # Use the lowest log level to ensure availability of diagnostic information
48 | # when problems arise.
49 | config.log_level = :debug
50 |
51 | # Prepend all log lines with the following tags.
52 | # config.log_tags = [ :subdomain, :uuid ]
53 |
54 | # Use a different logger for distributed setups.
55 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new)
56 |
57 | # Use a different cache store in production.
58 | # config.cache_store = :mem_cache_store
59 |
60 | # Enable serving of images, stylesheets, and JavaScripts from an asset server.
61 | # config.action_controller.asset_host = 'http://assets.example.com'
62 |
63 | # Ignore bad email addresses and do not raise email delivery errors.
64 | # Set this to true and configure the email server for immediate delivery to raise delivery errors.
65 | # config.action_mailer.raise_delivery_errors = false
66 |
67 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
68 | # the I18n.default_locale when a translation cannot be found).
69 | config.i18n.fallbacks = true
70 |
71 | # Send deprecation notices to registered listeners.
72 | config.active_support.deprecation = :notify
73 |
74 | # Use default logging formatter so that PID and timestamp are not suppressed.
75 | config.log_formatter = ::Logger::Formatter.new
76 |
77 | # Do not dump schema after migrations.
78 | config.active_record.dump_schema_after_migration = false
79 | end
80 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CaseNote
2 |
3 | [CaseNote Live][live]
4 |
5 | [live]: https://casenote.herokuapp.com/#/
6 |
7 | CaseNote is an app where users can access and contribute to a crowd-sourced collection of court opinions and legal knowledge. The app has a Genius-inspired annotation scheme that allows users to annotate opinions to provide insight and analysis. CaseNote utilizes Ruby on Rails on the backend, a PostgreSQL database, and React.js with a Redux architectural framework on the frontend.
8 |
9 | ## Features & Implementation
10 |
11 | 
12 |
13 | ### Opinions
14 |
15 | On the database side, court opinions are stored in one table which contains columns for `id`, `case`, `citation`, `date`, `body`, `transcriber_id`, `judge_id`, `court_id`, and `image`. User have access to read all Opinions on the site, but can only comment, annotate, or edit opinions upon log in.
16 |
17 | **Viewing opinions**
18 |
19 | Users can access opinions through either search or the opinions index in the navigation bar. The search bar allows users to search by case name, citation, or judge name.
20 |
21 | 
22 |
23 | The opinion page shows the User the key information related to the opinion as well as a related image to the opinion.
24 |
25 | 
26 |
27 | **Creating opinions**
28 |
29 | Users can create opinions by clicking the "Add Opinion" link in the navigation bar. Opinions require a case name, citation, judge, court, and date. The judge and court fields are drop down menus populated from judge and court reference tables. Optionally, the user can add an image when creating the opinion. By default, the image will be an image of the selected judge. CaseNote utilizes Amazon Web Services to store these images.
30 |
31 | 
32 |
33 | ### Annotations
34 |
35 | **Viewing annotations**
36 |
37 | In the text body of the opinions, annotations are distinguished by a gray background. Users click on the annotation to view the details of that annotation in the right panel.
38 |
39 | 
40 |
41 | The user can vote on the annotation and make suggestions to refine the annotation. Users can also vote on suggestions.
42 |
43 | 
44 |
45 | **Creating annotations**
46 |
47 | Users can create annotations by selecting the text in the opinion that they would like to annotate. The right panel will display a button which allows users to start an annotation.
48 |
49 | 
50 |
51 | Once the user submits the body of the annotation, CaseNote creates an Annotation object in the database that stores the `id`, `start_idx`, `length`, `body`, `user_id`, and `opinion_id`. When the opinion is rendered, CaseNote uses a custom format in the Quill editor to render the annotations on the opinion text.
52 |
53 | 
54 |
55 |
56 | ## Future Directions for the Project
57 |
58 | In addition to the features already implemented, I plan to continue work on this project. The next steps for CaseNote are outlined below.
59 |
60 | ### User Profiles and Rating
61 |
62 | User Profiles tracks is a standard feature of Genius. This allows users to upload profile pictures, view feeds of their activity, and be rated based on their contributes to the site.
63 |
64 | ### Court Transcripts with Audio / Video
65 |
66 | Although this is less essential functionality, I want to allow users to upload court transcripts and optionally include playable audio and/or video,
67 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # This file was generated by the `rails generate rspec:install` command. Conventionally, all
2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
3 | # The generated `.rspec` file contains `--require spec_helper` which will cause
4 | # this file to always be loaded, without a need to explicitly require it in any
5 | # files.
6 | #
7 | # Given that it is always loaded, you are encouraged to keep this file as
8 | # light-weight as possible. Requiring heavyweight dependencies from this file
9 | # will add to the boot time of your test suite on EVERY test run, even for an
10 | # individual file that may not need all of that loaded. Instead, consider making
11 | # a separate helper file that requires the additional dependencies and performs
12 | # the additional setup, and require it from the spec files that actually need
13 | # it.
14 | #
15 | # The `.rspec` file also contains a few flags that are not defaults but that
16 | # users commonly want.
17 | #
18 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
19 | RSpec.configure do |config|
20 | # rspec-expectations config goes here. You can use an alternate
21 | # assertion/expectation library such as wrong or the stdlib/minitest
22 | # assertions if you prefer.
23 | config.expect_with :rspec do |expectations|
24 | # This option will default to `true` in RSpec 4. It makes the `description`
25 | # and `failure_message` of custom matchers include text for helper methods
26 | # defined using `chain`, e.g.:
27 | # be_bigger_than(2).and_smaller_than(4).description
28 | # # => "be bigger than 2 and smaller than 4"
29 | # ...rather than:
30 | # # => "be bigger than 2"
31 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true
32 | end
33 |
34 | # rspec-mocks config goes here. You can use an alternate test double
35 | # library (such as bogus or mocha) by changing the `mock_with` option here.
36 | config.mock_with :rspec do |mocks|
37 | # Prevents you from mocking or stubbing a method that does not exist on
38 | # a real object. This is generally recommended, and will default to
39 | # `true` in RSpec 4.
40 | mocks.verify_partial_doubles = true
41 | end
42 |
43 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
44 | # have no way to turn it off -- the option exists only for backwards
45 | # compatibility in RSpec 3). It causes shared context metadata to be
46 | # inherited by the metadata hash of host groups and examples, rather than
47 | # triggering implicit auto-inclusion in groups with matching metadata.
48 | config.shared_context_metadata_behavior = :apply_to_host_groups
49 |
50 | # The settings below are suggested to provide a good initial experience
51 | # with RSpec, but feel free to customize to your heart's content.
52 | =begin
53 | # This allows you to limit a spec run to individual examples or groups
54 | # you care about by tagging them with `:focus` metadata. When nothing
55 | # is tagged with `:focus`, all examples get run. RSpec also provides
56 | # aliases for `it`, `describe`, and `context` that include `:focus`
57 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
58 | config.filter_run_when_matching :focus
59 |
60 | # Allows RSpec to persist some state between runs in order to support
61 | # the `--only-failures` and `--next-failure` CLI options. We recommend
62 | # you configure your source control system to ignore this file.
63 | config.example_status_persistence_file_path = "spec/examples.txt"
64 |
65 | # Limits the available syntax to the non-monkey patched syntax that is
66 | # recommended. For more details, see:
67 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/
68 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
69 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode
70 | config.disable_monkey_patching!
71 |
72 | # Many RSpec users commonly either run the entire suite or an individual
73 | # file, and it's useful to allow more verbose output when running an
74 | # individual spec file.
75 | if config.files_to_run.one?
76 | # Use the documentation formatter for detailed output,
77 | # unless a formatter has already been configured
78 | # (e.g. via a command-line flag).
79 | config.default_formatter = 'doc'
80 | end
81 |
82 | # Print the 10 slowest examples and example groups at the
83 | # end of the spec run, to help surface which specs are running
84 | # particularly slow.
85 | config.profile_examples = 10
86 |
87 | # Run specs in random order to surface order dependencies. If you find an
88 | # order dependency and want to debug it, you can fix the order by providing
89 | # the seed, which is printed after each run.
90 | # --seed 1234
91 | config.order = :random
92 |
93 | # Seed global randomization in this process using the `--seed` CLI option.
94 | # Setting this allows you to use `--seed` to deterministically reproduce
95 | # test failures related to randomization by passing the same `--seed` value
96 | # as the one that triggered the failure.
97 | Kernel.srand config.seed
98 | =end
99 | end
100 |
--------------------------------------------------------------------------------
/frontend/components/suggestions/suggestion_form.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ModalWrapper from '../session_form/modal_wrapper';
3 | import Thumb from '../header/thumb';
4 |
5 | class SuggestionForm extends React.Component {
6 | constructor(props) {
7 | super(props);
8 | this.state = {
9 | body: "",
10 | suggestion_type: "other",
11 | formClass: "",
12 | // fullForm: false,
13 | modalOn: false
14 | };
15 | this.handleSubmit = this.handleSubmit.bind(this);
16 | this.showFullForm = this.showFullForm.bind(this);
17 | this.handleLogIn = this.handleLogIn.bind(this);
18 | this.modalOff = this.modalOff.bind(this);
19 | }
20 |
21 | update(property) {
22 | return e => this.setState({ [property]: e.target.value });
23 | }
24 |
25 | handleSubmit(e) {
26 | e.preventDefault();
27 | const { body, suggestion_type } = this.state;
28 | const suggestion = {
29 | body,
30 | suggestion_type,
31 | annotation_id: this.props.annotationId
32 | };
33 |
34 | this.props.createSuggestion(suggestion).then(
35 | () => this.setState({ body: "", suggestion_type: "", formClass: "" })
36 | );
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 | showFullForm(e) {
49 | e.preventDefault();
50 | this.setState({ formClass: "full-mode" });
51 | }
52 |
53 | loginButton() {
54 | if (this.state.formClass === "full-mode") {
55 | if (this.props.currentUser) {
56 | return null;
57 | } else {
58 | return
59 | Log In to Make Suggestion
60 | ;
61 | }
62 | } else {
63 | return null;
64 | }
65 | }
66 |
67 | typeButtons() {
68 | if (this.state.formClass === "full-mode") {
69 | return(
70 |
112 | );
113 | } else {
114 | return null;
115 | }
116 | }
117 |
118 | submitButtons() {
119 | if (this.state.formClass === "full-mode") {
120 | return(
121 | Submit
122 | );
123 | } else {
124 | return null;
125 | }
126 | }
127 |
128 | render() {
129 | if (this.props.currentUser) {
130 | let placeholder;
131 | if (this.state.formClass === "") {
132 | placeholder = "Suggest an improvement";
133 | } else {
134 | placeholder = (this.state.suggestion_type === "other") ?
135 | "Suggest an improvement (required)" :
136 | "Suggest an improvement (optional)";
137 | }
138 |
139 | let formClasses = `suggestion-form ${this.state.formClass}`;
140 |
141 | return(
142 |
143 | { this.loginButton() }
144 |
145 | { this.typeButtons() }
146 |
151 | { this.submitButtons() }
152 |
153 |
154 | );
155 | } else {
156 | return (
157 |
158 |
159 | Sign In to Make a Suggestion
160 |
161 |
162 |
166 |
167 | );
168 | }
169 | }
170 | }
171 |
172 | export default SuggestionForm;
173 |
--------------------------------------------------------------------------------
/frontend/components/annotations/annotation_detail.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Quill from 'quill';
3 | import SuggestionFormContainer from '../suggestions/suggestion_form_container';
4 | import SuggestionIndex from '../suggestions/suggestion_index';
5 | import VoteContainer from '../votes/vote_container';
6 |
7 | class AnnotationDetail extends React.Component {
8 | constructor(props) {
9 | super(props);
10 | this.state = { editorClass: "" };
11 |
12 | this.showEditForm = this.showEditForm.bind(this);
13 | this.hideEditForm = this.hideEditForm.bind(this);
14 | this.handleSubmit = this.handleSubmit.bind(this);
15 | this.handleDelete = this.handleDelete.bind(this);
16 | }
17 |
18 | componentDidMount() {
19 | this.editor = document.getElementById("anno-editor");
20 | this.quill = new Quill(this.editor);
21 | if (this.props.annotationDetail.body) {
22 | this.quill.setContents(JSON.parse(this.props.annotationDetail.body));
23 | } else {
24 | this.props.fetchAnnotation(this.props.annotationId).then(
25 | (anno) => {
26 | this.quill.setContents(JSON.parse(anno.annotation.body));
27 | }
28 | );
29 | }
30 | }
31 |
32 | componentWillUnmount() {
33 | this.props.clearAnnotation();
34 | }
35 |
36 | componentWillReceiveProps(nextProps) {
37 | if (this.props.annotationDetail.id !== nextProps.annotationDetail.id) {
38 | this.quill.setContents(JSON.parse(nextProps.annotationDetail.body));
39 | } else
40 | if (this.props.annotationId !== nextProps.annotationId) {
41 | this.props.fetchAnnotation(nextProps.annotationId).then(
42 | (anno) => {
43 | this.quill.setContents(JSON.parse(anno.annotation.body));
44 | }
45 | );
46 | }
47 | }
48 |
49 | showEditForm(e) {
50 | e.preventDefault();
51 | this.quill.enable(true);
52 | this.setState({ editorClass: "edit-mode" });
53 | }
54 |
55 | hideEditForm(e) {
56 | if (e) {
57 | e.preventDefault();
58 | }
59 | this.quill.setContents(JSON.parse(this.props.annotationDetail.body));
60 | this.quill.enable(false);
61 | this.setState({ editorClass: "" });
62 | }
63 |
64 | handleSubmit(e) {
65 | e.preventDefault();
66 | const body = JSON.stringify(this.quill.getContents());
67 | this.props.editAnnotation({ body, id: this.props.annotationDetail.id })
68 | .then(
69 | (op) => {
70 | this.quill.setContents(JSON.parse(op.annotation.body));
71 | this.hideEditForm();
72 | }
73 | );
74 | }
75 |
76 | handleDelete(e) {
77 | e.preventDefault();
78 |
79 | const opinionId = this.props.annotationDetail.opinion_id;
80 | this.props.deleteAnnotation(this.props.annotationDetail.id).then(
81 | () => {
82 | this.props.setPanel("opinion");
83 | }
84 | );
85 | }
86 |
87 | buttons() {
88 | if (Object.getOwnPropertyNames(this.props.annotationDetail).length === 0) {
89 | return null;
90 | }
91 |
92 | let initialButtons;
93 | if (this.props.currentUser && this.props.annotationDetail) {
94 | if (this.props.currentUser.id === this.props.annotationDetail.user.id) {
95 | initialButtons = (
96 |
97 | Edit
98 | Delete
99 |
100 | );
101 | } else {
102 | initialButtons = (
103 |
104 | Edit
105 |
106 | );
107 | }
108 | } else { initialButtons = null; }
109 |
110 | let editButtons = (
111 |
112 | Submit
113 | Cancel
114 |
115 | );
116 |
117 | return this.state.editorClass !== "" ? editButtons : initialButtons;
118 | }
119 |
120 | contributors() {
121 | if (Object.getOwnPropertyNames(this.props.annotationDetail).length === 0) {
122 | return null;
123 | } else {
124 | return { this.props.annotationDetail.user.username } ;
125 | }
126 | }
127 |
128 | suggestions() {
129 | if (Object.getOwnPropertyNames(this.props.annotationDetail).length === 0) {
130 | return null;
131 | } else {
132 | return (
133 |
134 |
135 |
136 |
137 | );
138 | }
139 | }
140 |
141 | render() {
142 | const sectionStyle = {
143 | top: `${ this.props.locationY }px`
144 | };
145 |
146 | return (
147 |
150 |
151 | Annotation by { this.contributors() }
152 |
153 |
154 |
155 | { this.buttons() }
156 |
161 |
162 | { this.suggestions() }
163 |
164 |
165 | );
166 | }
167 |
168 | }
169 |
170 | export default AnnotationDetail;
171 |
--------------------------------------------------------------------------------