Buffer Listings (#2956)

* add share to buffer button

* wip

* wip

* adds listings to buffer via form

* create migration for buffered timestamp and update seed file

* display when listing was last buffered

* remove annoying spacing

* refactor article script for listings

* social preview wip

* add custom social card for indivdual listings

* add styling

* fix css

* tweak styling

* final css tweaks

* fix branch

* add buffer listings id

* update envfile

* add social previews spec
This commit is contained in:
Jess Lee 2019-06-14 19:33:19 -04:00 committed by Ben Halpern
parent d33cc055dd
commit 10674aed72
20 changed files with 310 additions and 107 deletions

View file

@ -79,6 +79,7 @@ variable :BUFFER_FACEBOOK_ID, :String, default: "Optional"
variable :BUFFER_LINKEDIN_ID, :String, default: "Optional"
variable :BUFFER_PROFILE_ID, :Integer, default: 0
variable :BUFFER_TWITTER_ID, :String, default: "Optional"
variable :BUFFER_LISTINGS_PROFILE, :String, default: "Optional"
# Cloudinary for image resizing and cache??
variable :CLOUDINARY_API_KEY, :String, default: "Optional"

View file

@ -1,16 +1,23 @@
class Internal::BufferUpdatesController < Internal::ApplicationController
def create
article = Article.find(params[:article_id])
article_id = params[:article_id]
article = Article.find(article_id) if article_id.present?
fb_post = params[:fb_post]
tweet = params[:tweet]
if params[:social_channel] == "main_twitter"
Bufferizer.new(article, tweet).main_teet!
listing_id = params[:listing_id]
listing = ClassifiedListing.find(params[:listing_id]) if listing_id.present?
case params[:social_channel]
when "main_twitter"
Bufferizer.new("article", article, tweet).main_tweet!
render body: nil
elsif params[:social_channel] == "satellite_twitter"
Bufferizer.new(article, tweet).satellite_tweet!
when "satellite_twitter"
Bufferizer.new("article", article, tweet).satellite_tweet!
render body: nil
elsif params[:social_channel] == "facebook"
Bufferizer.new(article, fb_post).facebook_post!
when "facebook"
Bufferizer.new("article", article, fb_post).facebook_post!
render body: nil
when "listings_twitter"
Bufferizer.new("listing", listing, tweet).listings_tweet!
render body: nil
end
end

View file

@ -23,14 +23,27 @@ class SocialPreviewsController < ApplicationController
def user
@user = User.find(params[:id]) || not_found
respond_to do |format|
format.html do
render layout: false
end
format.png do
html = render_to_string(formats: :html, layout: false)
redirect_to HtmlCssToImage.fetch_url(html: html, css: PNG_CSS, google_fonts: "Roboto"), status: :found
redirect_to HtmlCssToImage.fetch_url(html: html, css: PNG_CSS, google_fonts: "Roboto|Roboto+Condensed"), status: :found
end
end
end
def listing
@listing = ClassifiedListing.find(params[:id]) || not_found
define_categories
respond_to do |format|
format.html do
render layout: false
end
format.png do
html = render_to_string(formats: :html, layout: false)
redirect_to HtmlCssToImage.fetch_url(html: html, css: PNG_CSS, google_fonts: "Roboto|Roboto+Condensed"), status: :found
end
end
end
@ -62,4 +75,24 @@ class SocialPreviewsController < ApplicationController
end
end
end
private
def define_categories
cat_info = {
"collabs": ["Collaborators Wanted", "#5AE8D9"],
"cfp": ["Call For Proposal", "#f58f8d"],
"forhire": ["Available For Hire", "#b78cf4"],
"education": ["Education", "#5AABE8"],
"jobs": ["Now Hiring", "#53c3ad"],
"mentors": ["Offering Mentorship", "#A69EE8"],
"mentees": ["Looking For Mentorship", "#88aedb"],
"forsale": ["Stuff For Sale", "#d0adfb"],
"events": ["Upcoming Event", "#f8b3d0"],
"misc": ["Miscellaneous", "#6393FF"],
"products": ["Products & Tools", "#5AE8D9"]
}
@category = cat_info[@listing.category.to_sym][0]
@cat_color = cat_info[@listing.category.to_sym][1]
end
end

View file

@ -13,6 +13,10 @@ module SocialImageHelper
end
end
def listing_social_image_url(listing)
listing_social_preview_url(listing, format: :png)
end
def article_social_image_url(article)
image = user_defined_image(article)
if image.present?

View file

@ -1,35 +1,49 @@
class Bufferizer
attr_accessor :article, :text
def initialize(article, text)
@article = article
attr_accessor :post_type, :post, :text
def initialize(post_type, post, text)
if post_type == "article"
@article = post
else
@listing = post
end
@text = text
end
def satellite_tweet!
article.tags.find_each do |tag|
BufferUpdate.buff!(article.id, twitter_buffer_text, tag.buffer_profile_id_code, "twitter", tag.id) if tag.buffer_profile_id_code.present?
@article.tags.find_each do |tag|
BufferUpdate.buff!(@article.id, twitter_buffer_text, tag.buffer_profile_id_code, "twitter", tag.id) if tag.buffer_profile_id_code.present?
end
article.update(last_buffered: Time.current)
@article.update(last_buffered: Time.current)
end
def main_teet!
BufferUpdate.buff!(article.id, twitter_buffer_text, ApplicationConfig["BUFFER_TWITTER_ID"], "twitter", nil)
article.update(last_buffered: Time.current)
def main_tweet!
BufferUpdate.buff!(@article.id, twitter_buffer_text, ApplicationConfig["BUFFER_TWITTER_ID"], "twitter", nil)
@article.update(last_buffered: Time.current)
end
def facebook_post!
BufferUpdate.buff!(article.id, fb_buffer_text, ApplicationConfig["BUFFER_FACEBOOK_ID"], "facebook")
BufferUpdate.buff!(article.id, fb_buffer_text, ApplicationConfig["BUFFER_LINKEDIN_ID"], "linkedin")
article.update(facebook_last_buffered: Time.current)
BufferUpdate.buff!(@article.id, fb_buffer_text, ApplicationConfig["BUFFER_FACEBOOK_ID"], "facebook")
BufferUpdate.buff!(@article.id, fb_buffer_text, ApplicationConfig["BUFFER_LINKEDIN_ID"], "linkedin")
@article.update(facebook_last_buffered: Time.current)
end
def listings_tweet!
buffer_listings_id = ApplicationConfig["BUFFER_LISTINGS_PROFILE"]
BufferUpdate.send_to_buffer(listings_twitter_text, buffer_listings_id)
@listing.update(last_buffered: Time.current)
end
private
def twitter_buffer_text
"#{text} https://dev.to#{article.path}" if text.size <= 255
"#{text} https://dev.to#{@article.path}" if text.size <= 255
end
def fb_buffer_text
"#{text} https://dev.to#{article.path}"
"#{text} https://dev.to#{@article.path}"
end
def listings_twitter_text
"#{text} https://dev.to#{@listing.path}" if text.size <= 255
end
end

View file

@ -1,6 +1,5 @@
class BufferUpdate < ApplicationRecord
belongs_to :article
validate :validate_body_text_recent_uniqueness
validates :status, inclusion: { in: %w[pending sent_direct confirmed dismissed] }

View file

@ -5,7 +5,6 @@ class ClassifiedListing < ApplicationRecord
belongs_to :user, optional: true
belongs_to :organization, optional: true
before_save :evaluate_markdown
before_create :create_slug
before_validation :modify_inputs

View file

@ -4,37 +4,44 @@
<link rel="canonical" href="https://dev.to<%= request.path %>" />
<meta name="description" content="Where programmers share ideas and help each other grow.">
<meta name="keywords" content="software development,engineering,rails,javascript,ruby">
<meta property="og:type" content="website" />
<meta property="og:url" content="https://dev.to<%= request.path %>" />
<meta property="og:title" content="Listings" />
<meta property="og:image" content="https://thepracticaldev.s3.amazonaws.com/i/sa3xjflbtvt9zw0xpan5.png">
<meta property="og:description" content="Where programmers share ideas and help each other grow." />
<meta property="og:site_name" content="<%= community_qualified_name %>" />
<% if @displayed_classified_listing %>
<meta property="og:title" content="<%= truncate @displayed_classified_listing.title, length: 54 %>" />
<meta property="og:description" content="DEV Listing" />
<meta property="og:image" content="<%= listing_social_image_url @displayed_classified_listing %>">
<meta name="twitter:title" content="<%= truncate @displayed_classified_listing.title, length: 54 %>">
<meta name="twitter:description" content="DEV Listing">
<meta name="twitter:image:src" content="<%= listing_social_image_url @displayed_classified_listing %>">
<% else %>
<meta property="og:title" content="Listings" />
<meta property="og:description" content="Where programmers share ideas and help each other grow." />
<meta property="og:image" content="https://thepracticaldev.s3.amazonaws.com/i/sa3xjflbtvt9zw0xpan5.png">
<meta name="twitter:title" content="Listings">
<meta name="twitter:description" content="Where programmers share ideas, experiences, and help each other grow.">
<meta name="twitter:image:src" content="https://thepracticaldev.s3.amazonaws.com/i/sa3xjflbtvt9zw0xpan5.png">
<% end %>
<meta name="twitter:site" content="@ThePracticalDev">
<meta name="twitter:title" content="Listings">
<meta name="twitter:description" content="Where programmers share ideas, experiences, and help each other grow.">
<meta name="twitter:image:src" content="https://thepracticaldev.s3.amazonaws.com/i/sa3xjflbtvt9zw0xpan5.png">
<meta name="twitter:card" content="summary_large_image">
<% end %>
<div class="home">
<div class="classifieds-container" id="classifieds-index-container"
data-category="<%= params[:category] %>" data-listings="<%= @classified_listings.to_json(
only: %i[title processed_html tag_list category id user_id slug contact_via_connect],
include: {
author: { only: %i[username name], methods: %i[username profile_image_90] },
},
) %>"
only: %i[title processed_html tag_list category id user_id slug contact_via_connect],
include: {
author: { only: %i[username name], methods: %i[username profile_image_90] }
},
) %>"
data-allcategories="<%= ClassifiedListing.categories_for_display.to_json %>"
<% if @displayed_classified_listing %>
data-displayedlisting="<%= @displayed_classified_listing.to_json(
only: %i[title processed_html tag_list category id user_id slug contact_via_connect],
include: {
author: { only: %i[username name], methods: %i[username profile_image_90] },
},
) %> "
only: %i[title processed_html tag_list category id user_id slug contact_via_connect],
include: {
author: { only: %i[username name], methods: %i[username profile_image_90] }
},
) %> "
<% end %>
>
<div class="classified-filters">

