mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-27 13:47:58 +01:00
implement UI for multiple image sources editing
This commit is contained in:
parent
9bce2ca0a4
commit
a4b85feadc
14 changed files with 224 additions and 83 deletions
83
assets/js/input-duplicator.js
Normal file
83
assets/js/input-duplicator.js
Normal 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 };
|
|
@ -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
12
assets/js/sources.js
Normal 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 };
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
});
|
||||
|
|
|
@ -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,11 +331,13 @@ 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 ->
|
||||
|> Multi.run(
|
||||
:image,
|
||||
fn repo, _chg ->
|
||||
image = repo.preload(image, [:sources])
|
||||
|
||||
image
|
||||
|
|
|
@ -9,6 +9,7 @@ defmodule PhilomenaWeb.ImageController do
|
|||
alias Philomena.{
|
||||
Images,
|
||||
Images.Image,
|
||||
Images.Source,
|
||||
Comments.Comment,
|
||||
Galleries.Gallery
|
||||
}
|
||||
|
@ -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)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
.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
|
||||
|
||||
|
@ -6,9 +7,28 @@
|
|||
.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>
|
||||
= 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
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
],
|
||||
|
|
|
@ -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 (
|
||||
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)))
|
||||
CONSTRAINT image_sources_source_check CHECK (((source)::text ~* '^https?://'::text
|
||||
) )
|
||||
);
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue