mirror of
https://github.com/indentlabs/notebook.git
synced 2025-10-26 11:19:22 +00:00
Merge branch 'master' into rails-6.1
This commit is contained in:
commit
36fe52ab65
7
Gemfile
7
Gemfile
@ -3,13 +3,13 @@ ruby "~> 2.7"
|
||||
|
||||
# Server
|
||||
gem 'rails'
|
||||
gem 'puma', '~> 5.3'
|
||||
gem 'puma', '~> 5.5'
|
||||
gem 'puma-heroku'
|
||||
# gem 'bootsnap', require: false
|
||||
gem 'sprockets', '~> 3.7.2'
|
||||
|
||||
# Storage
|
||||
gem 'aws-sdk', '~> 3.0'
|
||||
gem 'aws-sdk', '~> 3.1'
|
||||
gem 'aws-sdk-s3'
|
||||
gem 'filesize'
|
||||
|
||||
@ -42,7 +42,6 @@ gem 'paranoia'
|
||||
# Javascript
|
||||
gem 'coffee-rails'
|
||||
gem 'rails-jquery-autocomplete'
|
||||
gem 'animate-rails'
|
||||
gem 'webpacker'
|
||||
gem 'react-rails'
|
||||
|
||||
@ -91,7 +90,7 @@ gem 'redis'
|
||||
gem 'csv'
|
||||
|
||||
# Admin
|
||||
gem 'rails_admin', '~> 2.1'
|
||||
gem 'rails_admin', '~> 2.2'
|
||||
|
||||
# Tech debt & hacks
|
||||
gem 'binding_of_caller' # see has_changelog.rb
|
||||
|
||||
1333
Gemfile.lock
1333
Gemfile.lock
File diff suppressed because it is too large
Load Diff
51
README.rdoc
51
README.rdoc
@ -1,4 +1,4 @@
|
||||
= Notebook.ai
|
||||
= Notebook.ai
|
||||
{<img src="https://codeclimate.com/github/indentlabs/notebook/badges/gpa.svg" />}[https://codeclimate.com/github/indentlabs/notebook]
|
||||
{<img src="https://codeclimate.com/github/indentlabs/notebook/badges/coverage.svg" />}[https://codeclimate.com/github/indentlabs/notebook/coverage]
|
||||
{<img src="http://inch-ci.org/github/indentlabs/notebook.svg?branch=master" alt="Inline docs" />}[http://inch-ci.org/github/indentlabs/notebook]
|
||||
@ -34,54 +34,7 @@ TL;DR Milestones are independent of each other -- work on whatever you want to s
|
||||
|
||||
== Installing the notebook development stack locally
|
||||
|
||||
Install ruby 2.6.6 (using `rbenv`, `rvm`, any other Ruby version manager, or just plain ol' ruby)
|
||||
|
||||
rbenv install 2.6.6
|
||||
|
||||
Install necessary libraries
|
||||
|
||||
sudo apt install imagemagick libmagickwand-dev
|
||||
sudo apt install libpq-dev
|
||||
|
||||
Clone the code
|
||||
|
||||
git clone git@github.com:indentlabs/notebook.git
|
||||
|
||||
Install gems
|
||||
|
||||
bundle install
|
||||
|
||||
Create database
|
||||
|
||||
rake db:create
|
||||
|
||||
Run initial database migrations
|
||||
|
||||
rake db:migrate
|
||||
rake billing_plans:initialize_defaults
|
||||
rake data_migrations:create_default_billing_plans
|
||||
rake db:seed
|
||||
|
||||
Finally, run the server with
|
||||
|
||||
bundle exec rails server
|
||||
|
||||
You should now see a copy of the site running locally at http://localhost:3000/!
|
||||
|
||||
You can also run background workers with sidekiq (and can run specific queues with the `-q <queue_name>` flag):
|
||||
|
||||
bundle exec sidekiq -C config/sidekiq.yml
|
||||
|
||||
== Running the notebook stack locally with Docker
|
||||
|
||||
Please note that the Docker installation is managed by the community, so it may not always be up to date. I recommend installing the stack manually.
|
||||
|
||||
- install {Docker}[https://www.docker.com/products/overview]
|
||||
- install {Docker Compose}[https://docs.docker.com/compose/install]
|
||||
- clone this git repo
|
||||
- cd into the root of this repo, and then run
|
||||
docker-compose up
|
||||
- You should now see a copy of the site running locally at http://localhost:3000/
|
||||
Please see the {installation Guide}[https://github.com/indentlabs/notebook/wiki/Setup-Instructions] in the wiki for setup instructions.
|
||||
|
||||
== Testing
|
||||
|
||||
|
||||
@ -104,13 +104,35 @@ $(document).ready(function () {
|
||||
// Replace this element's content with the name of the page
|
||||
var tag = $(this);
|
||||
|
||||
$.get(
|
||||
'/api/internal/' + tag.data('klass') + '/' + tag.data('id') + '/name'
|
||||
).done(function (response) {
|
||||
tag.find('.name-container').text(response);
|
||||
}).fail(function() {
|
||||
tag.find('.name-conainer').text("Unknown " + tag.data('klass'));
|
||||
});
|
||||
// Instantiate a cache for all page lookup queries (if not already created)
|
||||
window.load_page_name_cache = window.load_page_name_cache || {};
|
||||
var page_name_key = tag.data('klass') + '/' + tag.data('id');
|
||||
|
||||
if (page_name_key in window.load_page_name_cache) {
|
||||
// If we've already made a request for this klass+id, we can just insta-load the
|
||||
// cached result instead of requesting it again.
|
||||
tag.find('.name-container').text(window.load_page_name_cache[page_name_key]);
|
||||
|
||||
} else {
|
||||
// If we haven't made a request for this klass+id, look it up and cache it
|
||||
$.get(
|
||||
'/api/internal/' + page_name_key + '/name'
|
||||
).done(function (response) {
|
||||
tag.find('.name-container').text(response);
|
||||
window.load_page_name_cache[page_name_key] = response;
|
||||
|
||||
// Go ahead and pre-fill all tags on the page for this klass+id, too
|
||||
$('.js-load-page-name[data-klass=' + tag.data('klass') + '][data-id=' + tag.data('id') + ']')
|
||||
.find('.name-container')
|
||||
.text(response);
|
||||
|
||||
}).fail(function() {
|
||||
tag.find('.name-container').text("Unknown " + tag.data('klass'));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
3
app/assets/javascripts/page_tags.coffee
Normal file
3
app/assets/javascripts/page_tags.coffee
Normal file
@ -0,0 +1,3 @@
|
||||
# Place all the behaviors and hooks related to the matching controller here.
|
||||
# All this logic will automatically be available in application.js.
|
||||
# You can use CoffeeScript in this file: http://coffeescript.org/
|
||||
@ -13,7 +13,6 @@
|
||||
*= require font-awesome
|
||||
*= require medium-editor/medium-editor
|
||||
*= require medium-editor/themes/beagle
|
||||
*= require animate
|
||||
*= require tribute
|
||||
*= require_tree .
|
||||
*/
|
||||
|
||||
@ -9,4 +9,5 @@
|
||||
.card-panel-title {
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
48
app/authorizers/content_page_authorizer.rb
Normal file
48
app/authorizers/content_page_authorizer.rb
Normal file
@ -0,0 +1,48 @@
|
||||
class ContentPageAuthorizer < CoreContentAuthorizer
|
||||
def self.creatable_by?(user)
|
||||
return false unless user.present?
|
||||
return false if ENV.key?('CONTENT_BLACKLIST') && ENV['CONTENT_BLACKLIST'].split(',').include?(user.email)
|
||||
|
||||
if resource.page_type == 'Universe'
|
||||
return true if PermissionService.user_has_fewer_owned_universes_than_plan_limit?(user: user)
|
||||
|
||||
else
|
||||
is_premium_page = Rails.application.config.content_types[:premium].include?(resource.page_type)
|
||||
return true if !is_premium_page
|
||||
return true if is_premium_page && PermissionService.user_is_on_premium_plan?(user: user)
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
def readable_by?(user)
|
||||
return true if PermissionService.content_is_public?(content: resource)
|
||||
return true if PermissionService.user_owns_content?(user: user, content: resource)
|
||||
|
||||
if resource.page_type == 'Universe'
|
||||
return true if PermissionService.user_can_contribute_to_universe?(user: user, universe: resource)
|
||||
else
|
||||
return true if PermissionService.user_can_contribute_to_containing_universe?(user: user, content: resource)
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
def updatable_by?(user)
|
||||
return true if PermissionService.user_owns_content?(user: user, content: resource)
|
||||
|
||||
if resource.page_type == 'Universe'
|
||||
return true if PermissionService.user_can_contribute_to_universe?(user: user, universe: resource)
|
||||
else
|
||||
return true if PermissionService.user_can_contribute_to_containing_universe?(user: user, content: resource)
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
def deletable_by?(user)
|
||||
[
|
||||
PermissionService.user_owns_content?(user: user, content: resource)
|
||||
].any?
|
||||
end
|
||||
end
|
||||
@ -7,6 +7,7 @@ class DocumentAuthorizer < ApplicationAuthorizer
|
||||
|
||||
def readable_by?(user)
|
||||
return true if user && resource.user_id == user.id
|
||||
return true if user && user.site_administrator?
|
||||
return true if resource.privacy == 'public'
|
||||
return true if resource.universe.present? && resource.universe.privacy == 'public'
|
||||
return true if user && resource.universe.present? && resource.universe.contributors.pluck(:user_id).include?(user.id)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
# frozen_string_literal: true
|
||||
class ApplicationController < ActionController::Base
|
||||
protect_from_forgery
|
||||
|
||||
@ -27,7 +28,7 @@ class ApplicationController < ActionController::Base
|
||||
session.delete(:universe_id)
|
||||
elsif params[:universe].is_a?(String) && params[:universe].to_i.to_s == params[:universe]
|
||||
found_universe = Universe.find_by(id: params[:universe])
|
||||
found_universe = nil unless current_user.universes.include?(found_universe) || current_user.contributable_universes.include?(found_universe)
|
||||
found_universe = nil unless found_universe.user_id == current_user.id || found_universe.contributors.pluck(:user_id).include?(current_user.id)
|
||||
session[:universe_id] = found_universe.id if found_universe
|
||||
end
|
||||
end
|
||||
@ -36,52 +37,115 @@ class ApplicationController < ActionController::Base
|
||||
def set_universe_scope
|
||||
if user_signed_in? && session.key?(:universe_id)
|
||||
@universe_scope = Universe.find_by(id: session[:universe_id])
|
||||
@universe_scope = nil unless current_user.universes.include?(@universe_scope) || current_user.contributable_universes.include?(@universe_scope)
|
||||
|
||||
if @universe_scope && @universe_scope.user_id != current_user.try(:id)
|
||||
# Verify the current user has access to this universe by looking up their
|
||||
# universe contributorship
|
||||
contributorship = Contributor.find_by(
|
||||
user: current_user,
|
||||
universe: @universe_scope
|
||||
)
|
||||
|
||||
if contributorship.nil?
|
||||
# If the user doesn't have current contributor access to this universe,
|
||||
# then revert back to unscoped universe actions
|
||||
@universe_scope = nil
|
||||
end
|
||||
end
|
||||
|
||||
else
|
||||
@universe_scope = nil
|
||||
end
|
||||
end
|
||||
|
||||
# Cache some super-common stuff we need for every page. For example, content lists for the side nav.
|
||||
# Cache some super-common stuff we need for every page. For example, content lists for the side nav. This is a catch-all for most pages that render
|
||||
# UI, but methods are also free to skip this filter and call the individual cache methods they need instead.
|
||||
def cache_most_used_page_information
|
||||
return unless user_signed_in?
|
||||
|
||||
cache_activated_content_types
|
||||
cache_current_user_content
|
||||
cache_notifications
|
||||
cache_recently_edited_pages
|
||||
end
|
||||
|
||||
def cache_activated_content_types
|
||||
@activated_content_types ||= if user_signed_in?
|
||||
(
|
||||
# Use config to dictate order, but AND to only include what a user has turned on
|
||||
Rails.application.config.content_type_names[:all] & current_user.user_content_type_activators.pluck(:content_type)
|
||||
)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def cache_current_user_content
|
||||
return if @current_user_content
|
||||
|
||||
@current_user_content = {}
|
||||
return unless user_signed_in?
|
||||
|
||||
@activated_content_types = (
|
||||
Rails.application.config.content_types[:all].map(&:name) & # Use config to dictate order, but AND to only include what a user has turned on
|
||||
current_user.user_content_type_activators.pluck(:content_type)
|
||||
)
|
||||
cache_activated_content_types
|
||||
|
||||
# We always want to cache Universes, even if they aren't explicitly turned on.
|
||||
@current_user_content = current_user.content(content_types: @activated_content_types + ['Universe'], universe_id: @universe_scope.try(:id))
|
||||
@current_user_content = current_user.content(
|
||||
content_types: @activated_content_types + [Universe.name],
|
||||
universe_id: @universe_scope.try(:id)
|
||||
)
|
||||
|
||||
# Likewise, we should also always cache Timelines & Documents
|
||||
if @universe_scope
|
||||
@current_user_content['Timeline'] = current_user.timelines.where(universe_id: @universe_scope.try(:id)).to_a
|
||||
@current_user_content['Document'] = current_user.linkable_documents.includes([:user]).where(universe_id: @universe_scope.try(:id)).order('updated_at DESC').to_a
|
||||
@current_user_content['Document'] = current_user.documents.where(universe_id: @universe_scope.try(:id)).order('updated_at DESC').to_a
|
||||
else
|
||||
@current_user_content['Timeline'] = current_user.timelines.to_a
|
||||
@current_user_content['Document'] = current_user.linkable_documents.includes([:user]).order('updated_at DESC').to_a
|
||||
@current_user_content['Document'] = current_user.documents.order('updated_at DESC').to_a
|
||||
end
|
||||
end
|
||||
|
||||
# Fetch notifications
|
||||
@user_notifications = current_user.notifications.order('happened_at DESC').limit(100)
|
||||
def cache_notifications
|
||||
@user_notifications ||= if user_signed_in?
|
||||
current_user.notifications.order('happened_at DESC').limit(100)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# Cache recently-edited pages
|
||||
@recently_edited_pages = @current_user_content.values.flatten
|
||||
.sort_by(&:updated_at)
|
||||
.last(50)
|
||||
.reverse
|
||||
def cache_recently_created_pages(amount=50)
|
||||
cache_current_user_content
|
||||
|
||||
@recently_created_pages = if user_signed_in?
|
||||
@current_user_content.values.flatten
|
||||
.sort_by(&:created_at)
|
||||
.last(amount)
|
||||
.reverse
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def cache_recently_edited_pages(amount=50)
|
||||
cache_current_user_content
|
||||
|
||||
@recently_edited_pages ||= if user_signed_in?
|
||||
@current_user_content.values.flatten
|
||||
.sort_by(&:updated_at)
|
||||
.last(amount)
|
||||
.reverse
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def cache_forums_unread_counts
|
||||
@unread_threads = if user_signed_in?
|
||||
@unread_threads ||= if user_signed_in?
|
||||
Thredded::Topic.unread_followed_by(current_user).count
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
@unread_private_messages = if user_signed_in?
|
||||
@unread_private_messages ||= if user_signed_in?
|
||||
Thredded::PrivateTopic
|
||||
.for_user(current_user)
|
||||
.unread(current_user)
|
||||
@ -91,33 +155,69 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
end
|
||||
|
||||
def cache_contributable_universe_ids
|
||||
cache_current_user_content
|
||||
|
||||
@contributable_universe_ids ||= if user_signed_in?
|
||||
current_user.contributable_universe_ids
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def cache_linkable_content_for_each_content_type
|
||||
linkable_classes = Rails.application.config.content_types[:all].map(&:name) & current_user.user_content_type_activators.pluck(:content_type)
|
||||
cache_contributable_universe_ids
|
||||
cache_current_user_content
|
||||
|
||||
linkable_classes = @activated_content_types
|
||||
linkable_classes += %w(Document Timeline)
|
||||
|
||||
@linkables_cache = {}
|
||||
@linkables_raw = {}
|
||||
linkable_classes.each do |class_name|
|
||||
# class_name = "Character"
|
||||
@linkables_cache = {} # Cache is list of [[page_name, page_id], [page_name, page_id], ...]
|
||||
@linkables_raw = {} # Raw is list of objects [#{page}, #{page}, ...]
|
||||
|
||||
@linkables_cache[class_name] = current_user
|
||||
.send("linkable_#{class_name.downcase.pluralize}")
|
||||
.in_universe(@universe_scope)
|
||||
@current_user_content.each do |page_type, content_list|
|
||||
# We already have our own list of content by the current user in @current_user_content,
|
||||
# so all we need to grab is additional pages in contributable universes
|
||||
@linkables_raw[page_type] = @current_user_content[page_type]
|
||||
|
||||
if @content.present? && @content.persisted?
|
||||
@linkables_cache[class_name] = @linkables_cache[class_name]
|
||||
.in_universe(@content.universe)
|
||||
.reject { |content| content.class.name == class_name && content.id == @content.id }
|
||||
# Add contributor content
|
||||
if @contributable_universe_ids.any?
|
||||
existing_page_ids = @linkables_raw[page_type].map(&:id)
|
||||
|
||||
pages_to_add = if page_type == Universe.name
|
||||
page_type.constantize.where(id: @contributable_universe_ids)
|
||||
.where.not(id: existing_page_ids)
|
||||
.where.not(user_id: current_user.id)
|
||||
else
|
||||
page_type.constantize.where(universe_id: @contributable_universe_ids)
|
||||
.where.not(id: existing_page_ids)
|
||||
.where.not(user_id: current_user.id)
|
||||
end
|
||||
|
||||
# If we're scoped to a universe, also scope contributor content pulled to that
|
||||
# universe. If we're not, leave it as all contributor content.
|
||||
if @universe_scope && pages_to_add.klass.respond_to?(:universe)
|
||||
pages_to_add = pages_to_add.where(universe: @universe_scope)
|
||||
end
|
||||
|
||||
filtered_fields = ContentPage.polymorphic_content_fields.map(&:to_s)
|
||||
filtered_fields.push 'universe_id' unless page_type == Universe.name
|
||||
pages_to_add.each do |page_data|
|
||||
filtered_page_data = page_data.attributes.slice(*filtered_fields)
|
||||
@linkables_raw[page_type].push ContentPage.new(filtered_page_data)
|
||||
end
|
||||
end
|
||||
|
||||
@linkables_raw[class_name] = @linkables_cache[class_name]
|
||||
.sort_by { |p| p.name.downcase }
|
||||
.compact
|
||||
# We can't properly display or @-mention content without a name set, so we explicitly
|
||||
# reject it here. However, this is a bit of a code-smell: why is there content without
|
||||
# a name set?
|
||||
@linkables_raw[page_type].reject! { |page| page.name.nil? }
|
||||
|
||||
@linkables_cache[class_name] = @linkables_cache[class_name]
|
||||
.sort_by { |p| p.name.downcase }
|
||||
.map { |c| [c.name, c.id] }
|
||||
.compact
|
||||
# Finally, we want to sort our linkables cache once so we don't have to sort it again
|
||||
@linkables_raw[page_type].sort_by! { |page| page.name.downcase }.compact!
|
||||
|
||||
# Lastly, build our name/id cache as well
|
||||
@linkables_cache[page_type] = @linkables_raw[page_type].map { |page| [page.name, page.id] }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@ -26,7 +26,7 @@ class AttributeFieldsController < ContentController
|
||||
return redirect_back fallback_location: root_path
|
||||
end
|
||||
|
||||
if @attribute_field.update(attribute_field_params.merge({ migrated_from_legacy: true }))
|
||||
if @attribute_field.update(content_params.merge({ migrated_from_legacy: true }))
|
||||
@content = @attribute_field
|
||||
successful_response(
|
||||
@attribute_field,
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
class ContentController < ApplicationController
|
||||
# todo we should probably spin off an Api::ContentController for #api_sort and anything else api-wise we need
|
||||
# frozen_string_literal: true
|
||||
|
||||
# TODO: we should probably spin off an Api::ContentController for #api_sort and anything else
|
||||
# api-wise we need
|
||||
class ContentController < ApplicationController
|
||||
before_action :authenticate_user!, except: [:show, :changelog, :api_sort] \
|
||||
+ Rails.application.config.content_types[:all_non_universe].map { |type| type.name.downcase.pluralize.to_sym }
|
||||
|
||||
skip_before_action :cache_most_used_page_information, only: [
|
||||
:name_field_update, :text_field_update, :tags_field_update, :universe_field_update, :api_sort
|
||||
]
|
||||
|
||||
before_action :migrate_old_style_field_values, only: [:show, :edit]
|
||||
|
||||
before_action :cache_linkable_content_for_each_content_type, only: [:new, :edit, :index]
|
||||
@ -21,49 +27,35 @@ class ContentController < ApplicationController
|
||||
@page_title = "My #{pluralized_content_name}"
|
||||
|
||||
# Create the default fields for this user if they don't have any already
|
||||
# TODO: uh, this probably doesn't belong here!
|
||||
@content_type_class.attribute_categories(current_user)
|
||||
|
||||
if @universe_scope.present? && @content_type_class != Universe
|
||||
@content = @universe_scope.send(pluralized_content_name)
|
||||
.includes(:page_tags, :image_uploads)
|
||||
.unarchived
|
||||
# Linkables cache is already scoped per-universe, includes contributor pages
|
||||
@content = @linkables_raw.fetch(@content_type_class.name, [])
|
||||
|
||||
@show_scope_notice = true
|
||||
else
|
||||
@content = (
|
||||
current_user.send(pluralized_content_name).unarchived.includes(:page_tags, :image_uploads) +
|
||||
current_user.send("contributable_#{pluralized_content_name}").unarchived.includes(:page_tags, :image_uploads)
|
||||
)
|
||||
|
||||
if @content_type_class != Universe
|
||||
my_universe_ids = current_user.universes.pluck(:id)
|
||||
@content.concat(@content_type_class.where(universe_id: my_universe_ids).unarchived)
|
||||
end
|
||||
end
|
||||
|
||||
@content = @content.to_a.flatten.uniq
|
||||
@show_scope_notice = @universe_scope.present? && @content_type_class != Universe
|
||||
|
||||
# Filters
|
||||
@page_tags = PageTag.where(
|
||||
page_type: @content_type_class.name,
|
||||
page_id: @content.pluck(:id)
|
||||
).order(:tag)
|
||||
if params.key?(:tag)
|
||||
@filtered_page_tags = @page_tags.where(slug: params[:tag])
|
||||
if params.key?(:slug)
|
||||
@filtered_page_tags = @page_tags.where(slug: params[:slug])
|
||||
@content.select! { |content| @filtered_page_tags.pluck(:page_id).include?(content.id) }
|
||||
end
|
||||
@page_tags = @page_tags.uniq(&:tag)
|
||||
|
||||
if params.key?(:favorite_only)
|
||||
@content.select!(&:favorite?)
|
||||
end
|
||||
|
||||
@page_tags = @page_tags.uniq(&:tag)
|
||||
|
||||
@content = @content.sort_by {|x| [x.favorite? ? 0 : 1, x.name] }
|
||||
|
||||
@questioned_content = @content.sample
|
||||
@attribute_field_to_question = SerendipitousService.question_for(@questioned_content)
|
||||
|
||||
# Uh, do we ever actually make JSON requests to logged-in user pages?
|
||||
respond_to do |format|
|
||||
format.html { render 'content/index' }
|
||||
format.json { render json: @content }
|
||||
@ -72,7 +64,7 @@ class ContentController < ApplicationController
|
||||
|
||||
def show
|
||||
content_type = content_type_from_controller(self.class)
|
||||
return redirect_to(root_path, notice: "That page doesn't exist!") unless valid_content_types.map(&:name).include?(content_type.name)
|
||||
return redirect_to(root_path, notice: "That page doesn't exist!") unless valid_content_types.include?(content_type.name)
|
||||
|
||||
@content = content_type.find_by(id: params[:id])
|
||||
return redirect_to(root_path, notice: "You don't have permission to view that content.") if @content.nil?
|
||||
@ -126,6 +118,10 @@ class ContentController < ApplicationController
|
||||
end
|
||||
|
||||
@content.save!
|
||||
|
||||
# If the user doesn't have this content type enabled, go ahead and automatically enable it for them
|
||||
current_user.user_content_type_activators.find_or_create_by(content_type: @content.class.name)
|
||||
|
||||
return redirect_to edit_polymorphic_path(@content)
|
||||
else
|
||||
return redirect_to(subscription_path, notice: "#{@content.class.name.pluralize} require a Premium subscription to create.")
|
||||
@ -194,6 +190,9 @@ class ContentController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
# If the user doesn't have this content type enabled, go ahead and automatically enable it for them
|
||||
current_user.user_content_type_activators.find_or_create_by(content_type: content_type.name)
|
||||
|
||||
successful_response(content_creation_redirect_url, t(:create_success, model_name: @content.try(:name).presence || humanized_model_name))
|
||||
else
|
||||
failed_response('new', :unprocessable_entity, "Unable to save page. Error code: " + @content.errors.to_json.to_s)
|
||||
@ -289,7 +288,7 @@ class ContentController < ApplicationController
|
||||
|
||||
def changelog
|
||||
content_type = content_type_from_controller(self.class)
|
||||
return redirect_to root_path unless valid_content_types.map(&:name).include?(content_type.name)
|
||||
return redirect_to root_path unless valid_content_types.include?(content_type.name)
|
||||
@content = content_type.find_by(id: params[:id])
|
||||
return redirect_to(root_path, notice: "You don't have permission to view that content.") if @content.nil?
|
||||
@serialized_content = ContentSerializer.new(@content)
|
||||
@ -421,6 +420,7 @@ class ContentController < ApplicationController
|
||||
set_entity
|
||||
referencing_page = @entity
|
||||
|
||||
# TODO: move this into a link mention update job
|
||||
valid_reference_ids = []
|
||||
referenced_page_codes = JSON.parse(attribute_value.value)
|
||||
referenced_page_codes.each do |page_code|
|
||||
@ -455,7 +455,7 @@ class ContentController < ApplicationController
|
||||
|
||||
# We also need to update the cached `name` field on the content page itself
|
||||
entity_type = entity_params.fetch(:entity_type)
|
||||
raise "Invalid entity type: #{entity_params.fetch(:entity_type)}" unless valid_content_types.map(&:name).include?(entity_params.fetch('entity_type'))
|
||||
raise "Invalid entity type: #{entity_params.fetch(:entity_type)}" unless valid_content_types.include?(entity_params.fetch('entity_type'))
|
||||
entity = entity_type.constantize.find_by(id: entity_params.fetch(:entity_id).to_i)
|
||||
entity.update(name: field_params.fetch('value', ''))
|
||||
end
|
||||
@ -469,35 +469,16 @@ class ContentController < ApplicationController
|
||||
attribute_value.value = text
|
||||
attribute_value.save!
|
||||
|
||||
# Create PageReferences for mentioned pages
|
||||
tokens = ContentFormatterService.tokens_to_replace(text)
|
||||
if tokens.any?
|
||||
set_entity
|
||||
UpdateTextAttributeReferencesJob.perform_later(attribute_value.id)
|
||||
|
||||
valid_reference_ids = []
|
||||
tokens.each do |token|
|
||||
reference = @entity.outgoing_page_references.find_or_initialize_by(
|
||||
referenced_page_type: token[:content_type],
|
||||
referenced_page_id: token[:content_id],
|
||||
attribute_field_id: @attribute_field.id,
|
||||
reference_type: 'mentioned'
|
||||
)
|
||||
reference.cached_relation_title = @attribute_field.label
|
||||
reference.save!
|
||||
|
||||
valid_reference_ids << reference.reload.id
|
||||
end
|
||||
|
||||
# Delete all other references still attached to this field, but not present in this request
|
||||
@entity.outgoing_page_references
|
||||
.where(attribute_field_id: @attribute_field.id)
|
||||
.where.not(id: valid_reference_ids)
|
||||
.destroy_all
|
||||
respond_to do |format|
|
||||
format.html { redirect_back(fallback_location: root_path, notice: "#{@attribute_field.label} updated!") }
|
||||
format.json { render json: attribute_value, status: :success }
|
||||
end
|
||||
end
|
||||
|
||||
def tags_field_update
|
||||
return unless valid_content_types.map(&:name).include?(entity_params.fetch('entity_type'))
|
||||
return unless valid_content_types.include?(entity_params.fetch('entity_type'))
|
||||
|
||||
@attribute_field = AttributeField.find_by(id: params[:field_id].to_i)
|
||||
attribute_value = @attribute_field.attribute_values.order('created_at desc').find_or_initialize_by(entity_params)
|
||||
@ -512,7 +493,7 @@ class ContentController < ApplicationController
|
||||
end
|
||||
|
||||
def universe_field_update
|
||||
return unless valid_content_types.map(&:name).include?(entity_params.fetch('entity_type'))
|
||||
return unless valid_content_types.include?(entity_params.fetch('entity_type'))
|
||||
|
||||
@attribute_field = AttributeField.find_by(id: params[:field_id].to_i)
|
||||
attribute_value = @attribute_field.attribute_values.order('created_at desc').find_or_initialize_by(entity_params)
|
||||
@ -580,7 +561,7 @@ class ContentController < ApplicationController
|
||||
end
|
||||
|
||||
def valid_content_types
|
||||
Rails.application.config.content_types[:all]
|
||||
Rails.application.config.content_type_names[:all]
|
||||
end
|
||||
|
||||
def initialize_object
|
||||
@ -656,7 +637,7 @@ class ContentController < ApplicationController
|
||||
def set_attributes_content_type
|
||||
@content_type = params[:content_type]
|
||||
# todo make this a before_action load_content_type
|
||||
unless valid_content_types.map { |c| c.name.downcase }.include?(@content_type)
|
||||
unless valid_content_types.map(&:downcase).include?(@content_type)
|
||||
raise "Invalid content type on attributes customization page: #{@content_type}"
|
||||
end
|
||||
@content_type_class = @content_type.titleize.constantize
|
||||
@ -671,7 +652,7 @@ class ContentController < ApplicationController
|
||||
entity_page_type = entity_params.fetch(:entity_type)
|
||||
entity_page_id = entity_params.fetch(:entity_id)
|
||||
|
||||
return unless valid_content_types.map(&:name).include?(entity_page_type)
|
||||
return unless valid_content_types.include?(entity_page_type)
|
||||
@entity = entity_page_type.constantize.find_by(id: entity_page_id)
|
||||
end
|
||||
|
||||
|
||||
@ -115,6 +115,10 @@ class DataController < ApplicationController
|
||||
@content = current_user.content
|
||||
end
|
||||
|
||||
def tags
|
||||
@tags = current_user.page_tags
|
||||
end
|
||||
|
||||
def discussions
|
||||
@topics = Thredded::Topic.where(user_id: current_user.id)
|
||||
@posts = Thredded::Post.where(user_id: current_user.id)
|
||||
@ -140,6 +144,9 @@ class DataController < ApplicationController
|
||||
@share_link = "https://www.notebook.ai/?referral=#{current_user.referral_code.code}"
|
||||
end
|
||||
|
||||
def green
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_sidenav_expansion
|
||||
|
||||
@ -4,12 +4,18 @@ class DocumentsController < ApplicationController
|
||||
# todo Uh, this is a hack. The CSRF token on document editor model to add entities is being rejected... for whatever reason.
|
||||
skip_before_action :verify_authenticity_token, only: [:link_entity]
|
||||
|
||||
skip_before_action :cache_most_used_page_information, only: [:update]
|
||||
|
||||
before_action :set_document, only: [:show, :analysis, :plaintext, :queue_analysis, :edit, :destroy]
|
||||
before_action :set_sidenav_expansion, except: [:plaintext]
|
||||
before_action :set_navbar_color, except: [:plaintext]
|
||||
before_action :set_navbar_actions, except: [:edit, :plaintext]
|
||||
before_action :set_footer_visibility, only: [:edit]
|
||||
|
||||
# Skip UI-heavy calls for API endpoints
|
||||
skip_before_action :cache_most_used_page_information, only: [:update]
|
||||
skip_before_action :cache_forums_unread_counts, only: [:update]
|
||||
|
||||
# TODO: verify_user_can_read, verify_user_can_edit, etc before_actions instead of inlining them
|
||||
|
||||
before_action :cache_linkable_content_for_each_content_type, only: [:edit]
|
||||
@ -181,21 +187,21 @@ class DocumentsController < ApplicationController
|
||||
|
||||
def update
|
||||
document = Document.with_deleted.find_or_initialize_by(id: params[:id])
|
||||
d_params = document_params.clone # TODO: why are we duplicating the params here?
|
||||
|
||||
unless document.updatable_by?(current_user)
|
||||
redirect_to(dashboard_path, notice: "You don't have permission to do that!")
|
||||
return
|
||||
end
|
||||
|
||||
# Only queue document mentions for analysis if the document body has changed
|
||||
DocumentMentionJob.perform_later(document.id) if d_params.key?(:body)
|
||||
|
||||
# We can't pass actual-nil from HTML (for no universe), so we pass a string instead and convert it back here.
|
||||
d_params = document_params.clone
|
||||
if d_params.fetch(:universe_id, nil) == "nil"
|
||||
d_params[:universe_id] = nil
|
||||
end
|
||||
|
||||
# Only queue document mentions for analysis if the document body has changed
|
||||
DocumentMentionJob.perform_later(document.id) if d_params.key?(:body)
|
||||
|
||||
update_page_tags(document) if document_tag_params
|
||||
|
||||
if document.update(d_params)
|
||||
|
||||
@ -25,8 +25,7 @@ class MainController < ApplicationController
|
||||
def dashboard
|
||||
@page_title = "My notebook"
|
||||
|
||||
set_random_content # for questions
|
||||
@attribute_field_to_question = SerendipitousService.question_for(@content)
|
||||
set_questionable_content # for questions
|
||||
end
|
||||
|
||||
def infostack
|
||||
@ -35,13 +34,37 @@ class MainController < ApplicationController
|
||||
def sascon
|
||||
end
|
||||
|
||||
def paper
|
||||
@navbar_color = '#4CAF50'
|
||||
|
||||
@total_notebook_pages = 0
|
||||
@total_pages_equivalent = 0
|
||||
@total_trees_saved = 0
|
||||
|
||||
@per_page_savings = {}
|
||||
|
||||
(Rails.application.config.content_types[:all] + [Timeline, Document]).each do |content_type|
|
||||
physical_page_equivalent = GreenService.total_physical_pages_equivalent(content_type)
|
||||
tree_equivalent = physical_page_equivalent.to_f / GreenService::SHEETS_OF_PAPER_PER_TREE
|
||||
|
||||
@per_page_savings[content_type.name] = {
|
||||
digital: content_type.last.try(:id) || 0,
|
||||
pages: physical_page_equivalent,
|
||||
trees: tree_equivalent
|
||||
}
|
||||
|
||||
@total_notebook_pages += @per_page_savings.dig(content_type.name, :digital)
|
||||
@total_pages_equivalent += @per_page_savings.dig(content_type.name, :pages)
|
||||
@total_trees_saved += @per_page_savings.dig(content_type.name, :trees)
|
||||
end
|
||||
end
|
||||
|
||||
def prompts
|
||||
@sidenav_expansion = 'writing'
|
||||
@navbar_color = '#FF9800'
|
||||
@page_title = "Writing prompts"
|
||||
|
||||
set_random_content # for question
|
||||
@attribute_field_to_question = SerendipitousService.question_for(@content)
|
||||
set_questionable_content # for question
|
||||
end
|
||||
|
||||
# deprecated path just kept around for bookmarks for a while
|
||||
@ -50,13 +73,6 @@ class MainController < ApplicationController
|
||||
end
|
||||
|
||||
def recent_content
|
||||
# todo optimize this / use Attributes
|
||||
return [] if @activated_content_types.nil?
|
||||
|
||||
@recently_created_pages = @current_user_content.values.flatten
|
||||
.sort_by(&:created_at)
|
||||
.last(50)
|
||||
.reverse
|
||||
end
|
||||
|
||||
def for_writers
|
||||
@ -80,27 +96,8 @@ class MainController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def set_random_content
|
||||
@activated_content_types.shuffle.each do |content_type|
|
||||
if content_type == Universe.name
|
||||
if @universe_scope.present?
|
||||
@content = content_type.constantize.where(user: current_user, id: @universe_scope.id).includes(:user)
|
||||
else
|
||||
@content = content_type.constantize.where(user: current_user).includes(:user)
|
||||
end
|
||||
else
|
||||
if @universe_scope.present?
|
||||
# when we want to enable prompts for contributing universes we can remove the user:
|
||||
# selector here, but we will need to verify the user has permission to see the universe
|
||||
# when we do that, or else prompts could open leak
|
||||
@content = content_type.constantize.where(user: current_user, universe: @universe_scope).includes(:user, :universe)
|
||||
else
|
||||
@content = content_type.constantize.where(user: current_user).includes(:user, :universe)
|
||||
end
|
||||
end
|
||||
|
||||
@content = @content.sample
|
||||
return if @content.present?
|
||||
end
|
||||
def set_questionable_content
|
||||
@content = @current_user_content.except(*%w(Timeline Document)).values.flatten.sample
|
||||
@attribute_field_to_question = SerendipitousService.question_for(@content)
|
||||
end
|
||||
end
|
||||
|
||||
23
app/controllers/page_tags_controller.rb
Normal file
23
app/controllers/page_tags_controller.rb
Normal file
@ -0,0 +1,23 @@
|
||||
class PageTagsController < ApplicationController
|
||||
# Remove a tag and all of its links to a page
|
||||
def remove
|
||||
# Params
|
||||
# {"page_type"=>"Location", "slug"=>"mountains", "controller"=>"page_tags", "action"=>"remove"
|
||||
return unless params.key?(:page_type) && params.key?(:slug)
|
||||
|
||||
PageTag.where(
|
||||
page_type: params[:page_type],
|
||||
slug: params[:slug],
|
||||
user_id: current_user.id
|
||||
).destroy_all
|
||||
|
||||
return redirect_back fallback_location: root_path, notice: 'Tag(s) deleted successfully.'
|
||||
end
|
||||
|
||||
# Destroy a specific tag by ID
|
||||
def destroy
|
||||
PageTag.find_by(id: params[:id], user_id: current_user.id).destroy!
|
||||
|
||||
return redirect_back fallback_location: root_path, notice: 'Tag(s) deleted successfully.'
|
||||
end
|
||||
end
|
||||
@ -7,6 +7,8 @@ class TimelinesController < ApplicationController
|
||||
|
||||
# GET /timelines
|
||||
def index
|
||||
cache_linkable_content_for_each_content_type
|
||||
|
||||
@timelines = current_user.timelines
|
||||
@page_title = "My timelines"
|
||||
|
||||
|
||||
2
app/helpers/page_tags_helper.rb
Normal file
2
app/helpers/page_tags_helper.rb
Normal file
@ -0,0 +1,2 @@
|
||||
module PageTagsHelper
|
||||
end
|
||||
30
app/jobs/cache_attribute_word_count_job.rb
Normal file
30
app/jobs/cache_attribute_word_count_job.rb
Normal file
@ -0,0 +1,30 @@
|
||||
class CacheAttributeWordCountJob < ApplicationJob
|
||||
queue_as :cache
|
||||
|
||||
def perform(*args)
|
||||
attribute_id = args.shift
|
||||
attribute = Attribute.find_by(id: attribute_id)
|
||||
|
||||
return if attribute.nil?
|
||||
return if attribute.value.nil? || attribute.value.blank?
|
||||
|
||||
word_count = WordCountAnalyzer::Counter.new(
|
||||
ellipsis: 'no_special_treatment',
|
||||
hyperlink: 'count_as_one',
|
||||
contraction: 'count_as_one',
|
||||
hyphenated_word: 'count_as_one',
|
||||
date: 'no_special_treatment',
|
||||
number: 'count',
|
||||
numbered_list: 'ignore',
|
||||
xhtml: 'remove',
|
||||
forward_slash: 'count_as_multiple_except_dates',
|
||||
backslash: 'count_as_one',
|
||||
dotted_line: 'ignore',
|
||||
dashed_line: 'ignore',
|
||||
underscore: 'ignore',
|
||||
stray_punctuation: 'ignore'
|
||||
).count(attribute.value)
|
||||
|
||||
attribute.update!(word_count_cache: word_count)
|
||||
end
|
||||
end
|
||||
20
app/jobs/cache_sum_attribute_word_count_job.rb
Normal file
20
app/jobs/cache_sum_attribute_word_count_job.rb
Normal file
@ -0,0 +1,20 @@
|
||||
class CacheSumAttributeWordCountJob < ApplicationJob
|
||||
queue_as :cache
|
||||
|
||||
def perform(*args)
|
||||
entity_type = args.shift
|
||||
entity_id = args.shift
|
||||
|
||||
entity = entity_type.constantize.find_by(id: entity_id)
|
||||
return if entity.nil?
|
||||
|
||||
sum_attribute_word_count = Attribute.where(entity_type: entity_type, entity_id: entity_id).sum(:word_count_cache)
|
||||
update = entity.word_count_updates.find_or_initialize_by(
|
||||
for_date: DateTime.current,
|
||||
)
|
||||
update.word_count = sum_attribute_word_count
|
||||
update.user_id ||= entity.user_id
|
||||
|
||||
update.save!
|
||||
end
|
||||
end
|
||||
@ -4,7 +4,7 @@ class DocumentEntityAnalysisJob < ApplicationJob
|
||||
def perform(*args)
|
||||
document_entity_id = args.shift
|
||||
|
||||
entity = DocumentEntity.find(document_entity_id)
|
||||
entity = DocumentEntity.find_by(id: document_entity_id)
|
||||
return unless entity.present?
|
||||
|
||||
Documents::Analysis::ThirdParty::IbmWatsonService.analyze_entity(document_entity_id)
|
||||
|
||||
@ -4,13 +4,21 @@ class SaveDocumentRevisionJob < ApplicationJob
|
||||
def perform(*args)
|
||||
document_id = args.shift
|
||||
|
||||
document = Document.find(document_id)
|
||||
return unless document.present?
|
||||
document = Document.find_by(id: document_id)
|
||||
return unless document
|
||||
|
||||
# Update cached word count for the document regardless of how often this is called
|
||||
new_word_count = document.computed_word_count
|
||||
document.update(cached_word_count: new_word_count)
|
||||
|
||||
# Save a WordCountUpdate for this document for today
|
||||
update = document.word_count_updates.find_or_initialize_by(
|
||||
for_date: DateTime.current,
|
||||
)
|
||||
update.word_count = new_word_count
|
||||
update.user_id ||= document.user_id
|
||||
update.save!
|
||||
|
||||
# Make sure we're only storing revisions at least every 5 min
|
||||
latest_revision = document.document_revisions.order('created_at DESC').limit(1).first
|
||||
if latest_revision.present? && latest_revision.created_at > 5.minutes.ago
|
||||
|
||||
36
app/jobs/update_text_attribute_references_job.rb
Normal file
36
app/jobs/update_text_attribute_references_job.rb
Normal file
@ -0,0 +1,36 @@
|
||||
class UpdateTextAttributeReferencesJob < ApplicationJob
|
||||
queue_as :mentions
|
||||
|
||||
def perform(*args)
|
||||
attribute_id = args.shift
|
||||
|
||||
attribute = Attribute.find_by(id: attribute_id)
|
||||
return unless attribute.present?
|
||||
|
||||
# Create PageReferences for mentioned pages
|
||||
tokens = ContentFormatterService.tokens_to_replace(attribute.value)
|
||||
if tokens.any?
|
||||
entity = attribute.entity
|
||||
|
||||
valid_reference_ids = []
|
||||
tokens.each do |token|
|
||||
reference = entity.outgoing_page_references.find_or_initialize_by(
|
||||
referenced_page_type: token[:content_type],
|
||||
referenced_page_id: token[:content_id],
|
||||
attribute_field_id: attribute.attribute_field_id,
|
||||
reference_type: 'mentioned'
|
||||
)
|
||||
reference.cached_relation_title = AttributeField.find_by(id: attribute.attribute_field_id).try(:label)
|
||||
reference.save!
|
||||
|
||||
valid_reference_ids << reference.reload.id
|
||||
end
|
||||
|
||||
# Delete all other references still attached to this field, but not present in this request
|
||||
entity.outgoing_page_references
|
||||
.where(attribute_field_id: attribute.attribute_field_id)
|
||||
.where.not(id: valid_reference_ids)
|
||||
.destroy_all
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -42,17 +42,28 @@ module HasAttributes
|
||||
end
|
||||
|
||||
def self.attribute_categories(user, show_hidden: false)
|
||||
# TODO: this is a code smell; we should probably either be whitelisting or fixing whatever is calling
|
||||
# this with the wrong models
|
||||
return [] if ['attribute_category', 'attribute_field'].include?(content_name)
|
||||
|
||||
# Cache the result in case we call this function multiple times this request
|
||||
@cached_attribute_categories_for_this_content = begin
|
||||
# Always include the flatfile categories (but create AR versions if they don't exist)
|
||||
categories_list = AttributeCategory.with_deleted.where(user: user).to_a
|
||||
full_fields_list = AttributeField.with_deleted.where(user: user, attribute_category_id: categories_list.map(&:id))
|
||||
categories = YAML.load_file(Rails.root.join('config', 'attributes', "#{content_name}.yml")).map do |category_name, details|
|
||||
category = ::AttributeCategory.with_deleted.find_or_initialize_by(
|
||||
entity_type: self.content_name,
|
||||
name: category_name.to_s,
|
||||
user: user
|
||||
)
|
||||
category = categories_list.detect do |persisted_category|
|
||||
persisted_category.entity_type == self.content_name &&
|
||||
persisted_category.name == category_name.to_s
|
||||
end
|
||||
if category.nil?
|
||||
category = AttributeCategory.new(
|
||||
entity_type: self.content_name,
|
||||
name: category_name.to_s,
|
||||
user: user
|
||||
)
|
||||
end
|
||||
|
||||
# Default new categories to some sane defaults
|
||||
unless category.persisted?
|
||||
category.icon = details[:icon]
|
||||
@ -60,13 +71,21 @@ module HasAttributes
|
||||
end
|
||||
|
||||
category.save! if user && category.new_record?
|
||||
fields_list = full_fields_list.select do |field|
|
||||
field.attribute_category_id == category.id
|
||||
end
|
||||
category.attribute_fields << details[:attributes].map do |field|
|
||||
af_field = category.attribute_fields.with_deleted.find_or_initialize_by(
|
||||
# label: field[:label],
|
||||
old_column_source: field[:name],
|
||||
user: user,
|
||||
field_type: field[:field_type].presence || "text_area"
|
||||
)
|
||||
af_field = fields_list.detect do |persisted_field|
|
||||
persisted_field.old_column_source == field[:name] &&
|
||||
persisted_field.field_type == field[:field_type].presence || "text_area"
|
||||
end
|
||||
if af_field.nil?
|
||||
af_field = category.attribute_fields.new(
|
||||
old_column_source: field[:name],
|
||||
user: user,
|
||||
field_type: field[:field_type].presence || "text_area"
|
||||
)
|
||||
end
|
||||
if af_field.label.nil?
|
||||
af_field.label = field[:label]
|
||||
end
|
||||
@ -180,6 +199,7 @@ module HasAttributes
|
||||
end
|
||||
end
|
||||
|
||||
# All of these helpers are spooky and rife for N+1s
|
||||
def name_field
|
||||
category_ids = AttributeCategory.where(
|
||||
user_id: user_id,
|
||||
|
||||
@ -16,18 +16,49 @@ module HasContent
|
||||
has_many :attribute_categories
|
||||
has_many :attribute_values, class_name: 'Attribute', dependent: :destroy
|
||||
|
||||
# Alternative approach to #content for benchmarking in prod
|
||||
def content_with_multiple_queries(
|
||||
content_types: Rails.application.config.content_type_names[:all],
|
||||
page_scoping: { user_id: self.id },
|
||||
universe_id: nil
|
||||
)
|
||||
@content_by_page_type = {}
|
||||
content_types.each do |content_type|
|
||||
type_specific_fields = ContentPage.polymorphic_content_fields
|
||||
type_specific_fields.push 'universe_id' unless content_type == 'Universe'
|
||||
|
||||
pages_of_this_type = content_type.constantize
|
||||
.where(page_scoping)
|
||||
.select(type_specific_fields)
|
||||
|
||||
if content_type != 'Universe' && universe_id.present?
|
||||
pages_of_this_type = pages_of_this_type.where(universe_id: universe_id)
|
||||
end
|
||||
|
||||
@content_by_page_type[content_type] = []
|
||||
pages_of_this_type.each do |page_data|
|
||||
@content_by_page_type[content_type].push ContentPage.new(page_data.attributes)
|
||||
end
|
||||
end
|
||||
|
||||
@content_by_page_type
|
||||
end
|
||||
|
||||
# {
|
||||
# characters: [...],
|
||||
# locations: [...]
|
||||
# }
|
||||
def content(
|
||||
content_types: Rails.application.config.content_types[:all].map(&:name),
|
||||
content_types: Rails.application.config.content_type_names[:all],
|
||||
page_scoping: { user_id: self.id },
|
||||
universe_id: nil
|
||||
)
|
||||
return {} if content_types.empty?
|
||||
# return content_with_multiple_queries(content_types: content_types, page_scoping: page_scoping, universe_id: universe_id)
|
||||
|
||||
polymorphic_content_fields = [:id, :name, :page_type, :user_id, :created_at, :updated_at, :deleted_at, :archived_at, :privacy]
|
||||
return {} if content_types.empty?
|
||||
# TODO: we should return early if we already have @content_by_page_type!!!
|
||||
|
||||
polymorphic_content_fields = ContentPage.polymorphic_content_fields
|
||||
where_conditions = page_scoping.map { |key, value| "#{key} = #{value}" }.join(' AND ') + ' AND deleted_at IS NULL AND archived_at IS NULL'
|
||||
|
||||
sql = content_types.uniq.map do |page_type|
|
||||
@ -67,7 +98,7 @@ module HasContent
|
||||
)
|
||||
|
||||
# todo we can't select for universe_id here which kind of sucks, so we need to research 1) the repercussions, 2) what to do instead
|
||||
polymorphic_content_fields = [:id, :name, :page_type, :user_id, :created_at, :updated_at, :deleted_at, :archived_at, :privacy]
|
||||
polymorphic_content_fields = ContentPage.polymorphic_content_fields
|
||||
where_conditions = page_scoping.map { |key, value| "#{key} = #{value}" }.join(' AND ') + ' AND deleted_at IS NULL AND archived_at IS NULL'
|
||||
|
||||
sql = content_types.uniq.map do |page_type|
|
||||
|
||||
@ -9,23 +9,34 @@ module HasImageUploads
|
||||
# todo: destroy from s3 on destroy
|
||||
|
||||
def public_image_uploads
|
||||
self.image_uploads.where(privacy: 'public').presence || ["card-headers/#{self.class.name.downcase.pluralize}.jpg"]
|
||||
self.image_uploads.where(privacy: 'public').presence || [header_asset_for(self.class.name)]
|
||||
end
|
||||
|
||||
def private_image_uploads
|
||||
self.image.uploads.where(privacy: 'private').presence || ["card-headers/#{self.class.name.downcase.pluralize}.jpg"]
|
||||
self.image.uploads.where(privacy: 'private').presence || [header_asset_for(self.class.name)]
|
||||
end
|
||||
|
||||
def random_image_including_private(format: :medium)
|
||||
image_uploads.sample.try(:src, format).presence || "card-headers/#{self.class.name.downcase.pluralize}.jpg"
|
||||
@random_image_including_private_cache ||= {}
|
||||
key = self.class.name + self.id.to_s
|
||||
return @random_image_including_private_cache[key] if @random_image_including_private_cache.key?(key)
|
||||
|
||||
result = image_uploads.sample.try(:src, format).presence || header_asset_for(self.class.name)
|
||||
@random_image_including_private_cache[key] = result
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def first_public_image(format: :medium)
|
||||
public_image_uploads.first.try(:src, format).presence || "card-headers/#{self.class.name.downcase.pluralize}.jpg"
|
||||
public_image_uploads.first.try(:src, format).presence || header_asset_for(self.class.name)
|
||||
end
|
||||
|
||||
def random_public_image(format: :medium)
|
||||
public_image_uploads.sample.try(:src, format).presence || "card-headers/#{self.class.name.downcase.pluralize}.jpg"
|
||||
public_image_uploads.sample.try(:src, format).presence || header_asset_for(self.class.name)
|
||||
end
|
||||
|
||||
def header_asset_for(class_name)
|
||||
ActionController::Base.helpers.asset_path("card-headers/#{class_name.downcase.pluralize}.jpg")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@ -4,6 +4,6 @@ module HasPageTags
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :page_tags, as: :page
|
||||
has_many :page_tags, as: :page, dependent: :destroy
|
||||
end
|
||||
end
|
||||
|
||||
@ -18,6 +18,11 @@ module IsContentPage
|
||||
has_many :timeline_events, through: :timeline_event_entities
|
||||
has_many :timelines, -> { distinct }, through: :timeline_events
|
||||
|
||||
has_many :word_count_updates, as: :entity, dependent: :destroy
|
||||
def latest_word_count_cache
|
||||
word_count_updates.order('for_date DESC').limit(1).first.try(:word_count) || 0
|
||||
end
|
||||
|
||||
scope :unarchived, -> { where(archived_at: nil) }
|
||||
def archive!
|
||||
update!(archived_at: DateTime.now)
|
||||
|
||||
@ -26,6 +26,12 @@ class Document < ApplicationRecord
|
||||
|
||||
attr_accessor :tagged_text
|
||||
|
||||
# Duplicated from is_content_page since we don't include that here yet
|
||||
has_many :word_count_updates, as: :entity, dependent: :destroy
|
||||
def latest_word_count_cache
|
||||
word_count_updates.order('for_date DESC').limit(1).first.try(:word_count) || 0
|
||||
end
|
||||
|
||||
KEYS_TO_TRIGGER_REVISION_ON_CHANGE = %w(title body synopsis notes_text)
|
||||
|
||||
def self.color
|
||||
|
||||
@ -51,7 +51,7 @@ class PageCollection < ApplicationRecord
|
||||
end
|
||||
|
||||
# If all else fails, fall back on default header
|
||||
"card-headers/#{self.class.name.downcase.pluralize}.jpg"
|
||||
ActionController::Base.helpers.asset_path("card-headers/#{self.class.name.downcase.pluralize}.jpg")
|
||||
end
|
||||
|
||||
def first_public_image
|
||||
|
||||
@ -19,6 +19,16 @@ class Attribute < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
after_commit do
|
||||
if saved_changes.key?('value')
|
||||
# Cache the updated word count on this attribute
|
||||
CacheAttributeWordCountJob.perform_later(self.id)
|
||||
|
||||
# Cache the updated word count on the page this attribute belongs to
|
||||
CacheSumAttributeWordCountJob.perform_later(self.entity_type, self.entity_id)
|
||||
end
|
||||
end
|
||||
|
||||
after_save do
|
||||
entity.touch
|
||||
end
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
class ContentPage < ApplicationRecord
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :universe
|
||||
|
||||
attr_accessor :favorite
|
||||
|
||||
include Authority::Abilities
|
||||
self.authorizer_name = 'ContentPageAuthorizer'
|
||||
|
||||
def random_image_including_private(format: :small)
|
||||
ImageUpload.where(content_type: self.page_type, content_id: self.id).sample.try(:src, format) || "card-headers/#{self.page_type.downcase.pluralize}.jpg"
|
||||
ImageUpload.where(content_type: self.page_type, content_id: self.id).sample.try(:src, format) || ActionController::Base.helpers.asset_path("card-headers/#{self.page_type.downcase.pluralize}.jpg")
|
||||
end
|
||||
|
||||
def icon
|
||||
@ -17,4 +24,20 @@ class ContentPage < ApplicationRecord
|
||||
def text_color
|
||||
self.page_type.constantize.text_color
|
||||
end
|
||||
|
||||
def favorite?
|
||||
!!favorite
|
||||
end
|
||||
|
||||
def view_path
|
||||
send("#{self.page_type.downcase}_path", self.id)
|
||||
end
|
||||
|
||||
def edit_path
|
||||
send("edit_#{self.page_type.downcase}_path", self.id)
|
||||
end
|
||||
|
||||
def self.polymorphic_content_fields
|
||||
[:id, :name, :favorite, :page_type, :user_id, :created_at, :updated_at, :deleted_at, :archived_at, :privacy]
|
||||
end
|
||||
end
|
||||
|
||||
@ -20,7 +20,7 @@ class Creature < ApplicationRecord
|
||||
include Serendipitous::Concern
|
||||
|
||||
include Authority::Abilities
|
||||
self.authorizer_name = 'ExtendedContentAuthorizer'
|
||||
self.authorizer_name = 'CoreContentAuthorizer'
|
||||
|
||||
# Locations
|
||||
relates :habitats, with: :wildlifeships
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
class TimelineEvent < ApplicationRecord
|
||||
acts_as_paranoid
|
||||
|
||||
belongs_to :timeline
|
||||
belongs_to :timeline, touch: true
|
||||
|
||||
has_many :timeline_event_entities, dependent: :destroy
|
||||
|
||||
|
||||
@ -119,13 +119,12 @@ class User < ApplicationRecord
|
||||
@cached_user_contributable_universes ||= Universe.where(id: contributable_universe_ids)
|
||||
end
|
||||
|
||||
def linkable_universes
|
||||
@cached_linkable_universes ||= Universe.where(id: my_universe_ids + contributable_universes)
|
||||
end
|
||||
|
||||
def contributable_universe_ids
|
||||
# TODO: email confirmation needs to happen for data safety / privacy (only verified emails)
|
||||
@contributable_universe_ids ||= Contributor.where('email = ? OR user_id = ?', self.email, self.id).pluck(:universe_id)
|
||||
@contributable_universe_ids += Contributor.where(universe_id: my_universe_ids).pluck(:universe_id)
|
||||
|
||||
@contributable_universe_ids.uniq
|
||||
end
|
||||
|
||||
# TODO: rename this to #{content_type}_shared_with_me
|
||||
@ -156,7 +155,7 @@ class User < ApplicationRecord
|
||||
universe_id IN (#{(my_universe_ids + contributable_universe_ids + [-1]).uniq.join(',')})
|
||||
OR
|
||||
(universe_id IS NULL AND user_id = #{self.id.to_i})
|
||||
""")
|
||||
""").includes([:user])
|
||||
end
|
||||
|
||||
def linkable_timelines
|
||||
|
||||
4
app/models/word_count_update.rb
Normal file
4
app/models/word_count_update.rb
Normal file
@ -0,0 +1,4 @@
|
||||
class WordCountUpdate < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :entity, polymorphic: true
|
||||
end
|
||||
@ -15,4 +15,13 @@ class FieldTypeService < Service
|
||||
raise "Unexpected/unhandled field type: #{field[:type]}"
|
||||
end
|
||||
end
|
||||
|
||||
def self.form_path_from_attribute_field(field)
|
||||
field_data = {
|
||||
type: field.field_type,
|
||||
internal_id: field.id
|
||||
}
|
||||
|
||||
form_path(field_data)
|
||||
end
|
||||
end
|
||||
|
||||
107
app/services/green_service.rb
Normal file
107
app/services/green_service.rb
Normal file
@ -0,0 +1,107 @@
|
||||
class GreenService < Service
|
||||
AVERAGE_WORDS_PER_PAGE = 500
|
||||
AVERAGE_TIMELINE_EVENTS_PER_PAGE = 3
|
||||
SHEETS_OF_PAPER_PER_TREE = 11_000 # source: https://ribble-pack.co.uk/blog/much-paper-comes-one-tree
|
||||
|
||||
def self.physical_pages_equivalent_for(worldbuilding_page_type)
|
||||
# TODO: This would be better estimated with [average] word counts from pages (or a real total),
|
||||
# but we don't have that data computed (and definitely don't want to do so on each page load).
|
||||
# Until we have a better solution, these page counts come from printing out notebook pages
|
||||
# from http://www.notebook-paper.com/
|
||||
|
||||
case worldbuilding_page_type
|
||||
when "Universe" then 2
|
||||
when "Character" then 6
|
||||
when "Location" then 4
|
||||
when "Item" then 2
|
||||
when "Building" then 8
|
||||
when "Condition" then 4
|
||||
when "Continent" then 6
|
||||
when "Country" then 5
|
||||
when "Creature" then 8
|
||||
when "Deity" then 5
|
||||
when "Flora" then 4
|
||||
when "Food" then 5
|
||||
when "Government" then 6
|
||||
when "Group" then 4
|
||||
when "Job" then 4
|
||||
when "Landmark" then 3
|
||||
when "Language" then 5
|
||||
when "Lore" then 8
|
||||
when "Magic" then 4
|
||||
when "Planet" then 6
|
||||
when "Race" then 4
|
||||
when "Religion" then 3
|
||||
when "Scene" then 2
|
||||
when "School" then 6
|
||||
when "Sport" then 4
|
||||
when "Technology" then 4
|
||||
when "Town" then 4
|
||||
when "Tradition" then 3
|
||||
when "Vehicle" then 4
|
||||
else
|
||||
raise "Unknown green estimate: #{worldbuilding_page_type}"
|
||||
end
|
||||
end
|
||||
|
||||
def self.trees_saved_by(worldbuilding_page_type)
|
||||
(physical_pages_equivalent_for(worldbuilding_page_type) * (worldbuilding_page_type.constantize.last.try(:id) || 0)) / SHEETS_OF_PAPER_PER_TREE.to_f
|
||||
end
|
||||
|
||||
def self.total_document_pages_equivalent
|
||||
total_pages = 0
|
||||
|
||||
# Treat all <1-page documents as 1 page per document, since they'd print on separate pages
|
||||
total_pages += Document.with_deleted.where('cached_word_count <= ?', AVERAGE_WORDS_PER_PAGE).count
|
||||
|
||||
# For all >1-page documents, do a quick estimate of word count sum + num docs to also cover EOD page breaks
|
||||
docs = Document.with_deleted.where.not(cached_word_count: nil).where('cached_word_count > ?', AVERAGE_WORDS_PER_PAGE)
|
||||
total_pages += (docs.sum(:cached_word_count) / AVERAGE_WORDS_PER_PAGE.to_f).round
|
||||
total_pages += docs.count
|
||||
|
||||
total_pages
|
||||
end
|
||||
|
||||
def self.total_timeline_pages_equivalent
|
||||
((TimelineEvent.last.try(:id) || 0) / AVERAGE_TIMELINE_EVENTS_PER_PAGE.to_f).to_i
|
||||
end
|
||||
|
||||
def self.total_physical_pages_equivalent(content_type)
|
||||
case content_type.name
|
||||
when 'Timeline'
|
||||
GreenService.total_timeline_pages_equivalent
|
||||
when 'Document'
|
||||
GreenService.total_document_pages_equivalent
|
||||
else
|
||||
GreenService.physical_pages_equivalent_for(content_type.name) * (content_type.last.try(:id) || 0)
|
||||
end
|
||||
end
|
||||
|
||||
def self.total_pages_saved_by(user)
|
||||
total_pages = 0
|
||||
|
||||
user.content.each do |content_type, content_list|
|
||||
physical_page_equivalent_for_content_type = case content_type
|
||||
when 'Timeline'
|
||||
AVERAGE_TIMELINE_EVENTS_PER_PAGE * TimelineEvent.where(timeline_id: content_list.map(&:id)).count
|
||||
|
||||
when 'Document'
|
||||
[
|
||||
content_list.inject(0) { |sum, doc| sum + (doc.cached_word_count || 0) } / GreenService::AVERAGE_WORDS_PER_PAGE.to_f,
|
||||
content_list_count
|
||||
].max
|
||||
|
||||
else
|
||||
physical_pages_equivalent_for(content_type) * content_list.count
|
||||
end
|
||||
|
||||
total_pages += physical_page_equivalent_for_content_type
|
||||
end
|
||||
|
||||
total_pages
|
||||
end
|
||||
|
||||
def self.total_trees_saved_by(user)
|
||||
total_pages_saved_by(user).to_f / GreenService::SHEETS_OF_PAPER_PER_TREE
|
||||
end
|
||||
end
|
||||
@ -5,7 +5,7 @@ class PermissionService < Service
|
||||
end
|
||||
|
||||
def self.user_owns_content?(user:, content:)
|
||||
content.user && user && content.user.try(:id) == user.try(:id)
|
||||
content.user && user && content.try(:user_id) == user.try(:id)
|
||||
end
|
||||
|
||||
def self.user_owns_any_containing_universe?(user:, content:)
|
||||
@ -27,7 +27,11 @@ class PermissionService < Service
|
||||
def self.user_can_contribute_to_containing_universe?(user:, content:)
|
||||
return false if user.nil?
|
||||
return true if [AttributeCategory, AttributeField, Attribute].include?(content.class) #todo audit this
|
||||
content.universe.present? && user.contributable_universes.pluck(:id).include?(content.universe.id)
|
||||
|
||||
return true if user.contributable_universe_ids.include?(content.universe_id)
|
||||
return true if user.universes.pluck(:id).include?(content.universe_id)
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
def self.content_has_no_containing_universe?(content:)
|
||||
|
||||
@ -4,7 +4,7 @@ class SerendipitousService < Service
|
||||
|
||||
categories_for_this_type = AttributeCategory.where(
|
||||
user: content.user,
|
||||
entity_type: content.class.name.downcase,
|
||||
entity_type: content.page_type.downcase,
|
||||
hidden: [nil, false]
|
||||
)
|
||||
|
||||
@ -25,7 +25,7 @@ class SerendipitousService < Service
|
||||
#raise fields_for_these_categories.pluck(:label).inspect
|
||||
|
||||
attribute_fields_with_values = Attribute.where(
|
||||
entity_type: content.class.name,
|
||||
entity_type: content.page_type,
|
||||
entity_id: content.id,
|
||||
attribute_field_id: fields_for_these_categories.pluck(:id)
|
||||
).where.not(
|
||||
|
||||
@ -11,6 +11,7 @@ class TemporaryFieldMigrationService < Service
|
||||
def self.migrate_fields_for_content(content_model, user, force: false)
|
||||
return unless content_model.present? && user.present?
|
||||
return unless content_model.user == user
|
||||
return if content_model.is_a?(ContentPage)
|
||||
return if !force && content_model.persisted? && content_model.created_at > 'May 1, 2018'.to_datetime
|
||||
return if !!content_model.columns_migrated_from_old_style?
|
||||
|
||||
|
||||
@ -100,7 +100,7 @@
|
||||
<div class="card col s12 m10 offset-m1 l8 offset-l2">
|
||||
<div class="card-content">
|
||||
<div class="card-title">
|
||||
Gain full access to
|
||||
Gain full access to millions of
|
||||
<% Rails.application.config.content_types[:all_non_universe].each do |type| %>
|
||||
<span class="<%= type.text_color %>"><%= type.name.downcase.pluralize %>,</span>
|
||||
<% end %>
|
||||
@ -110,7 +110,7 @@
|
||||
<div class="col s12 m12 l6">
|
||||
<p>
|
||||
After a user authenticates your application, you'll have full access to integrate their worldbuilding pages into your app.
|
||||
You can show them their characters, let them edit one of their existing creatures, pin their towns and landmarks to your maps,
|
||||
You can show them their characters, let them import pictures from their locations, pin their towns and landmarks to your maps,
|
||||
create new items, and more — all without leaving your app.
|
||||
</p>
|
||||
<br />
|
||||
|
||||
@ -6,22 +6,25 @@
|
||||
<% if defined?(field) && field.present? %>
|
||||
<ul class="hoverable collapsible white">
|
||||
<li class="<%= 'active' if defined?(expand_by_default) && !!expand_by_default %>">
|
||||
<div class="collapsible-header <%= content.class.color %> white-text">
|
||||
<i class="material-icons tooltipped" data-tooltip="Answer this randomly-generated question to have it automatically saved to your <%= content.name_field_value %> <%= content.class.name.downcase %> page.">help</i>
|
||||
<div class="collapsible-header <%= content.color %> white-text">
|
||||
<i class="material-icons tooltipped" data-tooltip="Answer this randomly-generated question to have it automatically saved to your <%= content.name %> <%= content.page_type.downcase %> page.">help</i>
|
||||
<%=
|
||||
t(
|
||||
"serendipitous_questions.attributes.#{content.class.name.downcase}.#{field.label.downcase}",
|
||||
name: content.name_field_value,
|
||||
default: "What is #{content.name_field_value}'s #{field.label.downcase}?"
|
||||
"serendipitous_questions.attributes.#{content.page_type.downcase}.#{field.label.downcase}",
|
||||
name: content.name,
|
||||
default: "What is #{content.name}'s #{field.label.downcase}?"
|
||||
)
|
||||
%>
|
||||
</div>
|
||||
<div class="collapsible-body">
|
||||
<%= form_for content do |f| %>
|
||||
<%= form_for content, url: FieldTypeService.form_path_from_attribute_field(field), method: :patch do |f| %>
|
||||
<%= hidden_field(:override, :redirect_path, value: redirect_path) if defined?(redirect_path) %>
|
||||
|
||||
<%= hidden_field_tag "entity[entity_id]", content.id %>
|
||||
<%= hidden_field_tag "entity[entity_type]", content.page_type %>
|
||||
|
||||
<%=
|
||||
render 'content/form/text_input',
|
||||
render 'content/form/text_input_for_content_page',
|
||||
f: f,
|
||||
content: content,
|
||||
field: field
|
||||
@ -32,14 +35,14 @@
|
||||
<% end %>
|
||||
|
||||
<% if include_quick_reference %>
|
||||
<%= link_to content, class: 'entity-trigger sidenav-trigger orange white-text btn tooltipped', data: { target: "quick-reference-#{@content.class.name}-#{@content.id}", tooltip: "View this #{@content.class.name.downcase} without leaving this page" } do %>
|
||||
<%= link_to content.view_path, class: 'entity-trigger sidenav-trigger orange white-text btn tooltipped', data: { target: "quick-reference-#{content.page_type}-#{content.id}", tooltip: "View this #{content.page_type.downcase} without leaving this page" } do %>
|
||||
<i class="material-icons right">vertical_split</i>
|
||||
Quick-reference
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if !defined?(show_view_button) || !!show_view_button %>
|
||||
<%= link_to content, class: "btn #{content.class.color} white-text tooltipped", target: '_new', data: { tooltip: "View this #{@content.class.name.downcase} in a new tab" } do %>
|
||||
<i class="material-icons left white-text"><%= content.class.icon %></i>
|
||||
<%= link_to content.view_path, class: "btn #{content.color} white-text tooltipped", target: '_new', data: { tooltip: "View this #{content.name.downcase} in a new tab" } do %>
|
||||
<i class="material-icons left white-text"><%= content.icon %></i>
|
||||
View
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@ -30,8 +30,8 @@
|
||||
</li>
|
||||
<li class="divider" tabindex="-1"></li>
|
||||
<%
|
||||
linkable_universes_with_this_kind_of_content = current_user.linkable_universes.select do |universe|
|
||||
@current_user_content[content_type.name].any? { |content| content.universe_id == universe.id }
|
||||
linkable_universes_with_this_kind_of_content = @linkables_raw.fetch('Universe', []).select do |universe|
|
||||
@current_user_content.fetch(content_type.name, []).any? { |content| content.universe_id == universe.id }
|
||||
end
|
||||
%>
|
||||
<% linkable_universes_with_this_kind_of_content.each do |universe| %>
|
||||
@ -60,7 +60,7 @@
|
||||
</a>
|
||||
<ul id='tag-filter-dropdown' class='dropdown-content'>
|
||||
<li>
|
||||
<%= link_to params.permit(:tag, :favorite_only).merge({ tag: nil }), class: "#{content_type.text_color}" do %>
|
||||
<%= link_to params.permit(:tag, :favorite_only).merge({ slug: nil }), class: content_type.text_color do %>
|
||||
<i class="material-icons"><%= PageTag.icon %></i>
|
||||
All <%= content_type.name.downcase.pluralize %>
|
||||
<% end %>
|
||||
@ -69,7 +69,7 @@
|
||||
<% if @page_tags %>
|
||||
<% @page_tags.each do |page_tag| %>
|
||||
<li>
|
||||
<%= link_to params.permit(:tag, :favorite_only).merge({ tag: PageTagService.slug_for(page_tag.tag) }), class: "#{content_type.text_color}" do %>
|
||||
<%= link_to params.permit(:tag, :favorite_only).merge({ slug: PageTagService.slug_for(page_tag.tag) }), class: content_type.text_color do %>
|
||||
<i class="material-icons"><%= PageTag.icon %></i>
|
||||
<small class="grey-text">tagged</small>
|
||||
<%= page_tag.tag %>
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
changed_fields = AttributeField.where(id: changed_attributes.pluck(:attribute_field_id)).includes([:attribute_category])
|
||||
%>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col s12 m12 l10 offset-l1">
|
||||
<h1 class="flow-text grey-text">
|
||||
@ -72,10 +71,14 @@
|
||||
<span>
|
||||
<%= link_to(change_event.user.display_name, change_event.user, class: "#{User.text_color}") %>
|
||||
</span>
|
||||
<div class="grey-text right"><%= time_ago_in_words change_event.created_at %> ago</div>
|
||||
<div class="grey-text right">
|
||||
<%= time_ago_in_words change_event.created_at %> ago
|
||||
·
|
||||
<%= change_event.created_at.strftime('%B %d, %H:%M %Z') %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 4em">
|
||||
<div style="margin-bottom: 4em" class="black-text">
|
||||
<%=
|
||||
render partial: "content/changelog/field_change/#{related_field.field_type}",
|
||||
locals: { old_value: old_value, new_value: new_value }
|
||||
|
||||
@ -9,8 +9,12 @@
|
||||
on js-load-page-name to fetch & load in post-page-load if the content isn't ours.
|
||||
%>
|
||||
<%
|
||||
content = @current_user_content.fetch(klass, []).detect do |page|
|
||||
page.page_type === klass && page.id === id.to_i
|
||||
content = if user_signed_in?
|
||||
@current_user_content.fetch(klass, []).detect do |page|
|
||||
page.page_type === klass && page.id === id.to_i
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
%>
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
<%= link_to main_app.polymorphic_path(raw_model.class) do %>
|
||||
<i class="material-icons left"><%= raw_model.class.icon %></i>
|
||||
Your <%= raw_model.class.name.downcase.pluralize %>
|
||||
<span class="badge hide-on-med-and-down"><%= @current_user_content[raw_model.class.name].count %></span>
|
||||
<span class="badge hide-on-med-and-down"><%= @current_user_content.fetch(raw_model.class.name, []).count %></span>
|
||||
<% end %>
|
||||
</li>
|
||||
<% else %>
|
||||
|
||||
62
app/views/content/form/_text_input_for_content_page.html.erb
Normal file
62
app/views/content/form/_text_input_for_content_page.html.erb
Normal file
@ -0,0 +1,62 @@
|
||||
<%
|
||||
content_name = content.page_type.downcase
|
||||
|
||||
field_id = "#{content_name}_#{field.name}"
|
||||
value = field.attribute_values.find_by(
|
||||
user: content.user,
|
||||
entity_type: content.page_type,
|
||||
entity_id: content.id
|
||||
).try(:value)
|
||||
|
||||
should_autocomplete = defined?(autocomplete) && !!autocomplete
|
||||
should_autosave = defined?(autosave) && !!autosave
|
||||
%>
|
||||
|
||||
<div class="input-field content-field">
|
||||
<% unless defined?(show_label) && !show_label %>
|
||||
<%= f.label field.id do %>
|
||||
<%= field.label.present? ? field.label : ' ' %>
|
||||
<% if defined?(autocomplete) && autocomplete %>
|
||||
<i class="material-icons grey-text lighten-2 tooltipped" style="font-size: 100%" data-tooltip="This field may suggest some ideas for you when you start typing." data-position="right">
|
||||
offline_bolt
|
||||
</i>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%
|
||||
placeholder = I18n.translate "attributes.#{content_name}.#{field.label.downcase.gsub(/\s/, '_')}",
|
||||
scope: :serendipitous_questions,
|
||||
name: content.name || "this #{content_name}",
|
||||
default: 'Write as little or as much as you want!'
|
||||
%>
|
||||
|
||||
<%= hidden_field_tag "field[name]", field[:id] %>
|
||||
<%=
|
||||
text_area_tag "field[value]",
|
||||
value,
|
||||
class: "js-can-mention-pages materialize-textarea" \
|
||||
+ "#{' autocomplete js-autocomplete-' + field[:id].to_s if should_autocomplete}" \
|
||||
+ "#{' autosave-closest-form-on-change' if should_autosave}",
|
||||
placeholder: placeholder
|
||||
%>
|
||||
</div>
|
||||
|
||||
<% if defined?(autocomplete) && autocomplete %>
|
||||
<%= content_for :javascript do %>
|
||||
$(function() {
|
||||
// This setTimeout is an unfortunate hack to ensure this runs after initializing materialize
|
||||
setTimeout(function() {
|
||||
console.log("Initializing autocomplete for #<%= "#{content_name}_#{field.label}" %>");
|
||||
|
||||
$('.js-autocomplete-<%= field.id.to_s %>').autocomplete({
|
||||
limit: 5,
|
||||
data: {
|
||||
<% autocomplete.each do |autocomplete_option| %>
|
||||
"<%= autocomplete_option %>": null,
|
||||
<% end %>
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
<% end %>
|
||||
<% end %>
|
||||
@ -13,7 +13,7 @@
|
||||
<div class="col s12">
|
||||
<%= render partial: 'cards/serendipitous/content_question', locals: {
|
||||
content: @questioned_content,
|
||||
field: @attribute_field_to_question
|
||||
field: @attribute_field_to_question
|
||||
} %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@ -4,14 +4,7 @@
|
||||
<div class="hoverable card sticky-action" style="margin-bottom: 2px">
|
||||
<div class="card-image waves-effect waves-block waves-light">
|
||||
<%= render partial: 'content/display/favorite_control', locals: { content: content } %>
|
||||
<% content_image = asset_path("card-headers/#{content_type.name.downcase.pluralize}.jpg") %>
|
||||
<% if content.respond_to?(:image_uploads) %>
|
||||
<% images = content.image_uploads %>
|
||||
<% if images.any? %>
|
||||
<% content_image = images.sample.src(:medium) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="activator" style="height: 265px; background: url('<%= content_image %>'); background-size: cover;"></div>
|
||||
<div class="activator" style="height: 265px; background: url('<%= content.random_image_including_private(format: :medium) %>'); background-size: cover;"></div>
|
||||
|
||||
<span class="card-title js-content-name activator">
|
||||
<div class="bordered-text">
|
||||
@ -35,13 +28,13 @@
|
||||
</div>
|
||||
<div class="card-action">
|
||||
<% if current_user.can_update?(content) %>
|
||||
<%= link_to edit_polymorphic_path(content), class: 'green-text right', target: content.is_a?(Document) ? '_new' : '_self' do %>
|
||||
<%= link_to content.edit_path, class: 'green-text right', target: content.is_a?(Document) ? '_new' : '_self' do %>
|
||||
<i class="material-icons left"><%= content_type.icon %></i>
|
||||
Edit
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if current_user.can_read?(content) %>
|
||||
<%= link_to polymorphic_path(content), class: 'blue-text text-lighten-1' do %>
|
||||
<%= link_to content.view_path, class: 'blue-text text-lighten-1' do %>
|
||||
<i class="material-icons left"><%= content_type.icon %></i>
|
||||
View
|
||||
<% end %>
|
||||
|
||||
@ -58,10 +58,10 @@
|
||||
<% content.page_tags.each do |tag| %>
|
||||
<% if user_signed_in? && content.user == current_user %>
|
||||
<%= link_to params.permit(:tag).merge({ tag: PageTagService.slug_for(tag.tag) }) do %>
|
||||
<span class="new badge <%= params[:tag] == tag.slug ? @content_type_class.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
|
||||
<span class="new badge <%= params[:tag] == tag.slug ? content.class.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="new badge <%= params[:tag] == tag.slug ? @content_type_class.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
|
||||
<span class="new badge <%= params[:tag] == tag.slug ? content.class.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@ -2,10 +2,6 @@
|
||||
TODO: everything here (probably) should move to the ContentSerializer so we don't have this
|
||||
heavy logic in a random partial
|
||||
%>
|
||||
<%
|
||||
relations = Rails.application.config.content_relations[content.class.name]
|
||||
relations ||= []
|
||||
%>
|
||||
|
||||
<div id="associations_panel" class="panel">
|
||||
<%= render partial: 'notice_dismissal/messages/07' %>
|
||||
@ -29,37 +25,5 @@
|
||||
locals: { value: link_codes, content: content }
|
||||
%>
|
||||
<% end %>
|
||||
|
||||
<% if user_signed_in? %>
|
||||
<div class="card-panel yellow lighten-5 black-text">
|
||||
Notice: The newest Notebook.ai release is adding the ability to create your own link
|
||||
fields, which includes a rather large migration of all existing link fields into a new linking system.
|
||||
Links that haven't been migrated yet can be seen below this message; links on the new system appear above.
|
||||
<br /><br />
|
||||
Thank you for your patience during this large rewrite! This notice will automatically disappear after the
|
||||
migration has completed for everyone.
|
||||
<br /><br />
|
||||
— Andrew
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# TODO: remove these after finishing link migration script %>
|
||||
<% relations.each do |name, params| %>
|
||||
<%
|
||||
results = params[:related_class].where("#{params[:through_relation].downcase}_id": content.id)
|
||||
.map { |content| content.send(params[:inverse_class].downcase) }
|
||||
.select { |content| content && content.readable_by?(current_user || User.new) }
|
||||
%>
|
||||
<% next unless results.any? %>
|
||||
|
||||
<div class="uppercase grey-text">
|
||||
<%= params[:relation_text].to_s.titleize.downcase %> of
|
||||
</div>
|
||||
<%=
|
||||
link_codes = results.map { |page| "#{page.page_type}-#{page.id}" }
|
||||
render partial: "content/display/attribute_value/link",
|
||||
locals: { value: link_codes, content: content }
|
||||
%>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
186
app/views/data/green.html.erb
Normal file
186
app/views/data/green.html.erb
Normal file
@ -0,0 +1,186 @@
|
||||
<h4 class="white-text">
|
||||
<%= link_to data_vault_path, class: 'grey-text tooltipped', style: 'position: relative; top: 4px;', data: {
|
||||
position: 'bottom',
|
||||
enterDelay: '500',
|
||||
tooltip: "Back to your Data Vault"
|
||||
} do %>
|
||||
<i class="material-icons blue-text text-lighten-3">arrow_back</i>
|
||||
<% end %>
|
||||
Your paper footprint
|
||||
</h4>
|
||||
|
||||
<%
|
||||
total_pages_equivalent = 0
|
||||
%>
|
||||
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<div class="card-panel">
|
||||
All of the added functionality of Notebook.ai over a traditional paper notebook isn't the only benefit of going digital. Backing your ideas up in the cloud
|
||||
instead of on paper also saves trees!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col s12">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="card-title">Your personal paper footprint</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Digital content</th>
|
||||
<th>Equivalent physical pages</th>
|
||||
<th>Equivalent trees</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% @current_user_content.each do |content_type, content_list| %>
|
||||
<%
|
||||
content_list_count = content_list.count
|
||||
|
||||
physical_page_equivalent = case content_type
|
||||
when 'Timeline'
|
||||
GreenService::AVERAGE_TIMELINE_EVENTS_PER_PAGE * TimelineEvent.where(timeline_id: content_list.map(&:id)).count
|
||||
when 'Document'
|
||||
[
|
||||
content_list.inject(0) { |sum, doc| sum + (doc.cached_word_count || 0) } / GreenService::AVERAGE_WORDS_PER_PAGE.to_f,
|
||||
content_list_count
|
||||
].max
|
||||
else
|
||||
GreenService.physical_pages_equivalent_for(content_type) * content_list_count
|
||||
end
|
||||
|
||||
tree_equivalent = physical_page_equivalent.to_f / GreenService::SHEETS_OF_PAPER_PER_TREE
|
||||
|
||||
total_pages_equivalent += physical_page_equivalent
|
||||
%>
|
||||
<tr>
|
||||
<td>
|
||||
<i class="material-icons left <%= content_class_from_name(content_type).color %>-text">
|
||||
<%= content_class_from_name(content_type).icon %>
|
||||
</i>
|
||||
<%= pluralize content_list_count, content_type.pluralize %>
|
||||
</td>
|
||||
<td>
|
||||
<i class="material-icons left grey-text">copy_all</i>
|
||||
<%= number_with_delimiter physical_page_equivalent %>
|
||||
<%= 'page'.pluralize physical_page_equivalent %>
|
||||
</td>
|
||||
<td>
|
||||
<i class="material-icons left green-text">park</i>
|
||||
<%= pluralize tree_equivalent.round(5), 'tree' %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<tr>
|
||||
<td>Totals</td>
|
||||
<td>
|
||||
<strong>
|
||||
<i class="material-icons left grey-text">copy_all</i>
|
||||
<%= number_with_delimiter total_pages_equivalent.round %>
|
||||
<%= 'page'.pluralize total_pages_equivalent %>
|
||||
</strong>
|
||||
</td>
|
||||
<td>
|
||||
<strong>
|
||||
<i class="material-icons left green-text">park</i>
|
||||
<% trees_saved = total_pages_equivalent.to_f / GreenService::SHEETS_OF_PAPER_PER_TREE %>
|
||||
<%= number_with_delimiter trees_saved.round(5) %>
|
||||
<%= 'tree'.pluralize trees_saved %>
|
||||
saved
|
||||
</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<%
|
||||
total_pages_equivalent = 0
|
||||
%>
|
||||
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="card-title">Our community paper footprint</div>
|
||||
<p>Across all Notebook.ai pages...</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Digital content</th>
|
||||
<th>Equivalent physical pages</th>
|
||||
<th>Equivalent trees</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% (Rails.application.config.content_type_names[:all] + ["Timeline", "Document"]).each do |content_type| %>
|
||||
<%
|
||||
content_list_count = content_class_from_name(content_type).last.try(:id) || 0
|
||||
|
||||
physical_page_equivalent = case content_type
|
||||
when 'Timeline'
|
||||
GreenService.total_timeline_pages_equivalent
|
||||
when 'Document'
|
||||
GreenService.total_document_pages_equivalent
|
||||
else
|
||||
GreenService.physical_pages_equivalent_for(content_type) * content_list_count
|
||||
end
|
||||
|
||||
tree_equivalent = physical_page_equivalent.to_f / GreenService::SHEETS_OF_PAPER_PER_TREE
|
||||
|
||||
total_pages_equivalent += physical_page_equivalent
|
||||
%>
|
||||
<tr>
|
||||
<td>
|
||||
<i class="material-icons left <%= content_class_from_name(content_type).color %>-text">
|
||||
<%= content_class_from_name(content_type).icon %>
|
||||
</i>
|
||||
<%= number_with_delimiter content_list_count %>
|
||||
<%= content_type.pluralize content_list_count %>
|
||||
</td>
|
||||
<td>
|
||||
<i class="material-icons left grey-text">copy_all</i>
|
||||
<%= number_with_delimiter physical_page_equivalent %>
|
||||
<%= 'page'.pluralize physical_page_equivalent %>
|
||||
</td>
|
||||
<td>
|
||||
<i class="material-icons left green-text">park</i>
|
||||
<%= number_with_delimiter tree_equivalent.round(5) %>
|
||||
<%= 'tree'.pluralize tree_equivalent %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<tr>
|
||||
<td>Totals</td>
|
||||
<td>
|
||||
<strong>
|
||||
<i class="material-icons left grey-text">copy_all</i>
|
||||
<%= number_with_delimiter total_pages_equivalent %>
|
||||
<%= 'page'.pluralize total_pages_equivalent %>
|
||||
</strong>
|
||||
</td>
|
||||
<td>
|
||||
<strong>
|
||||
<i class="material-icons left green-text">park</i>
|
||||
<% trees_saved = total_pages_equivalent.to_f / GreenService::SHEETS_OF_PAPER_PER_TREE %>
|
||||
<%= number_with_delimiter trees_saved.round(5) %>
|
||||
<%= 'tree'.pluralize trees_saved %>
|
||||
saved
|
||||
</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -69,6 +69,20 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="col s12 m12 l6">
|
||||
<%= link_to tags_path, class: 'black-text' do %>
|
||||
<div class="hoverable card purple lighten-5">
|
||||
<div class="card-content">
|
||||
<i class="material-icons right purple-text text-lighten-4"><%= PageTag.icon %></i>
|
||||
<div class="card-title">Tag management</div>
|
||||
<p>
|
||||
Manage the tags you've used for your worldbuilding.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="col s12 m12 l6">
|
||||
<%= link_to notebook_export_path, class: 'black-text' do %>
|
||||
<div class="hoverable card brown lighten-3">
|
||||
@ -111,6 +125,20 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="col s12 m12 l6">
|
||||
<%= link_to green_path, class: 'black-text' do %>
|
||||
<div class="hoverable card green lighten-2">
|
||||
<div class="card-content">
|
||||
<i class="material-icons right blue-text text-lighten-1">public</i>
|
||||
<div class="card-title">Eco footprint</div>
|
||||
<p>
|
||||
How many trees have you saved by switching to digital?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="col s12 m12 l6">
|
||||
<%= link_to discussions_path, class: 'black-text' do %>
|
||||
<div class="hoverable card blue lighten-4">
|
||||
@ -182,7 +210,7 @@
|
||||
</div>
|
||||
|
||||
<%# this could actually be a cool s12 banner if we flesh out the help center with guides and stuff %>
|
||||
<div class="col s12 m12 l12">
|
||||
<div class="col s12 m12 l6">
|
||||
<%= link_to help_center_path, class: 'black-text' do %>
|
||||
<div class="hoverable card pink lighten-4">
|
||||
<div class="card-content">
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
<div class="col s12">
|
||||
<div class="card-panel">
|
||||
<p class="card-panel-title">
|
||||
<i class="left material-icons orange-text">cake</i>
|
||||
<i class="left material-icons large orange-text">cake</i>
|
||||
You signed up to Notebook.ai on <%= current_user.created_at.strftime("%B %d of this year, a very fine %A") %>!
|
||||
</p>
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
<div class="col s12 m5">
|
||||
<div class="card-panel">
|
||||
<div class="card-panel-title">
|
||||
<i class="material-icons left <%= @earliest_page.class.text_color %>">
|
||||
<i class="material-icons left large <%= @earliest_page.class.text_color %>">
|
||||
<%= @earliest_page.class.icon %>
|
||||
</i>
|
||||
It all started with <%= @earliest_page.name %>...
|
||||
@ -45,9 +45,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col s12 m7">
|
||||
<div class="card horizontal">
|
||||
<div class="hoverable card horizontal">
|
||||
<div class="card-image">
|
||||
<%= image_tag @earliest_page.random_image_including_private %>
|
||||
<%= link_to @earliest_page do %>
|
||||
<%= image_tag @earliest_page.random_image_including_private %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="card-stacked">
|
||||
<div class="card-content">
|
||||
@ -75,8 +77,8 @@
|
||||
<div class="col s12">
|
||||
<%= render partial: 'content/components/parallax_header', locals: { content_type: 'Universe', content_class: Universe, image_only: true } %>
|
||||
<div class="card-panel">
|
||||
<i class="left material-icons large <%= Universe.text_color %>"><%= Universe.icon %></i>
|
||||
<p class="card-panel-title">
|
||||
<i class="left material-icons <%= Universe.text_color %>"><%= Universe.icon %></i>
|
||||
You started creating <%= pluralize @created_content['Universe'].count, 'new universe' %> in <%= @year %>!
|
||||
</p>
|
||||
|
||||
@ -103,8 +105,8 @@
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<div class="card-panel">
|
||||
<i class="material-icons left large blue-text">dashboard</i>
|
||||
<div class="card-panel-title">
|
||||
<i class="material-icons left blue-text">dashboard</i>
|
||||
In your worlds, you created
|
||||
<%= pluralize @total_created_non_universe_content, 'notebook pages' %>
|
||||
this year!
|
||||
@ -138,7 +140,10 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
You also uploaded <%= pluralize @created_content['ImageUpload'].count, 'image' %> to your notebook pages this year!
|
||||
<p class="right">
|
||||
<i class="material-icons left">upload</i>
|
||||
You also uploaded <%= pluralize @created_content['ImageUpload'].count, 'image' %> to your notebook pages this year!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -153,8 +158,8 @@
|
||||
<%= render partial: 'content/components/parallax_header', locals: { content_type: 'Document', content_class: Document, image_only: true } %>
|
||||
|
||||
<div class="card-panel">
|
||||
<i class="material-icons left large <%= Document.text_color %>"><%= Document.icon %></i>
|
||||
<div class="card-panel-title">
|
||||
<i class="material-icons left <%= Document.text_color %>"><%= Document.icon %></i>
|
||||
You started writing <%= pluralize @created_content['Document'].count, 'document' %> in <%= @year %>!
|
||||
</div>
|
||||
<p>
|
||||
@ -164,11 +169,11 @@
|
||||
|
||||
<% @created_content['Document'].each do |document| %>
|
||||
<div class="col s12 m4 l3">
|
||||
<%= link_to edit_document_path(document) do %>
|
||||
<div class="hoverable card-panel fixed-card-content <%= Document.color %> white-text">
|
||||
<i class="material-icons left"><%= Document.icon %></i>
|
||||
<%= document.title %>
|
||||
</div>
|
||||
<%= link_to document_path(document) do %>
|
||||
<div class="hoverable card-panel fixed-card-content <%= Document.color %> white-text">
|
||||
<i class="material-icons left"><%= Document.icon %></i>
|
||||
<%= document.title %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@ -273,14 +278,14 @@
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<div class="card-panel">
|
||||
<i class="material-icons left large blue-text">date_range</i>
|
||||
<div class="card-panel-title">
|
||||
<i class="material-icons left blue-text">date_range</i>
|
||||
<%= @year %> was a productive year for you!
|
||||
</div>
|
||||
<p>
|
||||
Thanks for spending it on Notebook.ai. I can't wait to see what you accomplish in <%= @year + 1 %>! :)
|
||||
</p>
|
||||
<p>
|
||||
<p style="text-align: right">
|
||||
— Andrew
|
||||
</p>
|
||||
</div>
|
||||
|
||||
82
app/views/data/tags.html.erb
Normal file
82
app/views/data/tags.html.erb
Normal file
@ -0,0 +1,82 @@
|
||||
<%
|
||||
showed_any_tags = false
|
||||
%>
|
||||
|
||||
<h2 class="grey-text" style="font-size: 2rem">Your Notebook.ai tags</h2>
|
||||
<ul class="collapsible">
|
||||
<% Rails.application.config.content_types[:all].each do |content_type| %>
|
||||
<%
|
||||
grouped_tags = PageTag.where(page_type: content_type.name, user_id: current_user).order('tag ASC').group_by(&:tag)
|
||||
|
||||
next if grouped_tags.values.length == 0
|
||||
|
||||
showed_any_tags = true
|
||||
%>
|
||||
|
||||
<li>
|
||||
<div class="collapsible-header">
|
||||
<i class="material-icons <%= content_type.text_color %>"><%= content_type.icon %></i>
|
||||
<%= content_type.name %> tags
|
||||
<span class="badge"><%= grouped_tags.values.length %></span>
|
||||
</div>
|
||||
<div class="collapsible-body">
|
||||
<% grouped_tags.each do |tag, page_list| %>
|
||||
<div class="row" style="border-bottom: 1px solid #ccc; padding-bottom: 2em; margin-bottom: 2em">
|
||||
<div class="col s12 m6 l4">
|
||||
<div>
|
||||
<%=
|
||||
link_to send(
|
||||
"#{content_type.name.downcase.pluralize}_path",
|
||||
slug: PageTagService.slug_for(tag)
|
||||
) do
|
||||
%>
|
||||
<span class="<%= content_type.color %> white-text" style="padding: 0.3em 0.4em; font-size: 1em">
|
||||
<%= tag %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="grey-text" style="padding-top: 1em">
|
||||
Used by <%= pluralize page_list.length, 'page' %>
|
||||
</div>
|
||||
<div>
|
||||
<%=
|
||||
link_to 'Delete this tag', tag_remove_path(
|
||||
page_type: content_type.name,
|
||||
slug: PageTagService.slug_for(tag)
|
||||
), data: {
|
||||
confirm: "Are you sure? This will delete this tag and remove it from all pages."
|
||||
}, class: 'red-text'
|
||||
%>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col s12 m6 l8">
|
||||
<% page_list.each do |page_tag| %>
|
||||
<div class="chip js-load-page-name" data-klass="<%= page_tag.page_type %>" data-id="<%= page_tag.page_id %>">
|
||||
<%= link_to send("#{page_tag.page_type.downcase}_path", page_tag.page_id) do %>
|
||||
<span class="<%= content_type.text_color %>">
|
||||
<i class="material-icons left">
|
||||
<%= content_type.icon %>
|
||||
</i>
|
||||
</span>
|
||||
<span class="name-container">
|
||||
<em>Loading <%= content_type.name %> name...</em>
|
||||
</span>
|
||||
<% end %>
|
||||
<%= link_to destroy_specific_tag_path(page_tag), method: :delete, class: 'tooltipped', data: { tooltip: 'Remove this tag from this page' }, remote: true do %>
|
||||
<i class="close material-icons">close</i>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<% if !showed_any_tags %>
|
||||
<div class="card-panel">
|
||||
When you create tags for your pages, they'll appear here. Come back later when you've added some!
|
||||
</div>
|
||||
<% end %>
|
||||
@ -81,4 +81,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
<% if @universe_scope %>
|
||||
<p class="center help-text">
|
||||
<p class="center help-text teal card-panel lighten-5 black-text">
|
||||
Only showing documents
|
||||
in the <%= link_to @universe_scope.name, @universe_scope, class: Universe.color + '-text' %> universe.
|
||||
in the <%= link_to @universe_scope.name, @universe_scope, class: Universe.text_color %> universe.
|
||||
<%= link_to(
|
||||
"See documents from all universes.",
|
||||
"See documents from all universes instead.",
|
||||
'?universe=all',
|
||||
class: Universe.color + '-text')
|
||||
class: Universe.text_color)
|
||||
%>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
@ -42,10 +42,13 @@
|
||||
<div class="col s12 m4 l4">
|
||||
<div class="card">
|
||||
<div class="card-content spaced-paragraphs">
|
||||
<h2 class="card-title">
|
||||
<i class="material-icons green-text left">searchable</i>
|
||||
Search your <%= pluralized_class_name.downcase %>
|
||||
</h2>
|
||||
<div class="center">
|
||||
<i class="material-icons green-text large">searchable</i>
|
||||
<h2 class="card-title">
|
||||
Search your <%= pluralized_class_name.downcase %>
|
||||
</h2>
|
||||
</div>
|
||||
<br />
|
||||
<p>
|
||||
If you've got a lot of <%= pluralized_class_name.downcase %>, you can quickly get around to the right
|
||||
one with Notebook.ai's full-text search. It searches your entire notebook for anything you wrote about
|
||||
@ -65,11 +68,13 @@
|
||||
<div class="col s12 m4 l4">
|
||||
<div class="card">
|
||||
<div class="card-content spaced-paragraphs">
|
||||
<h2 class="card-title">
|
||||
<i class="material-icons left orange-text">lock</i>
|
||||
Private by default
|
||||
</h2>
|
||||
|
||||
<div class="center">
|
||||
<i class="material-icons orange-text large">lock</i>
|
||||
<h2 class="card-title">
|
||||
Private by default
|
||||
</h2>
|
||||
</div>
|
||||
<br />
|
||||
<p>
|
||||
Your ideas are valuable. Every <%= singular_class_name.downcase %> you create in Notebook.ai is owned by you, completely private by default, and only accessible to you.
|
||||
</p>
|
||||
@ -83,10 +88,13 @@
|
||||
<div class="col s12 m4 l4">
|
||||
<div class="card">
|
||||
<div class="card-content spaced-paragraphs">
|
||||
<h2 class="card-title">
|
||||
<i class="material-icons left blue-text">star</i>
|
||||
<%= (premium_page) ? 'Premium page' : 'Free page' %>
|
||||
</h2>
|
||||
<div class="center">
|
||||
<i class="material-icons blue-text large">star</i>
|
||||
<h2 class="card-title">
|
||||
<%= (premium_page) ? 'Premium page' : 'Free page' %>
|
||||
</h2>
|
||||
</div>
|
||||
<br />
|
||||
<% if premium_page %>
|
||||
<p>
|
||||
<%= singular_class_name %> notebook pages require an active Premium subscription to create, but users on free plans can
|
||||
@ -116,92 +124,100 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col s12">
|
||||
<div class="col s12"> <!-- spacer --></div>
|
||||
|
||||
<div class="col s12 m12 l5">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<h2 class="card-title">
|
||||
<i class="material-icons <%= @content_type.text_color %> left">
|
||||
<%= @content_type.icon %>
|
||||
</i>
|
||||
|
||||
Get a head start with a rich
|
||||
<span class="<%= @content_type.text_color %>"><%= singular_class_name.downcase %></span>
|
||||
template
|
||||
</h2>
|
||||
<br />
|
||||
|
||||
<div class="row">
|
||||
<div class="col s12 m12 l5">
|
||||
<div class="spaced-paragraphs">
|
||||
<p>
|
||||
Templates on Notebook.ai are what help our unique worldbuilding system better understand your world.
|
||||
</p>
|
||||
<p>
|
||||
You can fill out as little or as much as you'd like on every new <%= singular_class_name.downcase %>. You'll see
|
||||
progress indicators every time you edit it to show where you can make progress on, and our system will
|
||||
intelligently generate questions for you around the site that will automatically save your answers
|
||||
to the proper place on your <%= singular_class_name.downcase %> page.
|
||||
</p>
|
||||
<p>
|
||||
Templates are also fully customizable across every <%= singular_class_name.downcase %> in your notebook.
|
||||
</p>
|
||||
<p>
|
||||
You can browse the default template for <%= pluralized_class_name.downcase %> here; click any category
|
||||
to see its questions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col s12 m12 l7">
|
||||
<ul class="collapsible popout">
|
||||
<%
|
||||
YAML.load_file(Rails.root.join('config', 'attributes', "#{singular_class_name.downcase}.yml")).map do |category_name, category_details|
|
||||
%>
|
||||
<% next if category_name == :contributors %>
|
||||
<li>
|
||||
<div class="collapsible-header <%= @content_type.color %> darken-3 white-text">
|
||||
<i class="material-icons"><%= category_details[:icon] %></i>
|
||||
<%= category_details[:label] %>
|
||||
</div>
|
||||
<div class="collapsible-body">
|
||||
<% if category_name == :gallery %>
|
||||
<p>
|
||||
This category lets you upload images to this <%= singular_class_name.downcase %>'s notebook page.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
It's great if you have sketches or artwork for your <%= singular_class_name.downcase %>,
|
||||
but also works well for collecting visual inspiration, too!
|
||||
</p>
|
||||
<% end %>
|
||||
<% category_details.fetch(:attributes, []).each do |field| %>
|
||||
<p>
|
||||
<strong><%= field[:label] %></strong>
|
||||
</p>
|
||||
<p>
|
||||
<% if field[:field_type] == 'link' || field[:field_type] == 'universe' %>
|
||||
This field allows you to link your other Notebook.ai pages to this <%= singular_class_name.downcase %>.
|
||||
<% elsif field[:field_type] == 'tags' %>
|
||||
This field lets you add clickable tags to your <%= pluralized_class_name.downcase %>.
|
||||
<% else %>
|
||||
<%=
|
||||
I18n.translate "attributes.#{singular_class_name.downcase}.#{field[:label].downcase.gsub(/\s/, '_')}",
|
||||
scope: :serendipitous_questions,
|
||||
name: "this #{singular_class_name.downcase}",
|
||||
default: 'Write as little or as much as you want!'
|
||||
%>
|
||||
<br />
|
||||
<span class="grey-text"><%= field[:description].try(:html_safe) %></span>
|
||||
<% end %>
|
||||
</p>
|
||||
<br />
|
||||
<% end %>
|
||||
</div>
|
||||
</li>
|
||||
<%
|
||||
end
|
||||
%>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="spaced-paragraphs">
|
||||
<p>
|
||||
Templates on Notebook.ai are what help our unique worldbuilding system better understand your world.
|
||||
</p>
|
||||
<p>
|
||||
You can fill out as little or as much as you'd like on every new <%= singular_class_name.downcase %>. You'll see
|
||||
progress indicators every time you edit it to show where you can make progress on, and our system will
|
||||
intelligently generate questions for you around the site that will automatically save your answers
|
||||
to the proper place on your <%= singular_class_name.downcase %> page.
|
||||
</p>
|
||||
<p>
|
||||
Templates are also fully customizable across every <%= singular_class_name.downcase %> in your notebook.
|
||||
</p>
|
||||
<p>
|
||||
You can browse the default template for <%= pluralized_class_name.downcase %> here; click any category
|
||||
to see its questions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col s12 m12 l7">
|
||||
<ul class="collapsible popout">
|
||||
<%
|
||||
YAML.load_file(Rails.root.join('config', 'attributes', "#{singular_class_name.downcase}.yml")).map do |category_name, category_details|
|
||||
%>
|
||||
<% next if category_name == :contributors %>
|
||||
<li>
|
||||
<div class="collapsible-header <%= @content_type.color %> darken-3 white-text">
|
||||
<i class="material-icons"><%= category_details[:icon] %></i>
|
||||
<%= category_details[:label] %>
|
||||
</div>
|
||||
<div class="collapsible-body">
|
||||
<% if category_name == :gallery %>
|
||||
<p>
|
||||
This category lets you upload images to this <%= singular_class_name.downcase %>'s notebook page.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
It's great if you have sketches or artwork for your <%= singular_class_name.downcase %>,
|
||||
but also works well for collecting visual inspiration, too!
|
||||
</p>
|
||||
<% end %>
|
||||
<% category_details.fetch(:attributes, []).each do |field| %>
|
||||
<div>
|
||||
<strong><%= field[:label] %></strong>
|
||||
</div>
|
||||
<div>
|
||||
<% if field[:field_type] == 'link' || field[:field_type] == 'universe' %>
|
||||
This field allows you to link your other Notebook.ai pages to this <%= singular_class_name.downcase %>.
|
||||
<% elsif field[:field_type] == 'tags' %>
|
||||
This field lets you add clickable tags to your <%= pluralized_class_name.downcase %>.
|
||||
<% else %>
|
||||
<%=
|
||||
I18n.translate "attributes.#{singular_class_name.downcase}.#{field[:label].downcase.gsub(/\s/, '_')}",
|
||||
scope: :serendipitous_questions,
|
||||
name: "this #{singular_class_name.downcase}",
|
||||
default: 'Write as little or as much as you want!'
|
||||
%>
|
||||
<br />
|
||||
<span class="grey-text"><%= field[:description].try(:html_safe) %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<br />
|
||||
<% end %>
|
||||
</div>
|
||||
</li>
|
||||
<%
|
||||
end
|
||||
%>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="col s12"> <!-- spacer --></div>
|
||||
|
||||
<div class='col s12 grey-text uppercase center'><%= singular_class_name %> features</div>
|
||||
|
||||
<div class="col s12 m12 l6">
|
||||
<div class="card">
|
||||
<div class="card-content spaced-paragraphs">
|
||||
@ -209,12 +225,13 @@
|
||||
<i class="material-icons left red-text">photo_library</i>
|
||||
Upload images to your <%= singular_class_name.downcase %>
|
||||
</h2>
|
||||
<br />
|
||||
<p>
|
||||
<%= pluralized_class_name %> and other worldbuilding pages all come with a dedicated area to upload and showcase your own uploaded images.
|
||||
</p>
|
||||
<p>
|
||||
All users start out with 50MB of image storage space available for their notebook (usually around 250-500 images), but
|
||||
Premium users receive an extra 10GB (10,000MB) of storage space for plenty of wiggle room when decking your <%= singular_class_name.downcase %>
|
||||
Premium users receive an extra 10GB (10,000MB) of storage space for plenty of wiggle room when decking your <%= pluralized_class_name.downcase %>
|
||||
out with all kinds of images.
|
||||
</p>
|
||||
<p>
|
||||
@ -232,6 +249,7 @@
|
||||
<i class="material-icons left <%= Universe.text_color %>">vpn_lock</i>
|
||||
Focus on <%= pluralized_class_name.downcase %> from a single universe
|
||||
</h2>
|
||||
<br />
|
||||
<p>
|
||||
Building entire fictional worlds is hard, and it gets even harder when you've got ideas spanning multiple universes or worlds.
|
||||
</p>
|
||||
@ -254,6 +272,7 @@
|
||||
<i class="material-icons left green-text">group_add</i>
|
||||
Invite others to collaborate
|
||||
</h2>
|
||||
<br />
|
||||
<p>
|
||||
Notebook.ai lets you add an unlimited number of collaborators to your universes. Each one has full access to work alongside you
|
||||
on your <%= pluralized_class_name.downcase %> and other pages within that universe.
|
||||
@ -272,6 +291,7 @@
|
||||
<i class="material-icons left white-text">brightness_4</i>
|
||||
Work in light or dark mode
|
||||
</h2>
|
||||
<br />
|
||||
<p>
|
||||
Protect your eyes at night by choosing between light and dark themes at the click of a button, available across the entire website.
|
||||
</p>
|
||||
@ -292,6 +312,7 @@
|
||||
<i class="material-icons left <%= @content_type.text_color %>"><%= @content_type.icon %></i>
|
||||
Build your <%= singular_class_name.downcase %> piece by piece with personalized writing prompts
|
||||
</h2>
|
||||
<br />
|
||||
<p>
|
||||
Once you've started creating a <%= singular_class_name.downcase %> or two, you'll start noticing our worldbuilding tool asking you
|
||||
personalized questions about those <%= pluralized_class_name.downcase %> around the site.
|
||||
@ -310,7 +331,14 @@
|
||||
<br />
|
||||
Our users have created
|
||||
<div class="h1-size <%= @content_type.text_color %>" style="margin: 0"><%= number_with_delimiter @content_type.last.try(:id) || 0 %></div>
|
||||
<%= pluralized_class_name.downcase %> on Notebook.ai!
|
||||
<%= pluralized_class_name.downcase %> on Notebook.ai
|
||||
<div>
|
||||
<%= link_to green_paper_path, class: 'green-text' do %>
|
||||
and saved
|
||||
<%= number_with_delimiter GreenService.trees_saved_by(singular_class_name).round(2) %>
|
||||
trees!
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col s12 m12 l4">
|
||||
@ -342,22 +370,21 @@
|
||||
<i class="material-icons <%= Document.text_color %> left"><%= Document.icon %></i>
|
||||
Smoothly transition from worldbuilding to storytelling
|
||||
</h2>
|
||||
<br />
|
||||
<div class="row">
|
||||
<div class="col s12 m12 l6">
|
||||
<div class="spaced-paragraphs">
|
||||
<p>
|
||||
Every account on Notebook.ai comes with unlimited document storage for you to bring your fictional worlds to life.
|
||||
Every account on Notebook.ai comes with unlimited document storage and an integrated word processor for you to bring your fictional worlds to life.
|
||||
<!-- todo link to document info page -->
|
||||
</p>
|
||||
<p>
|
||||
There are a variety of writing tools for you to use, building on top of the foundation you lay from a world of <%= pluralized_class_name.downcase %>
|
||||
Other integrated tools allow you to build upon the foundation you lay from a world of <%= pluralized_class_name.downcase %>
|
||||
and other notebook pages. Each one offers new ways to enrich your world and write better stories within them.
|
||||
</p>
|
||||
<p>
|
||||
Some ways you can use the writing tools in Notebook.ai with your worlds are listed here:
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col s12 m12 l5 offset-l1">
|
||||
<div class="col s12 m12 l6">
|
||||
<ul>
|
||||
<li class="clearfix" style="padding-bottom: 1em">
|
||||
<i class="material-icons <%= Timeline.text_color %> left"><%= Timeline.icon %></i>
|
||||
@ -391,6 +418,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col s12"> <!-- spacer --></div>
|
||||
|
||||
<div class="col s12 center spaced-paragraphs">
|
||||
<p>
|
||||
<%= pluralized_class_name %> are just one of the <%= Rails.application.config.content_types[:all].count %> types of worldbuilding pages available
|
||||
|
||||
@ -92,7 +92,7 @@
|
||||
</a>
|
||||
<div class="collapsible-body" style="">
|
||||
<ul>
|
||||
<% @current_user_content['Document'].each do |document| %>
|
||||
<% @current_user_content.fetch('Document', []).each do |document| %>
|
||||
<li>
|
||||
<%= link_to edit_document_path(document), class: 'waves-effect tooltipped', data: { tooltip: "Last edited #{time_ago_in_words document.updated_at} ago", position: 'right' } do %>
|
||||
<i class="material-icons <%= Document.text_color %>">
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
</a>
|
||||
<div class="collapsible-body blue lighten-1">
|
||||
<ul>
|
||||
<% ((@current_user_content['Universe'] || []) + current_user.contributable_universes).sort_by(&:name).each do |universe| %>
|
||||
<% @current_user_content.fetch('Universe', []).sort_by(&:name).each do |universe| %>
|
||||
<li>
|
||||
<%= link_to "?universe=#{universe.id}", class: 'waves-effect' do %>
|
||||
<i class="material-icons <%= Universe.text_color %>">
|
||||
@ -70,7 +70,7 @@
|
||||
@universe_scope.send(pluralized_name).count
|
||||
else
|
||||
(
|
||||
@current_user_content[content_type] || [] +
|
||||
@current_user_content.fetch(content_type, []) +
|
||||
current_user.send("contributable_#{pluralized_name}") +
|
||||
(content_type_klass == Universe ? [] : content_type_klass.where(universe_id: current_user.universes.pluck(:id)))
|
||||
).uniq.count
|
||||
|
||||
@ -3,15 +3,11 @@
|
||||
<div class="hoverable card <%= Document.color %>" style="height: 88px;">
|
||||
<div class="card-content white-text">
|
||||
<span class="card-title">
|
||||
<span class="right white-text"><%= @current_user_content['Document'].count %></span>
|
||||
<span class="right white-text"><%= @current_user_content.fetch('Document', []).count %></span>
|
||||
<i class="material-icons left"><%= Document.icon %></i>
|
||||
<span class="hide-on-large-only hide-on-small-only">Docs</span>
|
||||
<span class="hide-on-med-and-down show-on-small">Documents</span>
|
||||
</span>
|
||||
|
||||
<div style="margin: 0" class="center">
|
||||
<strong>New</strong>: Folders, stats, and more</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@ -72,14 +72,16 @@
|
||||
your worlds
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @content %>
|
||||
<%= render partial: 'cards/serendipitous/content_question', locals: {
|
||||
content: @content,
|
||||
field: @attribute_field_to_question,
|
||||
expand_by_default: true,
|
||||
include_quick_reference: false
|
||||
} %>
|
||||
<% end %>
|
||||
<%=
|
||||
if @content
|
||||
render partial: 'cards/serendipitous/content_question', locals: {
|
||||
content: @content,
|
||||
field: @attribute_field_to_question,
|
||||
expand_by_default: true,
|
||||
include_quick_reference: false
|
||||
}
|
||||
end
|
||||
%>
|
||||
|
||||
<%= link_to prompts_path do %>
|
||||
<div class="hoverable card-panel orange white-text" style="margin: 0; margin-bottom: 2em">
|
||||
@ -125,7 +127,7 @@
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%
|
||||
accessible_universes = ((@current_user_content['Universe'] || []) + current_user.contributable_universes)
|
||||
accessible_universes = @current_user_content.fetch('Universe', []) + current_user.contributable_universes
|
||||
if accessible_universes.count > 1
|
||||
%>
|
||||
<div class="grey-text uppercase center">
|
||||
@ -184,8 +186,10 @@
|
||||
<% end %>
|
||||
|
||||
<div class="grey-text uppercase center">Create something new</div>
|
||||
<% shown_creatures = false %>
|
||||
<% @activated_content_types.sample(3).each do |type| %>
|
||||
<% klass = content_class_from_name(type) %>
|
||||
<% shown_creatures = true if type == 'Creature' %>
|
||||
<div>
|
||||
<%= link_to send("new_#{type.downcase}_path"), class: "white-text", style: 'width: 100%' do %>
|
||||
<div class="hoverable card-panel <%= klass.color %>" style="margin-bottom: 4px">
|
||||
@ -196,7 +200,7 @@
|
||||
<%= type %><br />
|
||||
<small>
|
||||
You've created
|
||||
<%= pluralize (@current_user_content[type] || []).count, type.downcase %>
|
||||
<%= pluralize @current_user_content.fetch(type, []).count, type.downcase %>
|
||||
<% if @universe_scope %>
|
||||
in this universe
|
||||
<% end %>
|
||||
@ -207,6 +211,24 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<br />
|
||||
<div>
|
||||
<%= link_to new_creature_path, class: "white-text", style: 'width: 100%' do %>
|
||||
<div class="hoverable card-panel <%= Creature.color %>" style="margin-bottom: 4px">
|
||||
<div class="valign-wrapper">
|
||||
<i class="material-icons grey-bordered-text" class="left" style="font-size: 3em;"><%= Creature.icon %></i>
|
||||
<span style="font-size: 1.1em; margin-left: 1em">
|
||||
New Creature<br />
|
||||
<small>
|
||||
Free for all users in October
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
140
app/views/main/paper.html.erb
Normal file
140
app/views/main/paper.html.erb
Normal file
@ -0,0 +1,140 @@
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<%=
|
||||
image_tag 'tristan/small.png', class: 'right hide-on-small-only', style: 'margin-left: 2em'
|
||||
%>
|
||||
|
||||
<div class="card-title green-text text-darken-2">Going digital saves paper and trees!</div>
|
||||
<br />
|
||||
<p>
|
||||
It's well-known that Notebook.ai offers a wide variety of features that just aren't
|
||||
possible with traditional paper notebooks. However, those aren't the only benefits of
|
||||
going digital!
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
Whether you're worldbuilding with our specialized notebook pages, outlining story
|
||||
timelines, or writing your next novel, every page you create in Notebook.ai directly
|
||||
contributes to less paper being used — and fewer trees cut down!
|
||||
</p>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col s12 m12 l4">
|
||||
<div class="card-panel center green white-text">
|
||||
<h1 style="margin: 0"><%= number_with_delimiter @total_notebook_pages %></h1>
|
||||
digital ideas stored
|
||||
</div>
|
||||
</div>
|
||||
<div class="col s12 m12 l4">
|
||||
<div class="card-panel center green white-text">
|
||||
<h1 style="margin: 0"><%= number_with_delimiter @total_pages_equivalent %></h1>
|
||||
equivalent physical pages
|
||||
</div>
|
||||
</div>
|
||||
<div class="col s12 m12 l4">
|
||||
<div class="card-panel center green darken-1 white-text">
|
||||
<i class="material-icons left large">park</i>
|
||||
<h1 style="margin: 0"><strong><%= number_with_delimiter @total_trees_saved.round %></strong></h1>
|
||||
trees saved
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col s12 m12 l9 offset-l3">
|
||||
<div class="card-panelf">
|
||||
<p class="grey-text text-darken-1">
|
||||
<i class="material-icons left green-text">park</i>
|
||||
The average 40-foot pine tree typically yields between 10,000 and 20,000 notebook-quality sheets of paper.
|
||||
For the estimations on this page, we're using a conservative estimate of
|
||||
<%= number_with_delimiter GreenService::SHEETS_OF_PAPER_PER_TREE %> pages per tree.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="card-title">Our community paper footprint</div>
|
||||
<!-- good place for a little bit of copy -->
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Digital content</th>
|
||||
<th>Equivalent physical pages</th>
|
||||
<th>Equivalent trees</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% (Rails.application.config.content_type_names[:all] + ["Timeline", "Document"]).each do |content_type| %>
|
||||
<%
|
||||
content_list_count = @per_page_savings.dig(content_type, :digital)
|
||||
physical_page_equivalent = @per_page_savings.dig(content_type, :pages)
|
||||
tree_equivalent = @per_page_savings.dig(content_type, :trees)
|
||||
%>
|
||||
<tr>
|
||||
<td>
|
||||
<i class="material-icons left <%= content_class_from_name(content_type).color %>-text">
|
||||
<%= content_class_from_name(content_type).icon %>
|
||||
</i>
|
||||
<% if ["Timeline", "Document"].include?(content_type) %>
|
||||
<%= number_with_delimiter content_list_count %>
|
||||
<%= content_type.pluralize content_list_count %>
|
||||
<% else %>
|
||||
<%= link_to send("#{content_type.downcase}_worldbuilding_info_path"), class: 'black-text' do %>
|
||||
<%= number_with_delimiter content_list_count %>
|
||||
<%= content_type.pluralize content_list_count %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<i class="material-icons left grey-text">copy_all</i>
|
||||
<% if content_type == 'Timeline' %>
|
||||
<% tooltip = "This estimate is based on the average timeline having approximately #{GreenService::AVERAGE_TIMELINE_EVENTS_PER_PAGE} detailed events per page when printed." %>
|
||||
<% elsif content_type == 'Document' %>
|
||||
<% tooltip = "This estimate is based on the total word count across all documents on Notebook.ai." %>
|
||||
<% else %>
|
||||
<% tooltip = "This estimate is based on the average #{content_type} page being approximately #{GreenService.physical_pages_equivalent_for(content_type)} physical pages long when printed." %>
|
||||
<% end %>
|
||||
<span class="tooltipped" data-tooltip="<%= tooltip %>" data-position="bottom">
|
||||
<%= number_with_delimiter physical_page_equivalent %>
|
||||
<%= 'page'.pluralize physical_page_equivalent %>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<i class="material-icons left green-text">park</i>
|
||||
<%= number_with_delimiter tree_equivalent.round(5) %>
|
||||
<%= 'tree'.pluralize tree_equivalent %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<tr>
|
||||
<td>Totals</td>
|
||||
<td>
|
||||
<strong>
|
||||
<i class="material-icons left grey-text">copy_all</i>
|
||||
<%= number_with_delimiter @total_pages_equivalent %>
|
||||
<%= 'page'.pluralize @total_pages_equivalent %>
|
||||
</strong>
|
||||
</td>
|
||||
<td>
|
||||
<strong>
|
||||
<i class="material-icons left green-text">park</i>
|
||||
<%= number_with_delimiter @total_trees_saved.round(5) %>
|
||||
<%= 'tree'.pluralize @total_trees_saved %>
|
||||
saved
|
||||
</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -12,7 +12,8 @@
|
||||
redirect_path: prompts_path,
|
||||
show_empty_prompt: true,
|
||||
expand_by_default: true,
|
||||
show_view_button: false
|
||||
show_view_button: false,
|
||||
include_quick_reference: false
|
||||
} %>
|
||||
|
||||
<p class="grey-text text-lighten-1 center">
|
||||
@ -23,8 +24,8 @@
|
||||
|
||||
<% if @attribute_field_to_question.present? %>
|
||||
<div class="col m12 l4">
|
||||
<%= link_to @content, class: 'entity-trigger sidenav-trigger', data: { target: "quick-reference-#{@content.class.name}-#{@content.id}"} do %>
|
||||
<div class="hoverable card <%= @content.class.color %>">
|
||||
<%= link_to @content.view_path, class: 'entity-trigger sidenav-trigger', data: { target: "quick-reference-#{@content.page_type}-#{@content.id}"} do %>
|
||||
<div class="hoverable card <%= @content.color %>">
|
||||
<div class="card-content white-text">
|
||||
<i class="material-icons right">vertical_split</i>
|
||||
Quick-reference <%= @content.name %>
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
<%# TODO: put this in more of a timeline design %>
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<br />
|
||||
<div class="grey-text uppercase center">Your recent worldbuilding activity</div>
|
||||
</div>
|
||||
<% @recently_edited_pages.each do |page| %>
|
||||
<% action = page.created_at === page.updated_at ? 'Created' : 'Updated' %>
|
||||
<% klass = page.is_a?(ContentPage) ? content_class_from_name(page.page_type) : page.class %>
|
||||
|
||||
@ -1,16 +1,25 @@
|
||||
<%# todo extract "sidebar" and call it with @content, then also do the same in documents/components/smart_sidebar %>
|
||||
<% serialized_entity = ContentSerializer.new(content) %>
|
||||
<%#
|
||||
This sidebar partial uses instantiated ContentPage models instead of the persisted content models (Character, Location, etc)
|
||||
|
||||
<ul id="quick-reference-<%= content.class.name %>-<%= content.id %>" class="sidenav quick-reference-sidenav">
|
||||
TODO: merge this with documents/components/smart_sidebar
|
||||
%>
|
||||
|
||||
<%# todo extract "sidebar" and call it with @content, then also do the same in documents/components/smart_sidebar %>
|
||||
<%
|
||||
raw_model = content.page_type.constantize.find_by(id: content.id, user: current_user)
|
||||
serialized_entity = ContentSerializer.new(raw_model)
|
||||
%>
|
||||
|
||||
<ul id="quick-reference-<%= content.page_type %>-<%= content.id %>" class="sidenav quick-reference-sidenav">
|
||||
<li>
|
||||
<div class="user-view">
|
||||
<div class="background">
|
||||
<%= image_tag "card-headers/#{content.class.name.downcase.pluralize}.jpg", width: '100%' %>
|
||||
<%= image_tag "card-headers/#{content.page_type.downcase.pluralize}.jpg", width: '100%' %>
|
||||
</div>
|
||||
<a href="#name">
|
||||
<h1 class="white-text name bordered-text">
|
||||
<i class="material-icons <%= content.class.text_color %> left">
|
||||
<%= content.class.icon %>
|
||||
<i class="material-icons <%= content.text_color %> left">
|
||||
<%= content.icon %>
|
||||
</i>
|
||||
<%= content.name %>
|
||||
</h1>
|
||||
@ -83,15 +92,15 @@
|
||||
<li><a href="#" class="subheader">Actions</a></li>
|
||||
|
||||
<li>
|
||||
<%= link_to polymorphic_path(content), class: "blue-text", target: '_new' do %>
|
||||
<i class="material-icons left <%= content.class.text_color %>"><%= content.class.icon %></i>
|
||||
<%= link_to content.view_path, class: "blue-text", target: '_new' do %>
|
||||
<i class="material-icons left <%= content.text_color %>"><%= content.icon %></i>
|
||||
<i class="material-icons right grey-text">exit_to_app</i>
|
||||
View <%= content.name %>
|
||||
<% end %>
|
||||
</li>
|
||||
<li>
|
||||
<%= link_to edit_polymorphic_path(content), class: "green-text" do %>
|
||||
<i class="material-icons left <%= content.class.text_color %>"><%= content.class.icon %></i>
|
||||
<%= link_to content.edit_path, class: "green-text" do %>
|
||||
<i class="material-icons left <%= content.text_color %>"><%= content.icon %></i>
|
||||
<i class="material-icons right grey-text">exit_to_app</i>
|
||||
Edit <%= content.name %>
|
||||
<% end %>
|
||||
|
||||
@ -66,7 +66,7 @@ Rails.application.config.content_types = {
|
||||
|
||||
# Content types to label as "new" around the site
|
||||
new: [
|
||||
Lore
|
||||
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ Rails.application.config.promos[:promo_bogo] = {}
|
||||
Rails.application.config.promos[:promo_bogo][:start_date] = 'March 21, 2020'.to_date
|
||||
Rails.application.config.promos[:promo_bogo][:end_date] = Rails.application.config.promos[:promo_bogo][:start_date] + 2.weeks
|
||||
|
||||
# Lore free during the month of April
|
||||
# Lore free during the month of April, 2020
|
||||
# Need to change Lore.rb authorizer at the end lol
|
||||
if Date.current >= 'March 1, 2020'.to_date
|
||||
if Date.current < 'April 1, 2020'.to_date
|
||||
@ -14,3 +14,12 @@ if Date.current >= 'March 1, 2020'.to_date
|
||||
Rails.application.config.content_types[:premium] -= [Lore]
|
||||
end
|
||||
end
|
||||
|
||||
# Lore free during the month of October
|
||||
# Need to change Creature.rb authorizer at the end
|
||||
if Date.current >= 'October 1, 2021'.to_date
|
||||
if Date.current < 'November 1, 2021'.to_date
|
||||
Rails.application.config.content_types[:free] << Creature
|
||||
Rails.application.config.content_types[:premium] -= [Creature]
|
||||
end
|
||||
end
|
||||
|
||||
@ -98,6 +98,9 @@ Rails.application.routes.draw do
|
||||
|
||||
get '/scratchpad', to: 'main#notes', as: :notes
|
||||
|
||||
get 'tag/remove', to: 'page_tags#remove'
|
||||
delete 'tag/:id/destroy', to: 'page_tags#destroy', as: :destroy_specific_tag
|
||||
|
||||
# Legacy route: left intact so /my/documents/X URLs continue to work for everyone's bookmarks
|
||||
resources :documents
|
||||
|
||||
@ -133,12 +136,14 @@ Rails.application.routes.draw do
|
||||
scope '/data' do
|
||||
get '/', to: 'data#index', as: :data_vault
|
||||
get '/usage', to: 'data#usage'
|
||||
get '/tags', to: 'data#tags'
|
||||
get '/recyclebin', to: 'data#recyclebin'
|
||||
get '/archive', to: 'data#archive'
|
||||
get '/documents', to: 'data#documents', as: :data_documents
|
||||
get '/uploads', to: 'data#uploads'
|
||||
get '/discussions', to: 'data#discussions'
|
||||
get '/collaboration', to: 'data#collaboration'
|
||||
get '/green', to: 'data#green'
|
||||
scope 'yearly' do
|
||||
get '/', to: 'data#yearly_index', as: :year_in_review
|
||||
get '/:year', to: 'data#review_year', as: :review_year
|
||||
@ -167,6 +172,7 @@ Rails.application.routes.draw do
|
||||
|
||||
# Info pages
|
||||
scope '/about' do
|
||||
get '/paper', to: 'main#paper', as: :green_paper
|
||||
get '/privacy', to: 'main#privacyinfo', as: :privacy_policy
|
||||
end
|
||||
|
||||
@ -281,6 +287,8 @@ Rails.application.routes.draw do
|
||||
scope '/worldbuilding' do
|
||||
Rails.application.config.content_types[:all].each do |content_type|
|
||||
get content_type.name.downcase.pluralize, to: "information##{content_type.name.downcase.pluralize}", as: "#{content_type.name.downcase}_worldbuilding_info"
|
||||
# TODO: documents info page
|
||||
# TODO: timelines info page
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
class RemoveServiceNameFromActiveStorageBlobs < ActiveRecord::Migration[6.0]
|
||||
def up
|
||||
if column_exists?(:active_storage_blobs, :service_name)
|
||||
remove_column :active_storage_blobs, :service_name
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,5 @@
|
||||
class AddWordCountCacheToAttributes < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
add_column :attributes, :word_count_cache, :integer
|
||||
end
|
||||
end
|
||||
12
db/migrate/20211007234707_create_word_count_updates.rb
Normal file
12
db/migrate/20211007234707_create_word_count_updates.rb
Normal file
@ -0,0 +1,12 @@
|
||||
class CreateWordCountUpdates < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
create_table :word_count_updates do |t|
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.references :entity, polymorphic: true, null: false
|
||||
t.integer :word_count
|
||||
t.date :for_date
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
16
db/schema.rb
16
db/schema.rb
@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2021_07_29_092030) do
|
||||
ActiveRecord::Schema.define(version: 2021_10_07_234707) do
|
||||
|
||||
create_table "active_storage_attachments", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
@ -172,6 +172,7 @@ ActiveRecord::Schema.define(version: 2021_07_29_092030) do
|
||||
t.datetime "created_at"
|
||||
t.datetime "updated_at"
|
||||
t.datetime "deleted_at"
|
||||
t.integer "word_count_cache"
|
||||
t.index ["attribute_field_id", "deleted_at", "entity_id", "entity_type"], name: "attributes_afi_deleted_at_entity_id_entity_type"
|
||||
t.index ["attribute_field_id", "deleted_at"], name: "index_attributes_on_attribute_field_id_and_deleted_at"
|
||||
t.index ["attribute_field_id", "user_id", "entity_type", "entity_id", "deleted_at"], name: "attributes_afi_ui_et_ei_da"
|
||||
@ -3643,6 +3644,18 @@ ActiveRecord::Schema.define(version: 2021_07_29_092030) do
|
||||
t.integer "habitat_id"
|
||||
end
|
||||
|
||||
create_table "word_count_updates", force: :cascade do |t|
|
||||
t.integer "user_id", null: false
|
||||
t.string "entity_type", null: false
|
||||
t.integer "entity_id", null: false
|
||||
t.integer "word_count"
|
||||
t.date "for_date"
|
||||
t.datetime "created_at", precision: 6, null: false
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.index ["entity_type", "entity_id"], name: "index_word_count_updates_on_entity_type_and_entity_id"
|
||||
t.index ["user_id"], name: "index_word_count_updates_on_user_id"
|
||||
end
|
||||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "api_keys", "users"
|
||||
@ -4055,4 +4068,5 @@ ActiveRecord::Schema.define(version: 2021_07_29_092030) do
|
||||
add_foreign_key "vehicles", "users"
|
||||
add_foreign_key "votes", "users"
|
||||
add_foreign_key "votes", "votables"
|
||||
add_foreign_key "word_count_updates", "users"
|
||||
end
|
||||
|
||||
@ -1,4 +1,58 @@
|
||||
namespace :backfill do
|
||||
desc "Backfill cached word counts on all attributes"
|
||||
task attribute_word_count_caches: :environment do
|
||||
Attribute.where(word_count_cache: nil).where.not(value: ["", " ", ".", nil]).find_each do |attribute|
|
||||
word_count = WordCountAnalyzer::Counter.new(
|
||||
ellipsis: 'no_special_treatment',
|
||||
hyperlink: 'count_as_one',
|
||||
contraction: 'count_as_one',
|
||||
hyphenated_word: 'count_as_one',
|
||||
date: 'no_special_treatment',
|
||||
number: 'count',
|
||||
numbered_list: 'ignore',
|
||||
xhtml: 'remove',
|
||||
forward_slash: 'count_as_multiple_except_dates',
|
||||
backslash: 'count_as_one',
|
||||
dotted_line: 'ignore',
|
||||
dashed_line: 'ignore',
|
||||
underscore: 'ignore',
|
||||
stray_punctuation: 'ignore'
|
||||
).count(attribute.value)
|
||||
|
||||
attribute.update_column(:word_count_cache, word_count)
|
||||
end
|
||||
end
|
||||
|
||||
task :most_used_attribute_word_counts: :environment do
|
||||
word_counts = {}
|
||||
Attribute.where(word_count_cache: nil).group(:value).order('count_id DESC').limit(500).count(:id).each do |value, count|
|
||||
word_count = WordCountAnalyzer::Counter.new(
|
||||
ellipsis: 'no_special_treatment',
|
||||
hyperlink: 'count_as_one',
|
||||
contraction: 'count_as_one',
|
||||
hyphenated_word: 'count_as_one',
|
||||
date: 'no_special_treatment',
|
||||
number: 'count',
|
||||
numbered_list: 'ignore',
|
||||
xhtml: 'remove',
|
||||
forward_slash: 'count_as_multiple_except_dates',
|
||||
backslash: 'count_as_one',
|
||||
dotted_line: 'ignore',
|
||||
dashed_line: 'ignore',
|
||||
underscore: 'ignore',
|
||||
stray_punctuation: 'ignore'
|
||||
).count(value)
|
||||
|
||||
word_counts[word_count] ||= []
|
||||
word_counts[word_count].push value
|
||||
puts "#{value} x #{count}: #{word_count} words"
|
||||
end
|
||||
|
||||
word_counts.each do |count, values|
|
||||
Attribute.where(word_count_cache: nil, value: values).update_all(word_count_cache: count)
|
||||
end
|
||||
end
|
||||
|
||||
desc "Backfill cached word counts on all documents"
|
||||
task document_word_count_caches: :environment do
|
||||
Document.where(cached_word_count: nil).where.not(body: [nil, ""]).find_each(batch_size: 500) do |document|
|
||||
|
||||
@ -1,4 +1,35 @@
|
||||
namespace :one_off do
|
||||
desc "Clean up orphaned page tags"
|
||||
task clean_orphaned_page_tags: :environment do
|
||||
PageTag.find_each do |page_tag|
|
||||
referenced_page = page_tag.page
|
||||
|
||||
if referenced_page.nil?
|
||||
page_tag.destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
desc "Alert users who've saved at least one tree"
|
||||
task trees_notification: :environment do
|
||||
reference_code = 'green-trees'
|
||||
|
||||
User.find_each do |user|
|
||||
trees = GreenService.total_trees_saved_by(user)
|
||||
|
||||
if trees >= 1
|
||||
user.notifications.create!(
|
||||
message_html: "<div>You've saved #{trees.round} tree#{'s' if trees.round > 1} by going digital!</div><div class='blue-text text-darken-3'>That's AWESOME! Click here to see how.</div>",
|
||||
icon: 'park',
|
||||
icon_color: 'green',
|
||||
happened_at: DateTime.current,
|
||||
passthrough_link: 'https://www.notebook.ai/my/data/green',
|
||||
reference_code: reference_code
|
||||
) unless user.notifications.where(reference_code: reference_code).any?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
desc "Create a notification for all users telling them about the new notifications"
|
||||
task notifications_announcement: :environment do
|
||||
User.all.find_each do |user|
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
"@material-ui/lab": "^4.0.0-alpha.41",
|
||||
"@rails/webpacker": "^5.2.1",
|
||||
"@types/react": "^17.0.13",
|
||||
"axios": "^0.21.1",
|
||||
"axios": "^0.21.2",
|
||||
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
|
||||
"pluralize": "^8.0.0",
|
||||
"prop-types": "^15.7.2",
|
||||
|
||||
9
test/controllers/page_tags_controller_test.rb
Normal file
9
test/controllers/page_tags_controller_test.rb
Normal file
@ -0,0 +1,9 @@
|
||||
require 'test_helper'
|
||||
|
||||
class PageTagsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "should get remove" do
|
||||
get page_tags_remove_url
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
end
|
||||
15
test/fixtures/word_count_updates.yml
vendored
Normal file
15
test/fixtures/word_count_updates.yml
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
user: one
|
||||
entity: one
|
||||
entity_type: Entity
|
||||
word_count: 1
|
||||
for_date: 2021-10-07
|
||||
|
||||
two:
|
||||
user: two
|
||||
entity: two
|
||||
entity_type: Entity
|
||||
word_count: 1
|
||||
for_date: 2021-10-07
|
||||
7
test/models/word_count_update_test.rb
Normal file
7
test/models/word_count_update_test.rb
Normal file
@ -0,0 +1,7 @@
|
||||
require 'test_helper'
|
||||
|
||||
class WordCountUpdateTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
30
yarn.lock
30
yarn.lock
@ -1478,12 +1478,12 @@ autoprefixer@^9.6.1:
|
||||
postcss "^7.0.32"
|
||||
postcss-value-parser "^4.1.0"
|
||||
|
||||
axios@^0.21.1:
|
||||
version "0.21.1"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
|
||||
integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==
|
||||
axios@^0.21.2:
|
||||
version "0.21.2"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.2.tgz#21297d5084b2aeeb422f5d38e7be4fbb82239017"
|
||||
integrity sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==
|
||||
dependencies:
|
||||
follow-redirects "^1.10.0"
|
||||
follow-redirects "^1.14.0"
|
||||
|
||||
babel-loader@^8.2.2:
|
||||
version "8.2.2"
|
||||
@ -3084,10 +3084,10 @@ flush-write-stream@^1.0.0:
|
||||
inherits "^2.0.3"
|
||||
readable-stream "^2.3.6"
|
||||
|
||||
follow-redirects@^1.0.0, follow-redirects@^1.10.0:
|
||||
version "1.14.1"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43"
|
||||
integrity sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==
|
||||
follow-redirects@^1.0.0, follow-redirects@^1.14.0:
|
||||
version "1.14.4"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379"
|
||||
integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==
|
||||
|
||||
for-in@^1.0.2:
|
||||
version "1.0.2"
|
||||
@ -6584,9 +6584,9 @@ tapable@^1.0.0, tapable@^1.1.3:
|
||||
integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==
|
||||
|
||||
tar@^6.0.2:
|
||||
version "6.1.0"
|
||||
resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.0.tgz#d1724e9bcc04b977b18d5c573b333a2207229a83"
|
||||
integrity sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==
|
||||
version "6.1.11"
|
||||
resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621"
|
||||
integrity sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==
|
||||
dependencies:
|
||||
chownr "^2.0.0"
|
||||
fs-minipass "^2.0.0"
|
||||
@ -6841,9 +6841,9 @@ urix@^0.1.0:
|
||||
integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
|
||||
|
||||
url-parse@^1.4.3, url-parse@^1.5.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.1.tgz#d5fa9890af8a5e1f274a2c98376510f6425f6e3b"
|
||||
integrity sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==
|
||||
version "1.5.3"
|
||||
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.3.tgz#71c1303d38fb6639ade183c2992c8cc0686df862"
|
||||
integrity sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==
|
||||
dependencies:
|
||||
querystringify "^2.1.1"
|
||||
requires-port "^1.0.0"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user