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 { 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',
});
}

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 { $, $$, 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;

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>[]) {
([] as E[]).concat(...elements).forEach(el => el.parentNode?.removeChild(el));
}

View file

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

View file

@ -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 =

View file

@ -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)

View file

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

View file

@ -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"

View file

@ -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"
]

View file

@ -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,

View file

@ -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"
}
],

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: -
--
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
) )
);