implement UI for multiple image sources editing

This commit is contained in:
SeinopSys 2021-10-10 00:50:57 +02:00 committed by Luna D
parent 9bce2ca0a4
commit a4b85feadc
No known key found for this signature in database
GPG key ID: 4B1C63448394F688
14 changed files with 224 additions and 83 deletions

View file

@ -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 };

View file

@ -1,41 +1,11 @@
import { $, $$, removeEl} from './utils/dom'; import { inputDuplicatorCreator } from './input-duplicator';
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'); inputDuplicatorCreator({
addButtonSelector: '.js-poll-add-option',
delegate(document, 'click', { fieldSelector: '.js-poll-option',
'.js-option-remove': leftClick(pollOptionRemover) maxInputCountSelector: '.js-max-option-count',
}); removeButtonSelector: '.js-option-remove',
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);
}
}); });
} }

12
assets/js/sources.js Normal file
View file

@ -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 };

View file

@ -3,7 +3,7 @@
*/ */
import { fetchJson, handleError } from './utils/requests'; 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'; import { addTag } from './tagsinput';
const MATROSKA_MAGIC = 0x1a45dfa3; const MATROSKA_MAGIC = 0x1a45dfa3;
@ -31,8 +31,10 @@ function setupImageUpload() {
if (!imgPreviews) return; if (!imgPreviews) return;
const form = imgPreviews.closest('form'); const form = imgPreviews.closest('form');
const [ fileField, remoteUrl, scraperError ] = $$('.js-scraper', form); const [fileField, remoteUrl, scraperError] = $$('.js-scraper', form);
const [ sourceEl, tagsEl, descrEl ] = $$('.js-image-input', 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'); const fetchButton = $('#js-scraper-preview');
if (!fetchButton) return; if (!fetchButton) return;

View file

@ -34,6 +34,18 @@ export function clearEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>
}); });
} }
export function disableEl<E extends HTMLInputElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => {
el.disabled = true;
});
}
export function enableEl<E extends HTMLInputElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => {
el.disabled = false;
});
}
export function removeEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) { export function removeEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => el.parentNode?.removeChild(el)); ([] as E[]).concat(...elements).forEach(el => el.parentNode?.removeChild(el));
} }

View file