View file

@ -1,4 +1,5 @@
<script>
console.log('its rendering!')
function timeNow(objectID) {
var seconds = new Date().getTime() / 1000;
document.getElementById("featured_number_" + objectID).value = Math.round(seconds);
@ -9,7 +10,7 @@
document.getElementById("featured_number_" + objectID).value = Math.round(seconds);
}
$('.row').on("submit", "form", function () {
function submitForms() {
var form = $(this);
var valuesToSubmit = $(this).serialize();
$.ajax({
@ -19,47 +20,54 @@
// dataType: "JSON" // you want a difference between normal and ajax-calls, and json is standard
}).success(function (json) {
console.log("success")
form.parents(".row").addClass("highlighted-bg")
form.parents(".row").addClass("highlighted-border")
form.parents(".row, .single-internal-listing").addClass("highlighted-bg")
form.parents(".row, .single-internal-listing").addClass("highlighted-border")
setTimeout(function () {
form.parents(".row").removeClass("highlighted-bg")
form.parents(".row, .single-internal-listing").removeClass("highlighted-bg")
}, 350)
});
return false; // prevents normal behaviour
});
}
$('.row').on("submit", "form", submitForms)
$('.buffering-area-for-single-listing').on("submit", "form", submitForms)
$(".toggle-buffering-butt").click(function (e) {
e.preventDefault()
console.log($(this).closest(".single-internal-article").find(".buffering-area-for-single-article").toggle());
console.log($(this).closest(".single-internal-article, .buffer-area").find(".buffering-area-for-single-article, .buffering-area-for-single-listing").toggle());
})
document.getElementById('image-upload-button').onclick = function (e) {
e.preventDefault();
document.getElementById('image-upload').click();
}
imageUploadButt = document.getElementById('image-upload-button')
imageUpload = document.getElementById('image-upload')
imageUploadSubmit = document.getElementById('image-upload-submit')
document.getElementById('image-upload').onchange = function (e) {
var image = document.getElementById('image-upload').files;
if (image.length > 0) {
document.getElementById('image-upload-file-label').style = 'color:#888888';
document.getElementById('image-upload-file-label').innerHTML = "Uploading...";
document.getElementById('uploaded-image').style = 'display:none';
document.getElementById('image-upload-submit').value = "uploading";
setTimeout(function () {
document.getElementById('image-upload-submit').click(function () {
});
}, 50)
if (imageUploadButt && imageUpload && imageUploadSubmit) {
imageUploadButt.onclick = function (e) {
e.preventDefault();
document.getElementById('image-upload').click();
}
}
document.getElementById('image-upload-submit').onclick = function (e) {
e.preventDefault();
var image = document.getElementById('image-upload').files;
if (image.length > 0) {
var ajaxReq = createAjaxReq();
ajaxReq.onreadystatechange = uploadImageCb.bind(this, ajaxReq);
ajaxReq.open('POST', '/image_uploads', true);
ajaxReq.send(generateUploadFormdata(image));
imageUpload.onchange = function (e) {
var image = document.getElementById('image-upload').files;
if (image.length > 0) {
document.getElementById('image-upload-file-label').style = 'color:#888888';
document.getElementById('image-upload-file-label').innerHTML = "Uploading...";
document.getElementById('uploaded-image').style = 'display:none';
document.getElementById('image-upload-submit').value = "uploading";
setTimeout(function () {
document.getElementById('image-upload-submit').click(function () {
});
}, 50)
}
}
imageUploadSubmit.onclick = function (e) {
e.preventDefault();
var image = document.getElementById('image-upload').files;
if (image.length > 0) {
var ajaxReq = createAjaxReq();
ajaxReq.onreadystatechange = uploadImageCb.bind(this, ajaxReq);
ajaxReq.open('POST', '/image_uploads', true);
ajaxReq.send(generateUploadFormdata(image));
}
}
}

View file

@ -132,8 +132,7 @@
<% if !article.user.twitter_username.blank? %>
{ author: @<%= article.user.twitter_username %> } #DEVCommunity
<% end %>
</textarea>
<% end %></textarea>
<button class="btn btn-info" style="font-size:1em;">🦅 Tweet to @ThePracticalDev</button>
<% end %>
<% if (article.decorate.cached_tag_list_array & Tag.bufferized_tags).any? %>

View file

@ -8,7 +8,7 @@
<%= submit_tag("Search") %>
<% end %>
<%= paginate @classified_listings %>
<div class="wrapper">
<div class="wrapper" style="border-bottom: 1px solid black">
<div class="grid-item">Title Link</div>
<div class="grid-item">User/Org</div>
<div class="grid-item">Category</div>
@ -17,23 +17,41 @@
<div class="grid-item">Last Bumped</div>
</div>
<% @classified_listings.each do |listing| %>
<div class="wrapper">
<div class="grid-item">
<a href="/internal/listings/<%= listing.id %>/edit" target="_blank">
<%= listing.title %>
</a>
<div class="single-internal-listing">
<div class="wrapper">
<div class="grid-item">
<a href="/internal/listings/<%= listing.id %>/edit" target="_blank">
<%= listing.title %>
</a>
</div>
<div class="grid-item"><a href="/internal/users/<%= listing.user_id %>"><%= User.find(listing.user_id).username %></a><%= " / " + Organization.find(listing.organization_id).name if listing.organization_id.present? %></div>
<div class="grid-item"><%= listing.category %></div>
<div class="grid-item"><%= listing.cached_tag_list %></div>
<div class="grid-item"><%= listing.published ? "Yes" : "No" %></div>
<div class="grid-item"><%= time_ago_in_words(listing.bumped_at) %> ago</div>
</div>
<div class="buffer-area" style="text-align: left">
<br>
<button class="btn toggle-buffering-butt" style="border: 1px solid grey; margin-top: 2px">Share to Buffer</button><% if listing.last_buffered.present? %> <em> Last shared: <%= listing.last_buffered.strftime("%d %B %Y") %></em><% end %>
<br>
<br>
<div class="buffering-area-for-single-listing" style="display: none;">
<div class="grid-item">
<p class="listing-title"><strong><%= listing.title %></strong></p>
<p class="listing-body"><%= listing.processed_html&.html_safe %></p>
</div>
<div class="grid-item">
<%= form_tag "/internal/buffer_updates" do %>
<input type="hidden" name="social_channel" value="listings_twitter" />
<input type="hidden" name="listing_id" value="<%= listing.id %>" />
<textarea cols="37" rows="5" wrap="hard" name="tweet" maxlength="255"><%= listing.title %><% if User.find(listing.user_id).twitter_username.present? %>&#013&#013Listed by @<%= User.find(listing.user_id).twitter_username %> 😎&#013<% end %>&#013#DEVCommunity</textarea>
<br>
<button class="btn-info tweet-listing">🐦 Tweet 🐦</button>
<% end %>
</div>
</div>
</div>
<div class="grid-item"><a href="/internal/users/<%= listing.user_id %>"><%= User.find(listing.user_id).username %></a><%= " / " + Organization.find(listing.organization_id).name if listing.organization_id.present? %></div>
<div class="grid-item"><%= listing.category %></div>
<div class="grid-item"><%= listing.cached_tag_list %></div>
<div class="grid-item"><%= listing.published ? "Yes" : "No" %></div>
<div class="grid-item"><%= time_ago_in_words(listing.bumped_at) %> ago</div>
</div>
<% end %>
<%= paginate @classified_listings %>
<style>
.wrapper {
border-bottom: 1px solid grey;
padding: 10px;
}
</style>
<%= render "internal/articles/article_script" %>

View file

@ -59,6 +59,17 @@
min-width: 100px;
}
.single-internal-listing {
border-bottom: 1px solid grey;
padding: 10px;
}
.buffering-area-for-single-listing {
display: grid;
grid-template-columns: 50% 50%;
padding: 2px;
}
.tab {
padding-top: 10px;
padding-bottom: 10px;

View file

@ -1,13 +1,86 @@
<div clas="listing-content">
<h3><%= @listing.title %></h3>
<div class="single-classified-listing-body">
<%= @listing.processed_html.html_safe %>
</div>
<div class="single-classified-listing-tags">
<% if @listing.tags.present? %>
<%= @listing.tags.each do |tag| %>
<%= tag.name %>
<% end %>
<% end %>
<style>
body {
background: white;
margin: 0;
padding: 0;
}
.preview-div-wrapper {
overflow: hidden;
position: relative;
}
.preview-div {
border: 0.25vw solid <%= @cat_color %>;
box-shadow: 0.75vw .75vw 0px <%= @cat_color %>;
width: 88vw;
height: 42vw;
margin: 2.5vw auto 5vw;
overflow: hidden;
position: relative;
background-image: linear-gradient(to bottom, transparent 40%, <%= @cat_color %>);
}
.preview-info-header {
color: black;
margin: 2vw auto 0vw;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, "Roboto", monospace;
font-size: 3.5vw;
width: 92%;
}
.preview-title{
padding: 0 2vw;
}
h1 {
color: <%= @cat_color %>;
width: 92%;
margin: 0;
padding: 1vw;
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", "Roboto", sans-serif;
}
.preview-body {
font-size: 2vw;
padding: 0 4vw;
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", "Roboto", sans-serif;
color: <%= @cat_color %>;
}
.preview-category {
font-size: 3.5vw;
position: absolute;
background: white;
left: -2vw;
bottom: 0vw;
padding: 1vw 4vw;
font-weight: 500;
width: 100%;
color: <%= @cat_color %>;
}
.preview-dev-logo {
position: absolute;
bottom: -2.25vw;
right: -3vw;
text-align: right;
}
</style>
<div class="preview-div-wrapper">
<div class="preview-div">
<div class="preview-title">
<% font_size = @listing.title.length > 65 ? 3 : 4 %>
<h1 style="font-size:<%= font_size %>vw;"><%= @listing.title %></h1>
</div>
<div class="preview-body">
<%= @listing.processed_html.html_safe %>
</div>
<div class="preview-category">
<%= @category %>
</div>
<div class="preview-dev-logo">
<%= inline_svg("devlistings-horizontal.svg", class: "logo", size: "30vw*10vw", aria: true, title: "DEV logo") %>
</div>
</div>
</div>

View file

@ -27,7 +27,7 @@ Rails.application.config.assets.precompile += %w[s3_direct_upload.css]
Rails.application.config.assets.precompile += %w[base.js]
Rails.application.config.assets.precompile += %w[hello-dev.js]
Rails.application.config.assets.precompile += %w[s3_direct_upload.js]
Rails.application.config.assets.precompile += %w[classified_listings.css]
Rails.application.config.assets.precompile += %w[lib/xss.js]
Rails.application.config.assets.precompile += %w[lib/pulltorefresh.js]
Rails.application.config.assets.precompile += %w[internal.js]

View file

@ -166,6 +166,7 @@ Rails.application.routes.draw do
get "/social_previews/user/:id" => "social_previews#user", as: :user_social_preview
get "/social_previews/organization/:id" => "social_previews#organization", as: :organization_social_preview
get "/social_previews/tag/:id" => "social_previews#tag", as: :tag_social_preview
get "/social_previews/listing/:id" => "social_previews#listing", as: :listing_social_preview
get "/async_info/base_data", controller: "async_info#base_data", defaults: { format: :json }

View file

@ -0,0 +1,5 @@
class AddLastBufferedToClassifiedListings < ActiveRecord::Migration[5.2]
def change
add_column :classified_listings, :last_buffered, :datetime
end
end

View file

@ -14,6 +14,7 @@
ActiveRecord::Schema.define(version: 2019_06_14_093041) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "plpgsql"
create_table "ahoy_messages", id: :serial, force: :cascade do |t|
@ -244,6 +245,7 @@ ActiveRecord::Schema.define(version: 2019_06_14_093041) do
t.string "category"
t.boolean "contact_via_connect", default: false
t.datetime "created_at", null: false
t.datetime "last_buffered"
t.bigint "organization_id"
t.text "processed_html"
t.boolean "published"

View file

@ -253,6 +253,8 @@ listings_categories.each_with_index do |category, index|
body_markdown: Faker::Markdown.random,
category: category,
contact_via_connect: true,
published: true,
bumped_at: Time.current,
)
end
end

View file

@ -2,17 +2,24 @@ require "rails_helper"
RSpec.describe Bufferizer do
let(:user) { create(:user) }
let(:listing) { create(:classified_listing, user_id: user.id) }
let(:article) { create(:article, user_id: user.id) }
it "sends to buffer twitter" do
tweet = "test tweet"
described_class.new(article, tweet).main_teet!
described_class.new("article", article, tweet).main_tweet!
expect(article.last_buffered.utc.to_i).to be > 2.minutes.ago.to_i
end
it "sends to buffer facebook" do
post = "test facebook post"
described_class.new(article, post).facebook_post!
described_class.new("article", article, post).facebook_post!
expect(article.facebook_last_buffered.utc.to_i).to be > 2.minutes.ago.to_i
end
it "sends to buffer listings" do
text = "test listing"
described_class.new("listing", listing, text).listings_tweet!
expect(listing.last_buffered).not_to be(nil)
end
end

View file

@ -6,6 +6,7 @@ RSpec.describe "SocialPreviews", type: :request do
let(:organization) { create(:organization) }
let(:article) { create(:article, user_id: user.id) }
let(:image_url) { "https://hcti.io/v1/image/6c52de9d-4d37-4008-80f8-67155589e1a1" }
let(:listing) { create(:classified_listing, user_id: user.id, category: "cfp") }
before do
stub_request(:post, /hcti.io/).
@ -73,4 +74,16 @@ RSpec.describe "SocialPreviews", type: :request do
expect(response).to redirect_to(image_url)
end
end
describe "GET /social_previews/listing/:id" do
it "renders pretty category name" do
get "/social_previews/listing/#{listing.id}"
expect(response.body).to include CGI.escapeHTML("Call For Proposal")
end
it "renders and image when requested and redirects to iamge url" do
get "/social_previews/listing/#{listing.id}.png"
expect(response).to redirect_to(image_url)
end
end
end