mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-27 13:47:58 +01:00
topics/polls forms, relative dates
This commit is contained in:
parent
3ee6c07609
commit
d6e07b8316
17 changed files with 449 additions and 63 deletions
|
@ -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++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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-")
|
||||||
|
|
|
@ -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 """
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -6,7 +6,10 @@ h1 = @forum.name
|
||||||
=> link("Forums", to: Routes.forum_path(@conn, :index))
|
=> link("Forums", to: Routes.forum_path(@conn, :index))
|
||||||
' »
|
' »
|
||||||
=> 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
|
||||||
|
|
85
lib/philomena_web/templates/topic/new.html.slime
Normal file
85
lib/philomena_web/templates/topic/new.html.slime
Normal 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"
|
|
@ -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)
|
|
||||||
' .
|
|
|
@ -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
|
||||||
|
|
87
lib/relative_date/parser.ex
Normal file
87
lib/relative_date/parser.ex
Normal 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
|
Loading…
Reference in a new issue