@ -35,6 +35,7 @@ import { setupToolbar } from './markdowntoolbar';
import { hideStaffTools } from './staffhider'; import { hideStaffTools } from './staffhider';
import { pollOptionCreator } from './poll'; import { pollOptionCreator } from './poll';
import { warnAboutPMs } from './pmwarning'; import { warnAboutPMs } from './pmwarning';
import { imageSourcesCreator } from './sources.js';
whenReady(() => { whenReady(() => {
@ -68,5 +69,6 @@ whenReady(() => {
hideStaffTools(); hideStaffTools();
pollOptionCreator(); pollOptionCreator();
warnAboutPMs(); warnAboutPMs();
imageSourcesCreator();
}); });

View file

@ -77,10 +77,12 @@ defmodule Philomena.Images do
""" """
def create_image(attribution, attrs \\ %{}) do def create_image(attribution, attrs \\ %{}) do
tags = Tags.get_or_create_tags(attrs["tag_input"]) tags = Tags.get_or_create_tags(attrs["tag_input"])
sources = attrs["sources"]
image = image =
%Image{} %Image{}
|> Image.creation_changeset(attrs, attribution) |> Image.creation_changeset(attrs, attribution)
|> Image.source_changeset(attrs, [], sources)
|> Image.tag_changeset(attrs, [], tags) |> Image.tag_changeset(attrs, [], tags)
|> Image.dnp_changeset(attribution[:user]) |> Image.dnp_changeset(attribution[:user])
|> Uploader.analyze_upload(attrs) |> Uploader.analyze_upload(attrs)
@ -329,11 +331,13 @@ defmodule Philomena.Images do
end end
def update_sources(%Image{} = image, attribution, attrs) do def update_sources(%Image{} = image, attribution, attrs) do
old_sources = attrs["old_source_input"] old_sources = attrs["old_sources"]
new_sources = attrs["source_input"] new_sources = attrs["sources"]
Multi.new() Multi.new()
|> Multi.run(:image, fn repo, _chg -> |> Multi.run(
:image,
fn repo, _chg ->
image = repo.preload(image, [:sources]) image = repo.preload(image, [:sources])
image image

View file

@ -9,6 +9,7 @@ defmodule PhilomenaWeb.ImageController do
alias Philomena.{ alias Philomena.{
Images, Images,
Images.Image, Images.Image,
Images.Source,
Comments.Comment, Comments.Comment,
Galleries.Gallery Galleries.Gallery
} }
@ -108,7 +109,7 @@ defmodule PhilomenaWeb.ImageController do
def new(conn, _params) do def new(conn, _params) do
changeset = changeset =
%Image{} %Image{sources: [%Source{}]}
|> Images.change_image() |> Images.change_image()
render(conn, "new.html", title: "New Image", changeset: changeset) render(conn, "new.html", title: "New Image", changeset: changeset)

View file

@ -1,4 +1,5 @@
.block .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 -> = 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 can?(@conn, :edit_metadata, @image) and !@conn.assigns.current_ban do
@ -6,9 +7,28 @@
.alert.alert-danger .alert.alert-danger
p Oops, something went wrong! Please check the errors below. p Oops, something went wrong! Please check the errors below.
.flex p
= url_input f, :source_url, id: "source-field", class: "input input--wide", autocomplete: "off", placeholder: "Source URL" 'The page(s) you found this image on. Images may have a maximum of
= submit "Save source", class: "button button--separate-left" 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" button.button.button--separate-left type="reset" data-click-hide="#source-form" data-click-show="#image-source"
' Cancel ' Cancel
@ -18,14 +38,17 @@
p p
' You can't edit the source on this image. ' You can't edit the source on this image.
.flex.flex--wrap#image-source .flex.flex--wrap.flex--centered#image-source
p 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> i.fas.fa-edit>
= if !has_sources || length(@image.sources) == 1 do
' Source: ' Source:
- else
' Sources:
p p
= if Enum.any?(@image.sources) do = if has_sources do
= for source <- @image.sources do = for source <- @image.sources do
a.js-source-link href=source.source a.js-source-link href=source.source
strong= source.source strong= source.source

View file

@ -38,9 +38,27 @@
.field-error-js.hidden.js-scraper .field-error-js.hidden.js-scraper
h4 About this image 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 .field
= label f, :source_url, "The page you found this image on" button.button.js-image-add-source type="button"
= url_input f, :source_url, class: "input input--wide js-image-input", placeholder: "Source URL" i.fa.fa-plus>
' Add source
.field .field
label for="image[tag_input]" label for="image[tag_input]"
@ -65,7 +83,7 @@
.field .field
.block .block
.communication-edit__wrap .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" = render PhilomenaWeb.MarkdownView, "_anon_checkbox.html", conn: @conn, f: f, label: "Post anonymously"

View file

@ -1,6 +1,6 @@
elixir: elixir:
textarea_options = [ 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", placeholder: "Add tags separated with commas",
autocomplete: "off" autocomplete: "off"
] ]

View file

@ -50,13 +50,13 @@ defmodule Philomena.Repo.Migrations.RewriteSourceChanges do
) )
create constraint(:image_sources, :image_sources_source_check, create constraint(:image_sources, :image_sources_source_check,
check: "substr(source, 1, 7) = 'http://' or substr(source, 1, 8) = 'https://'" check: "source ~* '^https?://'"
) )
execute(""" execute("""
insert into image_sources (image_id, source) insert into image_sources (image_id, source)
select id as image_id, substr(source_url, 1, 255) as source from images 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... # First insert the "added" changes...
@ -123,7 +123,7 @@ defmodule Philomena.Repo.Migrations.RewriteSourceChanges do
execute("truncate image_sources") execute("truncate image_sources")
drop constraint(:image_sources, :image_sources_source_check, 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, create constraint(:image_sources, :length_must_be_valid,

View file

@ -18,35 +18,48 @@
"role": "user" "role": "user"
} }
], ],
"remote_images": [{ "remote_images": [
{
"url": "https://derpicdn.net/img/2015/9/26/988000/thumb.gif", "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.", "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" "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", "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" "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", "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" "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", "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" "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", "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" "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", "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" "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"
} }
], ],

View file

@ -841,10 +841,11 @@ ALTER SEQUENCE public.image_intensities_id_seq OWNED BY public.image_intensities
-- Name: image_sources; Type: TABLE; Schema: public; Owner: - -- Name: image_sources; Type: TABLE; Schema: public; Owner: -
-- --
CREATE TABLE public.image_sources ( CREATE TABLE public.image_sources(
image_id bigint NOT NULL, image_id bigint NOT NULL,
source character varying(255) 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))) CONSTRAINT image_sources_source_check CHECK (((source)::text ~* '^https?://'::text
) )
); );