topics/polls forms, relative dates

This commit is contained in:
byte[] 2019-11-18 22:38:22 -05:00
parent 3ee6c07609
commit d6e07b8316
17 changed files with 449 additions and 63 deletions

View file

@ -1,8 +1,17 @@
import { $, $$, clearEl, removeEl, insertBefore } from './utils/dom'; import { $, $$, clearEl, removeEl, insertBefore } from './utils/dom';
import { delegate, leftClick } from './utils/events';
function pollOptionRemover(_event, target) {
removeEl(target.closest('.js-poll-option'));
}
function pollOptionCreator() { function pollOptionCreator() {
const addPollOptionButton = $('.js-poll-add-option'); const addPollOptionButton = $('.js-poll-add-option');
delegate(document, 'click', {
'.js-option-remove': leftClick(pollOptionRemover)
});
if (!addPollOptionButton) { if (!addPollOptionButton) {
return; return;
} }
@ -16,13 +25,10 @@ function pollOptionCreator() {
if (existingOptionCount < maxOptionCount) { if (existingOptionCount < maxOptionCount) {
// The element right before the add button will always be the last field, make a copy // The element right before the add button will always be the last field, make a copy
const prevFieldCopy = addPollOptionButton.previousElementSibling.cloneNode(true); const prevFieldCopy = addPollOptionButton.previousElementSibling.cloneNode(true);
// Clear its value and increment the N in "Option N" in the placeholder attribute const newHtml = prevFieldCopy.outerHTML.replace(/(\d+)/g, `${existingOptionCount}`);
clearEl($$('.js-option-id', prevFieldCopy));
const input = $('.js-option-label', prevFieldCopy);
input.value = '';
input.setAttribute('placeholder', input.getAttribute('placeholder').replace(/\d+$/, m => parseInt(m, 10) + 1));
// Insert copy before the button // Insert copy before the button
insertBefore(addPollOptionButton, prevFieldCopy); addPollOptionButton.insertAdjacentHTML("beforebegin", newHtml);
existingOptionCount++; existingOptionCount++;
} }

View file

@ -1,5 +1,5 @@
import { $$, makeEl, findFirstTextNode } from './utils/dom'; import { $$, makeEl, findFirstTextNode } from './utils/dom';
import { fire, delegate } from './utils/events'; import { fire, delegate, leftClick } from './utils/events';
const headers = () => ({ const headers = () => ({
'x-csrf-token': window.booru.csrfToken 'x-csrf-token': window.booru.csrfToken
@ -85,10 +85,6 @@ function linkRemote(event, target) {
); );
} }
function leftClick(func) {
return (event, target) => { if (event.button === 0) return func(event, target); };
}
delegate(document, 'click', { delegate(document, 'click', {
'a[data-confirm],button[data-confirm],input[data-confirm]': leftClick(confirm), 'a[data-confirm],button[data-confirm],input[data-confirm]': leftClick(confirm),
'a[data-disable-with],button[data-disable-with],input[data-disable-with]': leftClick(disable), 'a[data-disable-with],button[data-disable-with],input[data-disable-with]': leftClick(disable),

View file

@ -10,6 +10,10 @@ export function on(node, event, selector, func) {
delegate(node, event, { [selector]: func }); delegate(node, event, { [selector]: func });
} }
export function leftClick(func) {
return (event, target) => { if (event.button === 0) return func(event, target); };
}
export function delegate(node, event, selectors) { export function delegate(node, event, selectors) {
node.addEventListener(event, e => { node.addEventListener(event, e => {
for (const selector in selectors) { for (const selector in selectors) {

View file

@ -17,4 +17,19 @@ defmodule Philomena.PollOptions.PollOption do
|> cast(attrs, []) |> cast(attrs, [])
|> validate_required([]) |> validate_required([])
end end
@doc false
def creation_changeset(poll_option, attrs) do
poll_option
|> cast(attrs, [:label])
|> validate_required([:label])
|> validate_length(:label, max: 80, count: :bytes)
|> unique_constraint(:label, name: :index_poll_options_on_poll_id_and_label)
|> ignore_if_blank()
end
defp ignore_if_blank(%{valid?: false, changes: changes} = changeset) when changes == %{},
do: %{changeset | action: :ignore}
defp ignore_if_blank(changeset),
do: changeset
end end

View file

@ -17,6 +17,7 @@ defmodule Philomena.Polls.Poll do
field :total_votes, :integer, default: 0 field :total_votes, :integer, default: 0
field :hidden_from_users, :boolean, default: false field :hidden_from_users, :boolean, default: false
field :deletion_reason, :string, default: "" field :deletion_reason, :string, default: ""
field :until, :string, virtual: true
timestamps(inserted_at: :created_at) timestamps(inserted_at: :created_at)
end end
@ -27,4 +28,37 @@ defmodule Philomena.Polls.Poll do
|> cast(attrs, []) |> cast(attrs, [])
|> validate_required([]) |> validate_required([])
end end
@doc false
def creation_changeset(poll, attrs) do
poll
|> cast(attrs, [:title, :until, :vote_method])
|> put_active_until()
|> validate_required([:title, :active_until, :vote_method])
|> validate_length(:title, max: 140, count: :bytes)
|> validate_inclusion(:vote_method, ["single", "multiple"])
|> cast_assoc(:options, with: &PollOption.creation_changeset/2)
|> validate_length(:options, min: 2, max: 20)
|> ignore_if_blank()
end
defp ignore_if_blank(%{valid?: false, changes: changes} = changeset) when changes == %{},
do: %{changeset | action: :ignore}
defp ignore_if_blank(changeset),
do: changeset
defp put_active_until(changeset) do
changeset
|> get_field(:until)
|> RelativeDate.Parser.parse()
|> case do
{:ok, until} ->
changeset
|> change(active_until: until)
_error ->
changeset
|> add_error(:active_until, "invalid date format")
end
end
end end

View file

@ -49,6 +49,18 @@ defmodule Philomena.Posts.Post do
|> put_name_at_post_time() |> put_name_at_post_time()
end end
@doc false
def topic_creation_changeset(post, attrs, attribution, anonymous?) do
post
|> change(anonymous: anonymous?)
|> cast(attrs, [:body])
|> validate_required([:body])
|> validate_length(:body, min: 1, max: 300_000, count: :bytes)
|> change(attribution)
|> change(topic_position: 0)
|> put_name_at_post_time()
end
defp put_name_at_post_time(%{changes: %{user: %{data: %{name: name}}}} = changeset), defp put_name_at_post_time(%{changes: %{user: %{data: %{name: name}}}} = changeset),
do: change(changeset, name_at_post_time: name) do: change(changeset, name_at_post_time: name)
defp put_name_at_post_time(changeset), defp put_name_at_post_time(changeset),

View file

@ -1,4 +1,6 @@
defmodule Philomena.Repo do defmodule Philomena.Repo do
alias Ecto.Multi
use Ecto.Repo, use Ecto.Repo,
otp_app: :philomena, otp_app: :philomena,
adapter: Ecto.Adapters.Postgres adapter: Ecto.Adapters.Postgres
@ -11,17 +13,14 @@ defmodule Philomena.Repo do
serializable: "SERIALIZABLE" serializable: "SERIALIZABLE"
} }
def isolated_transaction(f, level) do def isolated_transaction(%Multi{} = multi, level) do
Philomena.Repo.transaction(fn -> Multi.append(
Philomena.Repo.query!("SET TRANSACTION ISOLATION LEVEL #{@levels[level]}") Multi.new |> Multi.run(:isolate, fn repo, _chg ->
Philomena.Repo.transaction(f) repo.query!("SET TRANSACTION ISOLATION LEVEL #{@levels[level]}")
end) {:ok, nil}
|> case do end),
{:ok, value} -> multi
value )
|> Philomena.Repo.transaction()
error ->
error
end
end end
end end

View file

@ -1,4 +1,39 @@
defmodule Philomena.Slug do defmodule Philomena.Slug do
# Generates a URL-safe slug from a string by removing nonessential
# information from it.
#
# The process for this is as follows:
#
# 1. Remove non-ASCII or non-printable characters.
#
# 2. Replace any runs of non-alphanumeric characters that were allowed
# through previously with hyphens.
#
# 3. Remove any starting or ending hyphens.
#
# 4. Convert all characters to their lowercase equivalents.
#
# This method makes no guarantee of creating unique slugs for unique inputs.
# In addition, for certain inputs, it will return empty strings.
#
# Example
#
# destructive_slug("Time-Wasting Thread 3.0 (SFW - No Explicit/Grimdark)")
# #=> "time-wasting-thread-3-0-sfw-no-explicit-grimdark"
#
# destructive_slug("~`!@#$%^&*()-_=+[]{};:'\" <>,./?")
# #=> ""
#
@spec destructive_slug(String.t()) :: String.t()
def destructive_slug(input) when is_binary(input) do
input
|> String.replace(~r/[^ -~]/, "") # 1
|> String.replace(~r/[^a-zA-Z0-9]+/, "-") # 2
|> String.replace(~r/\A-|-\z/, "") # 3
|> String.downcase() # 4
end
def destructive_slug(_input), do: ""
def slug(string) when is_binary(string) do def slug(string) when is_binary(string) do
string string
|> String.replace("-", "-dash-") |> String.replace("-", "-dash-")

View file

@ -4,22 +4,12 @@ defmodule Philomena.Topics do
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Ecto.Multi
alias Philomena.Repo alias Philomena.Repo
alias Philomena.Topics.Topic alias Philomena.Topics.Topic
alias Philomena.Forums.Forum
@doc """ alias Philomena.Notifications
Returns the list of topics.
## Examples
iex> list_topics()
[%Topic{}, ...]
"""
def list_topics do
Repo.all(Topic)
end
@doc """ @doc """
Gets a single topic. Gets a single topic.
@ -49,10 +39,58 @@ defmodule Philomena.Topics do
{:error, %Ecto.Changeset{}} {:error, %Ecto.Changeset{}}
""" """
def create_topic(attrs \\ %{}) do def create_topic(forum, attribution, attrs \\ %{}) do
%Topic{} topic =
|> Topic.changeset(attrs) %Topic{}
|> Repo.insert() |> Topic.creation_changeset(attrs, forum, attribution)
Multi.new
|> Multi.insert(:topic, topic)
|> Multi.run(:update_topic, fn repo, %{topic: topic} ->
{count, nil} =
Topic
|> where(id: ^topic.id)
|> repo.update_all(set: [last_post_id: hd(topic.posts).id])
{:ok, count}
end)
|> Multi.run(:update_forum, fn repo, %{topic: topic} ->
{count, nil} =
Forum
|> where(id: ^topic.forum_id)
|> repo.update_all(inc: [post_count: 1], set: [last_post_id: hd(topic.posts).id])
{:ok, count}
end)
|> Repo.isolated_transaction(:serializable)
end
def notify_topic(topic) do
spawn fn ->
forum =
topic
|> Repo.preload(:forum)
|> Map.fetch!(:forum)
subscriptions =
forum
|> Repo.preload(:subscriptions)
|> Map.fetch!(:subscriptions)
Notifications.notify(
topic,
subscriptions,
%{
actor_id: forum.id,
actor_type: "Forum",
actor_child_id: topic.id,
actor_child_type: "Topic",
action: "posted a new topic in"
}
)
end
topic
end end
@doc """ @doc """

View file

@ -7,6 +7,7 @@ defmodule Philomena.Topics.Topic do
alias Philomena.Polls.Poll alias Philomena.Polls.Poll
alias Philomena.Posts.Post alias Philomena.Posts.Post
alias Philomena.Topics.Subscription alias Philomena.Topics.Subscription
alias Philomena.Slug
@derive {Phoenix.Param, key: :slug} @derive {Phoenix.Param, key: :slug}
schema "topics" do schema "topics" do
@ -20,7 +21,7 @@ defmodule Philomena.Topics.Topic do
has_many :subscriptions, Subscription has_many :subscriptions, Subscription
field :title, :string field :title, :string
field :post_count, :integer, default: 0 field :post_count, :integer, default: 1
field :view_count, :integer, default: 0 field :view_count, :integer, default: 0
field :sticky, :boolean, default: false field :sticky, :boolean, default: false
field :last_replied_to_at, :naive_datetime field :last_replied_to_at, :naive_datetime
@ -40,4 +41,37 @@ defmodule Philomena.Topics.Topic do
|> cast(attrs, []) |> cast(attrs, [])
|> validate_required([]) |> validate_required([])
end end
@doc false
def creation_changeset(topic, attrs, forum, attribution) do
changes =
topic
|> cast(attrs, [:title, :anonymous])
|> validate_required([:title, :anonymous])
anonymous? =
changes
|> get_field(:anonymous)
changes
|> validate_length(:title, min: 4, max: 96, count: :bytes)
|> put_slug()
|> change(forum: forum, user: attribution[:user])
|> validate_required(:forum)
|> cast_assoc(:poll, with: &Poll.creation_changeset/2)
|> cast_assoc(:posts, with: {Post, :topic_creation_changeset, [attribution, anonymous?]})
|> validate_length(:posts, is: 1)
|> unique_constraint(:slug, name: :index_topics_on_forum_id_and_slug)
end
def put_slug(changeset) do
slug =
changeset
|> get_field(:title)
|> Slug.destructive_slug()
changeset
|> put_change(:slug, slug)
|> validate_required(:slug, message: "must be printable")
end
end end

View file

@ -1,10 +1,14 @@
defmodule PhilomenaWeb.TopicController do defmodule PhilomenaWeb.TopicController do
use PhilomenaWeb, :controller use PhilomenaWeb, :controller
alias Philomena.{Forums.Forum, Topics, Topics.Topic, Posts, Posts.Post, Textile.Renderer} alias Philomena.{Forums.Forum, Topics.Topic, Posts.Post, Polls.Poll, PollOptions.PollOption}
alias Philomena.{Topics, Posts}
alias Philomena.Textile.Renderer
alias Philomena.Repo alias Philomena.Repo
import Ecto.Query import Ecto.Query
plug PhilomenaWeb.FilterBannedUsersPlug when action in [:new, :create]
plug PhilomenaWeb.UserAttributionPlug when action in [:new, :create]
plug :load_and_authorize_resource, model: Forum, id_name: "forum_id", id_field: "short_name", persisted: true plug :load_and_authorize_resource, model: Forum, id_name: "forum_id", id_field: "short_name", persisted: true
def show(conn, %{"id" => slug} = params) do def show(conn, %{"id" => slug} = params) do
@ -60,4 +64,37 @@ defmodule PhilomenaWeb.TopicController do
render(conn, "show.html", posts: posts, changeset: changeset, watching: watching) render(conn, "show.html", posts: posts, changeset: changeset, watching: watching)
end end
def new(conn, _params) do
changeset =
%Topic{poll: %Poll{options: [%PollOption{}, %PollOption{}]}, posts: [%Post{}]}
|> Topics.change_topic()
render(conn, "new.html", changeset: changeset)
end
def create(conn, %{"topic" => topic_params}) do
attributes = conn.assigns.attributes
forum = conn.assigns.forum
case Topics.create_topic(forum, attributes, topic_params) do
{:ok, %{topic: topic}} ->
post = hd(topic.posts)
Posts.reindex_post(post)
Topics.notify_topic(topic)
conn
|> put_flash(:info, "Successfully posted topic.")
|> redirect(to: Routes.forum_topic_path(conn, :show, forum, topic))
{:error, :topic, changeset, _} ->
conn
|> render("new.html", changeset: changeset)
_error ->
conn
|> put_flash(:error, "There was an error with your submission. Please try again.")
|> redirect(to: Routes.forum_topic_path(conn, :new, forum))
end
end
end end

View file

@ -67,7 +67,7 @@ defmodule PhilomenaWeb.Router do
end end
resources "/forums", ForumController, only: [] do resources "/forums", ForumController, only: [] do
resources "/topics", TopicController, only: [] do resources "/topics", TopicController, only: [:new, :create] do
resources "/subscription", Topic.SubscriptionController, only: [:create, :delete], singleton: true resources "/subscription", Topic.SubscriptionController, only: [:create, :delete], singleton: true
end end
end end

View file

@ -6,7 +6,10 @@ h1 = @forum.name
=> link("Forums", to: Routes.forum_path(@conn, :index)) => link("Forums", to: Routes.forum_path(@conn, :index))
' &raquo; ' &raquo;
=> link(@forum.name, to: Routes.forum_path(@conn, :show, @forum)) => link(@forum.name, to: Routes.forum_path(@conn, :show, @forum))
/= icon_link 'New Topic', 'fas fa-fw fa-pen-square', new_forum_topic_path(@forum) a href=Routes.forum_topic_path(@conn, :new, @forum)
i.fa.fa-fw.fa-pencil>
' New Topic
/= icon_link 'Search Posts', 'fa fa-fw fa-search', posts_path(forum_id: @forum.id) /= icon_link 'Search Posts', 'fa fa-fw fa-search', posts_path(forum_id: @forum.id)
span.spacing-left span.spacing-left
=> @forum.topic_count => @forum.topic_count

View file

@ -0,0 +1,85 @@
= form_for @changeset, Routes.forum_topic_path(@conn, :create, @forum), fn f ->
= if @changeset.action do
.alert.alert-danger
p Oops, something went wrong! Please check the errors below.
.block
.block__header.block__header--js-tabbed
a.selected href="#" data-click-tab="write"
i.fa.fa-pencil>
' Create a Topic
a href="#" data-click-tab="preview"
i.fa.fa-eye>
' Preview
.block__tab.communication-edit__tab.selected data-tab="write"
.field
= text_input f, :title, class: "input input--wide", placeholder: "Title"
= error_tag f, :title
= error_tag f, :slug
= inputs_for f, :posts, fn fp ->
.field
= textarea fp, :body, class: "input input--wide input--text js-preview-input js-toolbar-input", placeholder: "Please read the site rules before posting and use [spoiler][/spoiler] for NSFW stuff in SFW forums.", required: true
= error_tag fp, :body
.field
=> checkbox f, :anonymous
= label f, :anonymous, "Post anonymously"
= inputs_for f, :poll, fn fp ->
#add-poll
input.toggle-box id="add_poll" name="add_poll" type="checkbox"
label for="add_poll" Add a poll
.toggle-box-container
p
' Polls may have a maximum of
span.js-max-option-count> 20
' options. Leave any options you don't want to use blank.
' Only registered users will be able to vote.
.field.field--block
= text_input fp, :title, class: "input input--wide", placeholder: "Poll title", maxlength: 140
= error_tag fp, :title
p.fieldlabel
' End date
.field.field--block
= text_input fp, :until, class: "input input--wide", placeholder: "2 weeks from now", maxlength: 255
= error_tag fp, :until
= error_tag fp, :active_until
p.fieldlabel
' Specify when the poll should end. Once the poll ends, no more
' votes can be cast and the final results will be displayed. Good
' values to try are "1 week from now" and "24 hours from now". Polls
' must last for at least 24 hours.
p.fieldlabel
' Voting method:
.field.field--block
= select fp, :vote_method, ["-": "", "Single option": :single, "Multiple options": :multiple], class: "input"
= error_tag fp, :vote_method
= inputs_for fp, :options, fn fo ->
.field.js-poll-option.field--inline.flex--no-wrap.flex--centered
= text_input fo, :label, class: "input flex__grow js-option-label", placeholder: "Option"
= error_tag fo, :label
label.input--separate-left.flex__fixed.flex--centered
a.js-option-remove href="#"
i.fa.fa-trash>
' Delete
button.button.js-poll-add-option type="button"
i.fa.fa-plus>
' Add option
.block__tab.communication-edit__tab.hidden data-tab="preview"
' [Loading preview...]
.block__content.communication-edit__actions
= submit "Post", class: "button"

View file

@ -23,22 +23,23 @@
svg.poll-bar__image width=percent_of_total(option, @poll) height="100%" viewBox="0 0 1 1" preserveAspectRatio="none" svg.poll-bar__image width=percent_of_total(option, @poll) height="100%" viewBox="0 0 1 1" preserveAspectRatio="none"
rect class=poll_bar_class(option, winning, winners?) width="1" height="1" rect class=poll_bar_class(option, winning, winners?) width="1" height="1"
= if active?(@poll) do p
' Poll ends = if active?(@poll) do
= pretty_time(@poll.active_until) ' Poll ends
' . = pretty_time(@poll.active_until)
' .
= if @poll.total_votes > 0 do
=> @poll.total_votes
=> pluralize("vote", "votes", @poll.total_votes)
- else
' No votes have been
' cast so far.
= if @poll.total_votes > 0 do
=> @poll.total_votes
=> pluralize("vote", "votes", @poll.total_votes)
- else - else
' No votes have been ' Poll ended
' cast so far. => pretty_time(@poll.active_until)
' with
- else => @poll.total_votes
' Poll ended = pluralize("vote", "votes", @poll.total_votes)
=> pretty_time(@poll.active_until) ' .
' with
=> @poll.total_votes
= pluralize("vote", "votes", @poll.total_votes)
' .

View file

@ -13,10 +13,10 @@ defmodule PhilomenaWeb.Topic.PollView do
end end
def active?(poll) do def active?(poll) do
not poll.hidden_from_users and poll.active_until > DateTime.utc_now() not poll.hidden_from_users and DateTime.diff(poll.active_until, DateTime.utc_now()) > 0
end end
def percent_of_total(_option, %{total_votes: 0}), do: 0 def percent_of_total(_option, %{total_votes: 0}), do: "0%"
def percent_of_total(%{vote_count: vote_count}, %{total_votes: total_votes}) do def percent_of_total(%{vote_count: vote_count}, %{total_votes: total_votes}) do
:io_lib.format("~.2f%", [(vote_count / total_votes * 100)]) :io_lib.format("~.2f%", [(vote_count / total_votes * 100)])
end end

View file

@ -0,0 +1,87 @@
defmodule RelativeDate.Parser do
import NimbleParsec
time_specifier =
choice([
string("second") |> replace(1),
string("minute") |> replace(60),
string("hour") |> replace(3_600),
string("day") |> replace(86_400),
string("week") |> replace(604_800),
string("month") |> replace(2_592_000),
string("year") |> replace(31_536_000)
])
|> ignore(optional(string("s")))
direction_specifier =
choice([
string("ago") |> replace(-1),
string("from now") |> replace(1)
])
space = ignore(repeat(string(" ")))
moon =
space
|> string("moon")
|> concat(space)
|> eos()
|> unwrap_and_tag(:moon)
date =
space
|> integer(min: 1)
|> concat(space)
|> concat(time_specifier)
|> concat(space)
|> concat(direction_specifier)
|> concat(space)
|> eos()
|> tag(:relative_date)
relative_date =
choice([
moon,
date
])
defparsecp :relative_date, relative_date
def parse(input) do
input =
input
|> to_string()
|> String.trim()
case parse_absolute(input) do
{:ok, datetime} ->
{:ok, datetime}
_error ->
parse_relative(input)
end
end
def parse_absolute(input) do
case DateTime.from_iso8601(input) do
{:ok, datetime, _offset} ->
{:ok, datetime |> DateTime.truncate(:second)}
_error ->
{:error, "Parse error"}
end
end
def parse_relative(input) do
case relative_date(input) do
{:ok, [moon: _moon], _1, _2, _3, _4} ->
{:ok, DateTime.utc_now() |> DateTime.add(31_536_000_000, :second) |> DateTime.truncate(:second)}
{:ok, [relative_date: [amount, scale, direction]], _1, _2, _3, _4} ->
{:ok, DateTime.utc_now() |> DateTime.add(amount * scale * direction, :second) |> DateTime.truncate(:second)}
_error ->
{:error, "Parse error"}
end
end
end