diff --git a/assets/js/input-duplicator.js b/assets/js/input-duplicator.js new file mode 100644 index 00000000..2ffa89bc --- /dev/null +++ b/assets/js/input-duplicator.js @@ -0,0 +1,83 @@ +import { $, $$, disableEl, enableEl, removeEl } from './utils/dom'; +import { delegate, leftClick } from './utils/events'; + +/** + * @typedef InputDuplicatorOptions + * @property {string} addButtonSelector + * @property {string} fieldSelector + * @property {string} maxInputCountSelector + * @property {string} removeButtonSelector + */ + +/** + * @param {InputDuplicatorOptions} options + */ +function inputDuplicatorCreator({ + addButtonSelector, + fieldSelector, + maxInputCountSelector, + removeButtonSelector +}) { + const addButton = $(addButtonSelector); + if (!addButton) { + return; + } + + const form = addButton.closest('form'); + const fieldRemover = (event, target) => { + event.preventDefault(); + + // Prevent removing the final field element to not "brick" the form + const existingFields = $$(fieldSelector, form); + if (existingFields.length <= 1) { + return; + } + + removeEl(target.closest(fieldSelector)); + enableEl(addButton); + }; + + delegate(document, 'click', { + [removeButtonSelector]: leftClick(fieldRemover) + }); + + + const maxOptionCount = parseInt($(maxInputCountSelector, form).innerHTML, 10); + addButton.addEventListener('click', e => { + e.preventDefault(); + + const existingFields = $$(fieldSelector, form); + let existingFieldsLength = existingFields.length; + if (existingFieldsLength < maxOptionCount) { + // The last element matched by the `fieldSelector` will be the last field, make a copy + const prevField = existingFields[existingFieldsLength - 1]; + const prevFieldCopy = prevField.cloneNode(true); + const prevFieldCopyInputs = $$('input', prevFieldCopy); + prevFieldCopyInputs.forEach(prevFieldCopyInput => { + // Reset new input's value + prevFieldCopyInput.value = ''; + prevFieldCopyInput.removeAttribute('value'); + // Increment sequential attributes of the input + ['name', 'id'].forEach(attr => { + prevFieldCopyInput.setAttribute(attr, prevFieldCopyInput[attr].replace(/\d+/g, `${existingFieldsLength}`)); + }); + }); + + // Insert copy before the last field's next sibling, or if none, at the end of its parent + if (prevField.nextElementSibling) { + prevField.parentNode.insertBefore(prevFieldCopy, prevField.nextElementSibling); + } + else { + prevField.parentNode.appendChild(prevFieldCopy); + } + existingFieldsLength++; + } + + // Remove the button if we reached the max number of options + if (existingFieldsLength >= maxOptionCount) { + disableEl(addButton); + } + }); +} + +export { inputDuplicatorCreator }; diff --git a/assets/js/poll.js b/assets/js/poll.js index 0687bff3..68debf5d 100644 --- a/assets/js/poll.js +++ b/assets/js/poll.js @@ -1,41 +1,11 @@ -import { $, $$, removeEl} from './utils/dom'; -import { delegate, leftClick } from './utils/events'; - -function pollOptionRemover(_event, target) { - removeEl(target.closest('.js-poll-option')); -} +import { inputDuplicatorCreator } from './input-duplicator'; function pollOptionCreator() { - const addPollOptionButton = $('.js-poll-add-option'); - - delegate(document, 'click', { - '.js-option-remove': leftClick(pollOptionRemover) - }); - - if (!addPollOptionButton) { - return; - } - - const form = addPollOptionButton.closest('form'); - const maxOptionCount = parseInt($('.js-max-option-count', form).innerHTML, 10); - addPollOptionButton.addEventListener('click', e => { - e.preventDefault(); - - let existingOptionCount = $$('.js-poll-option', form).length; - if (existingOptionCount < maxOptionCount) { - // The element right before the add button will always be the last field, make a copy - const prevFieldCopy = addPollOptionButton.previousElementSibling.cloneNode(true); - const newHtml = prevFieldCopy.outerHTML.replace(/(\d+)/g, `${existingOptionCount}`); - - // Insert copy before the button - addPollOptionButton.insertAdjacentHTML('beforebegin', newHtml); - existingOptionCount++; - } - - // Remove the button if we reached the max number of options - if (existingOptionCount >= maxOptionCount) { - removeEl(addPollOptionButton); - } + inputDuplicatorCreator({ + addButtonSelector: '.js-poll-add-option', + fieldSelector: '.js-poll-option', + maxInputCountSelector: '.js-max-option-count', + removeButtonSelector: '.js-option-remove', }); } diff --git a/assets/js/sources.js b/assets/js/sources.js new file mode 100644 index 00000000..210382fc --- /dev/null +++ b/assets/js/sources.js @@ -0,0 +1,12 @@ +import { inputDuplicatorCreator } from './input-duplicator'; + +function imageSourcesCreator() { + inputDuplicatorCreator({ + addButtonSelector: '.js-image-add-source', + fieldSelector: '.js-image-source', + maxInputCountSelector: '.js-max-source-count', + removeButtonSelector: '.js-source-remove', + }); +} + +export { imageSourcesCreator }; diff --git a/assets/js/upload.js b/assets/js/upload.js index 8f00903f..f8800b73 100644 --- a/assets/js/upload.js +++ b/assets/js/upload.js @@ -3,7 +3,7 @@ */ import { fetchJson, handleError } from './utils/requests'; -import { $, $$, hideEl, showEl, makeEl, clearEl } from './utils/dom'; +import { $, $$, clearEl, hideEl, makeEl, showEl } from './utils/dom'; import { addTag } from './tagsinput'; const MATROSKA_MAGIC = 0x1a45dfa3; @@ -31,8 +31,10 @@ function setupImageUpload() { if (!imgPreviews) return; const form = imgPreviews.closest('form'); - const [ fileField, remoteUrl, scraperError ] = $$('.js-scraper', form); - const [ sourceEl, tagsEl, descrEl ] = $$('.js-image-input', form); + const [fileField, remoteUrl, scraperError] = $$('.js-scraper', form); + const descrEl = $('.js-image-descr-input', form); + const tagsEl = $('.js-image-tags-input', form); + const sourceEl = $$('.js-image-source', form).find(input => input.value === ''); const fetchButton = $('#js-scraper-preview'); if (!fetchButton) return; diff --git a/assets/js/utils/dom.ts b/assets/js/utils/dom.ts index 34bfe02b..06a1dd3e 100644 --- a/assets/js/utils/dom.ts +++ b/assets/js/utils/dom.ts @@ -34,6 +34,18 @@ export function clearEl(...elements: E[] | ConcatArray }); } +export function disableEl(...elements: E[] | ConcatArray[]) { + ([] as E[]).concat(...elements).forEach(el => { + el.disabled = true; + }); +} + +export function enableEl(...elements: E[] | ConcatArray[]) { + ([] as E[]).concat(...elements).forEach(el => { + el.disabled = false; + }); +} + export function removeEl(...elements: E[] | ConcatArray[]) { ([] as E[]).concat(...elements).forEach(el => el.parentNode?.removeChild(el)); } diff --git a/assets/js/when-ready.js b/assets/js/when-ready.js index eed3082a..2201819c 100644 --- a/assets/js/when-ready.js +++ b/assets/js/when-ready.js @@ -2,10 +2,10 @@ * Functions to execute when the DOM is ready */ -import { whenReady } from './utils/dom'; +import { whenReady } from './utils/dom'; -import { showOwnedComments } from './communications/comment'; -import { showOwnedPosts } from './communications/post'; +import { showOwnedComments } from './communications/comment'; +import { showOwnedPosts } from './communications/post'; import { listenAutocomplete } from './autocomplete'; import { loadBooruData } from './booru'; @@ -35,6 +35,7 @@ import { setupToolbar } from './markdowntoolbar'; import { hideStaffTools } from './staffhider'; import { pollOptionCreator } from './poll'; import { warnAboutPMs } from './pmwarning'; +import { imageSourcesCreator } from './sources.js'; whenReady(() => { @@ -68,5 +69,6 @@ whenReady(() => { hideStaffTools(); pollOptionCreator(); warnAboutPMs(); + imageSourcesCreator(); }); diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index 7f823bf6..4888d67f 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -77,10 +77,12 @@ defmodule Philomena.Images do """ def create_image(attribution, attrs \\ %{}) do tags = Tags.get_or_create_tags(attrs["tag_input"]) + sources = attrs["sources"] image = %Image{} |> Image.creation_changeset(attrs, attribution) + |> Image.source_changeset(attrs, [], sources) |> Image.tag_changeset(attrs, [], tags) |> Image.dnp_changeset(attribution[:user]) |> Uploader.analyze_upload(attrs) @@ -329,23 +331,25 @@ defmodule Philomena.Images do end def update_sources(%Image{} = image, attribution, attrs) do - old_sources = attrs["old_source_input"] - new_sources = attrs["source_input"] + old_sources = attrs["old_sources"] + new_sources = attrs["sources"] Multi.new() - |> Multi.run(:image, fn repo, _chg -> - image = repo.preload(image, [:sources]) + |> Multi.run( + :image, + fn repo, _chg -> + image = repo.preload(image, [:sources]) - image - |> Image.source_changeset(%{}, old_sources, new_sources) - |> repo.update() - |> case do - {:ok, image} -> - {:ok, {image, image.added_sources, image.removed_sources}} + image + |> Image.source_changeset(%{}, old_sources, new_sources) + |> repo.update() + |> case do + {:ok, image} -> + {:ok, {image, image.added_sources, image.removed_sources}} - error -> - error - end + error -> + error + end end) |> Multi.run(:added_source_changes, fn repo, %{image: {image, added_sources, _removed}} -> source_changes = diff --git a/lib/philomena_web/controllers/image_controller.ex b/lib/philomena_web/controllers/image_controller.ex index 5200deba..c7743ef0 100644 --- a/lib/philomena_web/controllers/image_controller.ex +++ b/lib/philomena_web/controllers/image_controller.ex @@ -9,9 +9,10 @@ defmodule PhilomenaWeb.ImageController do alias Philomena.{ Images, Images.Image, + Images.Source, Comments.Comment, Galleries.Gallery - } + } alias Philomena.Elasticsearch alias Philomena.Interactions @@ -108,7 +109,7 @@ defmodule PhilomenaWeb.ImageController do def new(conn, _params) do changeset = - %Image{} + %Image{sources: [%Source{}]} |> Images.change_image() render(conn, "new.html", title: "New Image", changeset: changeset) diff --git a/lib/philomena_web/templates/image/_source.html.slime b/lib/philomena_web/templates/image/_source.html.slime index 379bd99c..97f37d42 100644 --- a/lib/philomena_web/templates/image/_source.html.slime +++ b/lib/philomena_web/templates/image/_source.html.slime @@ -1,14 +1,34 @@ .block + - has_sources = Enum.any?(@image.sources) = form_for @changeset, Routes.image_source_path(@conn, :update, @image), [method: "put", class: "hidden", id: "source-form", data: [remote: "true"]], fn f -> = if can?(@conn, :edit_metadata, @image) and !@conn.assigns.current_ban do = if @changeset.action do .alert.alert-danger p Oops, something went wrong! Please check the errors below. - - .flex - = url_input f, :source_url, id: "source-field", class: "input input--wide", autocomplete: "off", placeholder: "Source URL" - = submit "Save source", class: "button button--separate-left" + + p + 'The page(s) you found this image on. Images may have a maximum of + span.js-max-source-count> 10 + ' source URLs. Leave any sources you don't want to use blank. + + = inputs_for f, :sources, [as: "image[old_sources]", skip_hidden: true], fn fs -> + = hidden_input fs, :source + = inputs_for f, :sources, [skip_hidden: true], fn fs -> + .field.js-image-source.field--inline.flex--no-wrap.flex--centered + = text_input fs, :source, class: "input flex__grow js-source-url", placeholder: "Source URL" + = error_tag fs, :source + + label.input--separate-left.flex__fixed.flex--centered + a.js-source-remove href="#" + i.fa.fa-trash> + ' Delete + + .field + button.button.js-image-add-source type="button" + i.fa.fa-plus> + ' Add source + = submit "Save sources", class: "button button--separate-left" button.button.button--separate-left type="reset" data-click-hide="#source-form" data-click-show="#image-source" ' Cancel @@ -18,14 +38,17 @@ p ' You can't edit the source on this image. - .flex.flex--wrap#image-source + .flex.flex--wrap.flex--centered#image-source p - a.button.button--separate-right#edit-source data-click-focus="#source-field" data-click-hide="#image-source" data-click-show="#source-form" title="Edit source" accessKey="s" + a.button.button--separate-right#edit-source data-click-focus=".js-image-source" data-click-hide="#image-source" data-click-show="#source-form" title="Edit source" accessKey="s" i.fas.fa-edit> - ' Source: + = if !has_sources || length(@image.sources) == 1 do + ' Source: + - else + ' Sources: p - = if Enum.any?(@image.sources) do + = if has_sources do = for source <- @image.sources do a.js-source-link href=source.source strong= source.source diff --git a/lib/philomena_web/templates/image/new.html.slime b/lib/philomena_web/templates/image/new.html.slime index e9db71c4..c23e04e3 100644 --- a/lib/philomena_web/templates/image/new.html.slime +++ b/lib/philomena_web/templates/image/new.html.slime @@ -38,9 +38,27 @@ .field-error-js.hidden.js-scraper h4 About this image + p + 'The page(s) you found this image on. Images may have a maximum of + span.js-max-source-count> 10 + ' source URLs. Leave any sources you don't want to use blank. + + = inputs_for f, :sources, [as: "image[old_sources]"], fn fs -> + = hidden_input fs, :source + = inputs_for f, :sources, fn fs -> + .field.js-image-source.field--inline.flex--no-wrap.flex--centered + = text_input fs, :source, class: "input flex__grow js-source-url", placeholder: "Source URL" + = error_tag fs, :source + + label.input--separate-left.flex__fixed.flex--centered + a.js-source-remove href="#" + i.fa.fa-trash> + ' Delete + .field - = label f, :source_url, "The page you found this image on" - = url_input f, :source_url, class: "input input--wide js-image-input", placeholder: "Source URL" + button.button.js-image-add-source type="button" + i.fa.fa-plus> + ' Add source .field label for="image[tag_input]" @@ -65,7 +83,7 @@ .field .block .communication-edit__wrap - = render PhilomenaWeb.MarkdownView, "_input.html", conn: @conn, f: f, action_icon: "pencil-alt", action_text: "Description", placeholder: "Describe this image in plain words - this should generally be info about the image that doesn't belong in the tags or source.", name: :description, class: "js-image-input", required: false + = render PhilomenaWeb.MarkdownView, "_input.html", conn: @conn, f: f, action_icon: "pencil-alt", action_text: "Description", placeholder: "Describe this image in plain words - this should generally be info about the image that doesn't belong in the tags or source.", name: :description, class: "js-image-descr-input", required: false = render PhilomenaWeb.MarkdownView, "_anon_checkbox.html", conn: @conn, f: f, label: "Post anonymously" diff --git a/lib/philomena_web/templates/tag/_tag_editor.html.slime b/lib/philomena_web/templates/tag/_tag_editor.html.slime index 3f4d43db..0bb692fe 100644 --- a/lib/philomena_web/templates/tag/_tag_editor.html.slime +++ b/lib/philomena_web/templates/tag/_tag_editor.html.slime @@ -1,6 +1,6 @@ elixir: textarea_options = [ - class: "input input--wide tagsinput js-image-input js-taginput js-taginput-plain js-taginput-#{@name}", + class: "input input--wide tagsinput js-image-tags-input js-taginput js-taginput-plain js-taginput-#{@name}", placeholder: "Add tags separated with commas", autocomplete: "off" ] diff --git a/priv/repo/migrations/20211009011024_rewrite_source_changes.exs b/priv/repo/migrations/20211009011024_rewrite_source_changes.exs index 9fa9e3c2..52b3cfd4 100644 --- a/priv/repo/migrations/20211009011024_rewrite_source_changes.exs +++ b/priv/repo/migrations/20211009011024_rewrite_source_changes.exs @@ -50,13 +50,13 @@ defmodule Philomena.Repo.Migrations.RewriteSourceChanges do ) create constraint(:image_sources, :image_sources_source_check, - check: "substr(source, 1, 7) = 'http://' or substr(source, 1, 8) = 'https://'" + check: "source ~* '^https?://'" ) execute(""" insert into image_sources (image_id, source) select id as image_id, substr(source_url, 1, 255) as source from images - where source_url is not null and (substr(source_url, 1, 7) = 'http://' or substr(source_url, 1, 8) = 'https://'); + where source_url is not null and source_url ~* '^https?://'; """) # First insert the "added" changes... @@ -123,7 +123,7 @@ defmodule Philomena.Repo.Migrations.RewriteSourceChanges do execute("truncate image_sources") drop constraint(:image_sources, :image_sources_source_check, - check: "substr(source, 1, 7) = 'http://' or substr(source, 1, 8) = 'https://'" + check: "source ~* '^https?://'" ) create constraint(:image_sources, :length_must_be_valid, diff --git a/priv/repo/seeds_development.json b/priv/repo/seeds_development.json index a31b420a..47625a51 100644 --- a/priv/repo/seeds_development.json +++ b/priv/repo/seeds_development.json @@ -18,35 +18,48 @@ "role": "user" } ], - "remote_images": [{ + "remote_images": [ + { "url": "https://derpicdn.net/img/2015/9/26/988000/thumb.gif", - "source_url": "https://derpibooru.org/988000", + "sources": [ + "https://derpibooru.org/988000" + ], "description": "Fairly large GIF (~23MB), use to test WebM stuff.", "tag_input": "alicorn, angry, animated, art, artist:assasinmonkey, artist:equum_amici, badass, barrier, crying, dark, epic, female, fight, force field, glare, glow, good vs evil, lord tirek, low angle, magic, mare, messy mane, metal as fuck, perspective, plot, pony, raised hoof, safe, size difference, spread wings, stomping, twilight's kingdom, twilight sparkle, twilight sparkle (alicorn), twilight vs tirek, underhoof" }, { "url": "https://derpicdn.net/img/2012/1/2/25/large.png", - "source_url": "https://derpibooru.org/25", + "sources": [ + "https://derpibooru.org/25" + ], "tag_input": "artist:moe, canterlot, castle, cliff, cloud, detailed background, fog, forest, grass, mountain, mountain range, nature, no pony, outdoors, path, river, safe, scenery, scenery porn, signature, source needed, sunset, technical advanced, town, tree, useless source url, water, waterfall, widescreen, wood" }, { "url": "https://derpicdn.net/img/2018/6/28/1767886/full.webm", - "source_url": "http://hydrusbeta.deviantart.com/art/Gleaming-in-the-Sun-Our-Colors-Shine-in-Every-Hue-611497309", + "sources": [ + "http://hydrusbeta.deviantart.com/art/Gleaming-in-the-Sun-Our-Colors-Shine-in-Every-Hue-611497309" + ], "tag_input": "3d, animated, architecture, artist:hydrusbeta, castle, cloud, crystal empire, crystal palace, flag, flag waving, no pony, no sound, safe, scenery, webm" }, { "url": "https://derpicdn.net/img/view/2015/2/19/832750.jpg", - "source_url": "http://sovietrussianbrony.tumblr.com/post/111504505079/this-image-actually-took-me-ages-to-edit-the", + "sources": [ + "http://sovietrussianbrony.tumblr.com/post/111504505079/this-image-actually-took-me-ages-to-edit-the" + ], "tag_input": "artist:rhads, artist:the sexy assistant, canterlot, cloud, cloudsdale, cloudy, edit, lens flare, no pony, ponyville, rainbow, river, safe, scenery, sweet apple acres" }, { "url": "https://derpicdn.net/img/view/2016/3/17/1110529.jpg", - "source_url": "https://www.deviantart.com/devinian/art/Commission-Crystals-of-thy-heart-511134926", + "sources": [ + "https://www.deviantart.com/devinian/art/Commission-Crystals-of-thy-heart-511134926" + ], "tag_input": "artist:devinian, aurora crystialis, bridge, cloud, crepuscular rays, crystal empire, crystal palace, edit, flower, forest, grass, log, mountain, no pony, river, road, safe, scenery, scenery porn, source needed, stars, sunset, swing, tree, wallpaper" }, { "url": "https://derpicdn.net/img/view/2019/6/16/2067468.svg", - "source_url": "https://derpibooru.org/2067468", + "sources": [ + "https://derpibooru.org/2067468" + ], "tag_input": "artist:cheezedoodle96, babs seed, bloom and gloom, cutie mark, cutie mark only, no pony, safe, scissors, simple background, svg, .svg available, transparent background, vector" } ], diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index d41d1d6f..0bf866c2 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -841,10 +841,11 @@ ALTER SEQUENCE public.image_intensities_id_seq OWNED BY public.image_intensities -- Name: image_sources; Type: TABLE; Schema: public; Owner: - -- -CREATE TABLE public.image_sources ( - image_id bigint NOT NULL, - source character varying(255) NOT NULL, - CONSTRAINT image_sources_source_check CHECK (((substr((source)::text, 1, 7) = 'http://'::text) OR (substr((source)::text, 1, 8) = 'https://'::text))) +CREATE TABLE public.image_sources( + image_id bigint NOT NULL, + source character varying(255) NOT NULL, + CONSTRAINT image_sources_source_check CHECK (((source)::text ~* '^https?://'::text +) ) );