mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-27 21:47:59 +01:00
Merge remote-tracking branch 'origin/master' into redesign
This commit is contained in:
commit
db237a9853
20 changed files with 509 additions and 132 deletions
13
assets/fix-jsdom.ts
Normal file
13
assets/fix-jsdom.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import JSDOMEnvironment from 'jest-environment-jsdom';
|
||||||
|
|
||||||
|
export default class FixJSDOMEnvironment extends JSDOMEnvironment {
|
||||||
|
constructor(...args: ConstructorParameters<typeof JSDOMEnvironment>) {
|
||||||
|
super(...args);
|
||||||
|
|
||||||
|
// https://github.com/jsdom/jsdom/issues/1721#issuecomment-1484202038
|
||||||
|
// jsdom URL and Blob are missing most of the implementation
|
||||||
|
// Use the node version of these types instead
|
||||||
|
this.global.URL = URL;
|
||||||
|
this.global.Blob = Blob;
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,7 +25,7 @@ export default {
|
||||||
},
|
},
|
||||||
preset: 'ts-jest/presets/js-with-ts-esm',
|
preset: 'ts-jest/presets/js-with-ts-esm',
|
||||||
setupFilesAfterEnv: ['<rootDir>/test/jest-setup.ts'],
|
setupFilesAfterEnv: ['<rootDir>/test/jest-setup.ts'],
|
||||||
testEnvironment: 'jsdom',
|
testEnvironment: './fix-jsdom.ts',
|
||||||
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
|
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'./js/(.*)': '<rootDir>/js/$1',
|
'./js/(.*)': '<rootDir>/js/$1',
|
||||||
|
|
91
assets/js/__tests__/input-duplicator.spec.ts
Normal file
91
assets/js/__tests__/input-duplicator.spec.ts
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
import { inputDuplicatorCreator } from '../input-duplicator';
|
||||||
|
import { assertNotNull } from '../utils/assert';
|
||||||
|
import { $, $$, removeEl } from '../utils/dom';
|
||||||
|
|
||||||
|
describe('Input duplicator functionality', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.documentElement.insertAdjacentHTML('beforeend', `<form action="/">
|
||||||
|
<div class="js-max-input-count">3</div>
|
||||||
|
<div class="js-input-source">
|
||||||
|
<input id="0" name="0" class="js-input" type="text"/>
|
||||||
|
<label>
|
||||||
|
<a href="#" class="js-remove-input">Delete</a>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="js-button-container">
|
||||||
|
<button type="button" class="js-add-input">Add input</button>
|
||||||
|
</div>
|
||||||
|
</form>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
removeEl($$<HTMLFormElement>('form'));
|
||||||
|
});
|
||||||
|
|
||||||
|
function runCreator() {
|
||||||
|
inputDuplicatorCreator({
|
||||||
|
addButtonSelector: '.js-add-input',
|
||||||
|
fieldSelector: '.js-input-source',
|
||||||
|
maxInputCountSelector: '.js-max-input-count',
|
||||||
|
removeButtonSelector: '.js-remove-input',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should ignore forms without a duplicator button', () => {
|
||||||
|
removeEl($$<HTMLButtonElement>('button'));
|
||||||
|
expect(runCreator()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should duplicate the input elements', () => {
|
||||||
|
runCreator();
|
||||||
|
|
||||||
|
expect($$('input')).toHaveLength(1);
|
||||||
|
|
||||||
|
assertNotNull($<HTMLButtonElement>('.js-add-input')).click();
|
||||||
|
|
||||||
|
expect($$('input')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should duplicate the input elements when the button is before the inputs', () => {
|
||||||
|
const form = assertNotNull($<HTMLFormElement>('form'));
|
||||||
|
const buttonDiv = assertNotNull($<HTMLDivElement>('.js-button-container'));
|
||||||
|
removeEl(buttonDiv);
|
||||||
|
form.insertAdjacentElement('afterbegin', buttonDiv);
|
||||||
|
runCreator();
|
||||||
|
|
||||||
|
assertNotNull($<HTMLButtonElement>('.js-add-input')).click();
|
||||||
|
|
||||||
|
expect($$('input')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not create more input elements than the limit', () => {
|
||||||
|
runCreator();
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
assertNotNull($<HTMLButtonElement>('.js-add-input')).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($$('input')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove duplicated input elements', () => {
|
||||||
|
runCreator();
|
||||||
|
|
||||||
|
assertNotNull($<HTMLButtonElement>('.js-add-input')).click();
|
||||||
|
assertNotNull($<HTMLAnchorElement>('.js-remove-input')).click();
|
||||||
|
|
||||||
|
expect($$('input')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not remove the last input element', () => {
|
||||||
|
runCreator();
|
||||||
|
|
||||||
|
assertNotNull($<HTMLAnchorElement>('.js-remove-input')).click();
|
||||||
|
assertNotNull($<HTMLAnchorElement>('.js-remove-input')).click();
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
assertNotNull($<HTMLAnchorElement>('.js-remove-input')).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($$('input')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,5 +1,5 @@
|
||||||
import fetchMock from 'jest-fetch-mock';
|
import fetchMock from 'jest-fetch-mock';
|
||||||
import { fireEvent } from '@testing-library/dom';
|
import { fireEvent, waitFor } from '@testing-library/dom';
|
||||||
import { assertType } from '../utils/assert';
|
import { assertType } from '../utils/assert';
|
||||||
import '../ujs';
|
import '../ujs';
|
||||||
|
|
||||||
|
@ -117,6 +117,7 @@ describe('Remote utilities', () => {
|
||||||
// https://www.benmvp.com/blog/mocking-window-location-methods-jest-jsdom/
|
// https://www.benmvp.com/blog/mocking-window-location-methods-jest-jsdom/
|
||||||
let oldWindowLocation: Location;
|
let oldWindowLocation: Location;
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
oldWindowLocation = window.location;
|
oldWindowLocation = window.location;
|
||||||
delete (window as any).location;
|
delete (window as any).location;
|
||||||
|
@ -136,6 +137,7 @@ describe('Remote utilities', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
(window.location.reload as any).mockReset();
|
(window.location.reload as any).mockReset();
|
||||||
});
|
});
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
// restore window.location to the jsdom Location object
|
// restore window.location to the jsdom Location object
|
||||||
|
@ -199,18 +201,10 @@ describe('Remote utilities', () => {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should reload the page on 300 multiple choices response', () => {
|
it('should reload the page on 300 multiple choices response', () => {
|
||||||
const promiseLike = {
|
jest.spyOn(global, 'fetch').mockResolvedValue(new Response('', { status: 300}));
|
||||||
then(cb: (r: Response) => void) {
|
|
||||||
if (cb) {
|
|
||||||
cb(new Response('', { status: 300 }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.spyOn(global, 'fetch').mockReturnValue(promiseLike as any);
|
|
||||||
|
|
||||||
submitForm();
|
submitForm();
|
||||||
expect(window.location.reload).toHaveBeenCalledTimes(1);
|
return waitFor(() => expect(window.location.reload).toHaveBeenCalledTimes(1));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
BIN
assets/js/__tests__/upload-test.png
Normal file
BIN
assets/js/__tests__/upload-test.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 527 B |
BIN
assets/js/__tests__/upload-test.webm
Normal file
BIN
assets/js/__tests__/upload-test.webm
Normal file
Binary file not shown.
178
assets/js/__tests__/upload.spec.ts
Normal file
178
assets/js/__tests__/upload.spec.ts
Normal file
|
@ -0,0 +1,178 @@
|
||||||
|
import { $, $$, removeEl } from '../utils/dom';
|
||||||
|
import { assertNotNull, assertNotUndefined } from '../utils/assert';
|
||||||
|
|
||||||
|
import fetchMock from 'jest-fetch-mock';
|
||||||
|
import { fixEventListeners } from '../../test/fix-event-listeners';
|
||||||
|
import { fireEvent, waitFor } from '@testing-library/dom';
|
||||||
|
import { promises } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
import { setupImageUpload } from '../upload';
|
||||||
|
|
||||||
|
/* eslint-disable camelcase */
|
||||||
|
const scrapeResponse = {
|
||||||
|
description: 'test',
|
||||||
|
images: [
|
||||||
|
{url: 'http://localhost/images/1', camo_url: 'http://localhost/images/1'},
|
||||||
|
{url: 'http://localhost/images/2', camo_url: 'http://localhost/images/2'},
|
||||||
|
],
|
||||||
|
source_url: 'http://localhost/images',
|
||||||
|
author_name: 'test',
|
||||||
|
};
|
||||||
|
const nullResponse = null;
|
||||||
|
const errorResponse = {
|
||||||
|
errors: ['Error 1', 'Error 2'],
|
||||||
|
};
|
||||||
|
/* eslint-enable camelcase */
|
||||||
|
|
||||||
|
describe('Image upload form', () => {
|
||||||
|
let mockPng: File;
|
||||||
|
let mockWebm: File;
|
||||||
|
|
||||||
|
beforeAll(async() => {
|
||||||
|
const mockPngPath = join(__dirname, 'upload-test.png');
|
||||||
|
const mockWebmPath = join(__dirname, 'upload-test.webm');
|
||||||
|
|
||||||
|
mockPng = new File([(await promises.readFile(mockPngPath, { encoding: null })).buffer], 'upload-test.png', { type: 'image/png' });
|
||||||
|
mockWebm = new File([(await promises.readFile(mockWebmPath, { encoding: null })).buffer], 'upload-test.webm', { type: 'video/webm' });
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
fetchMock.enableMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fetchMock.disableMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
fixEventListeners(window);
|
||||||
|
|
||||||
|
let form: HTMLFormElement;
|
||||||
|
let imgPreviews: HTMLDivElement;
|
||||||
|
let fileField: HTMLInputElement;
|
||||||
|
let remoteUrl: HTMLInputElement;
|
||||||
|
let scraperError: HTMLDivElement;
|
||||||
|
let fetchButton: HTMLButtonElement;
|
||||||
|
let tagsEl: HTMLTextAreaElement;
|
||||||
|
let sourceEl: HTMLInputElement;
|
||||||
|
let descrEl: HTMLTextAreaElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.documentElement.insertAdjacentHTML('beforeend', `
|
||||||
|
<form action="/images">
|
||||||
|
<div id="js-image-upload-previews"></div>
|
||||||
|
<input id="image_image" name="image[image]" type="file" class="js-scraper" />
|
||||||
|
<input id="image_scraper_url" name="image[scraper_url]" type="url" class="js-scraper" />
|
||||||
|
<button id="js-scraper-preview" type="button">Fetch</button>
|
||||||
|
<div class="field-error-js hidden js-scraper"></div>
|
||||||
|
|
||||||
|
<input id="image_sources_0_source" name="image[sources][0][source]" type="text" class="js-source-url" />
|
||||||
|
<textarea id="image_tag_input" name="image[tag_input]" class="js-image-tags-input"></textarea>
|
||||||
|
<textarea id="image_description" name="image[description]" class="js-image-descr-input"></textarea>
|
||||||
|
</form>
|
||||||
|
`);
|
||||||
|
|
||||||
|
form = assertNotNull($<HTMLFormElement>('form'));
|
||||||
|
imgPreviews = assertNotNull($<HTMLDivElement>('#js-image-upload-previews'));
|
||||||
|
fileField = assertNotUndefined($$<HTMLInputElement>('.js-scraper')[0]);
|
||||||
|
remoteUrl = assertNotUndefined($$<HTMLInputElement>('.js-scraper')[1]);
|
||||||
|
scraperError = assertNotUndefined($$<HTMLInputElement>('.js-scraper')[2]);
|
||||||
|
tagsEl = assertNotNull($<HTMLTextAreaElement>('.js-image-tags-input'));
|
||||||
|
sourceEl = assertNotNull($<HTMLInputElement>('.js-source-url'));
|
||||||
|
descrEl = assertNotNull($<HTMLTextAreaElement>('.js-image-descr-input'));
|
||||||
|
fetchButton = assertNotNull($<HTMLButtonElement>('#js-scraper-preview'));
|
||||||
|
|
||||||
|
setupImageUpload();
|
||||||
|
fetchMock.resetMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
removeEl(form);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable fetch button on empty source', () => {
|
||||||
|
fireEvent.input(remoteUrl, { target: { value: '' }});
|
||||||
|
expect(fetchButton.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable fetch button on non-empty source', () => {
|
||||||
|
fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' }});
|
||||||
|
expect(fetchButton.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a preview element when an image file is uploaded', () => {
|
||||||
|
fireEvent.change(fileField, { target: { files: [mockPng] }});
|
||||||
|
return waitFor(() => expect(imgPreviews.querySelectorAll('img')).toHaveLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a preview element when a Matroska video file is uploaded', () => {
|
||||||
|
fireEvent.change(fileField, { target: { files: [mockWebm] }});
|
||||||
|
return waitFor(() => expect(imgPreviews.querySelectorAll('video')).toHaveLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block navigation away after an image file is attached, but not after form submission', async() => {
|
||||||
|
fireEvent.change(fileField, { target: { files: [mockPng] }});
|
||||||
|
await waitFor(() => { expect(imgPreviews.querySelectorAll('img')).toHaveLength(1); });
|
||||||
|
|
||||||
|
const failedUnloadEvent = new Event('beforeunload', { cancelable: true });
|
||||||
|
expect(fireEvent(window, failedUnloadEvent)).toBe(false);
|
||||||
|
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
form.addEventListener('submit', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
const succeededUnloadEvent = new Event('beforeunload', { cancelable: true });
|
||||||
|
expect(fireEvent(window, succeededUnloadEvent)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scrape images when the fetch button is clicked', async() => {
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify(scrapeResponse), { status: 200 }));
|
||||||
|
fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' }});
|
||||||
|
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
tagsEl.addEventListener('addtag', (event: Event) => {
|
||||||
|
expect((event as CustomEvent).detail).toEqual({name: 'artist:test'});
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(remoteUrl, { keyCode: 13 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||||
|
await waitFor(() => expect(imgPreviews.querySelectorAll('img')).toHaveLength(2));
|
||||||
|
|
||||||
|
expect(scraperError.innerHTML).toEqual('');
|
||||||
|
expect(sourceEl.value).toEqual('http://localhost/images');
|
||||||
|
expect(descrEl.value).toEqual('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show null scrape result', () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify(nullResponse), { status: 200 }));
|
||||||
|
|
||||||
|
fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' }});
|
||||||
|
fetchButton.click();
|
||||||
|
|
||||||
|
return waitFor(() => {
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(imgPreviews.querySelectorAll('img')).toHaveLength(0);
|
||||||
|
expect(scraperError.innerText).toEqual('No image found at that address.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error scrape result', () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify(errorResponse), { status: 200 }));
|
||||||
|
|
||||||
|
fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' }});
|
||||||
|
fetchButton.click();
|
||||||
|
|
||||||
|
return waitFor(() => {
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(imgPreviews.querySelectorAll('img')).toHaveLength(0);
|
||||||
|
expect(scraperError.innerText).toEqual('Error 1 Error 2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,83 +0,0 @@
|
||||||
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 };
|
|
76
assets/js/input-duplicator.ts
Normal file
76
assets/js/input-duplicator.ts
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import { assertNotNull } from './utils/assert';
|
||||||
|
import { $, $$, disableEl, enableEl, removeEl } from './utils/dom';
|
||||||
|
import { delegate, leftClick } from './utils/events';
|
||||||
|
|
||||||
|
export interface InputDuplicatorOptions {
|
||||||
|
addButtonSelector: string;
|
||||||
|
fieldSelector: string;
|
||||||
|
maxInputCountSelector: string;
|
||||||
|
removeButtonSelector: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inputDuplicatorCreator({
|
||||||
|
addButtonSelector,
|
||||||
|
fieldSelector,
|
||||||
|
maxInputCountSelector,
|
||||||
|
removeButtonSelector
|
||||||
|
}: InputDuplicatorOptions) {
|
||||||
|
const addButton = $<HTMLButtonElement>(addButtonSelector);
|
||||||
|
if (!addButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = assertNotNull(addButton.closest('form'));
|
||||||
|
const fieldRemover = (event: MouseEvent, target: HTMLElement) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// Prevent removing the final field element to not "brick" the form
|
||||||
|
const existingFields = $$(fieldSelector, form);
|
||||||
|
if (existingFields.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEl(assertNotNull(target.closest<HTMLElement>(fieldSelector)));
|
||||||
|
enableEl(addButton);
|
||||||
|
};
|
||||||
|
|
||||||
|
delegate(form, 'click', {
|
||||||
|
[removeButtonSelector]: leftClick(fieldRemover)
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const maxOptionCountElement = assertNotNull($(maxInputCountSelector, form));
|
||||||
|
const maxOptionCount = parseInt(maxOptionCountElement.innerHTML, 10);
|
||||||
|
|
||||||
|
addButton.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const existingFields = $$<HTMLElement>(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) as HTMLElement;
|
||||||
|
|
||||||
|
$$<HTMLInputElement>('input', prevFieldCopy).forEach(prevFieldCopyInput => {
|
||||||
|
// Reset new input's value
|
||||||
|
prevFieldCopyInput.value = '';
|
||||||
|
prevFieldCopyInput.removeAttribute('value');
|
||||||
|
|
||||||
|
// Increment sequential attributes of the input
|
||||||
|
prevFieldCopyInput.setAttribute('name', prevFieldCopyInput.name.replace(/\d+/g, `${existingFieldsLength}`));
|
||||||
|
prevFieldCopyInput.setAttribute('id', prevFieldCopyInput.id.replace(/\d+/g, `${existingFieldsLength}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
prevField.insertAdjacentElement('afterend', prevFieldCopy);
|
||||||
|
|
||||||
|
existingFieldsLength++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the button if we reached the max number of options
|
||||||
|
if (existingFieldsLength >= maxOptionCount) {
|
||||||
|
disableEl(addButton);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -132,21 +132,17 @@ function setupImageUpload() {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enable/disable the fetch button based on content in the image scraper. Fetching with no URL makes no sense.
|
// Enable/disable the fetch button based on content in the image scraper. Fetching with no URL makes no sense.
|
||||||
remoteUrl.addEventListener('input', () => {
|
function setFetchEnabled() {
|
||||||
if (remoteUrl.value.length > 0) {
|
if (remoteUrl.value.length > 0) {
|
||||||
enableFetch();
|
enableFetch();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
disableFetch();
|
disableFetch();
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
if (remoteUrl.value.length > 0) {
|
remoteUrl.addEventListener('input', () => setFetchEnabled());
|
||||||
enableFetch();
|
setFetchEnabled();
|
||||||
}
|
|
||||||
else {
|
|
||||||
disableFetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Catch unintentional navigation away from the page
|
// Catch unintentional navigation away from the page
|
||||||
|
|
||||||
|
|
26
assets/test/fix-event-listeners.ts
Normal file
26
assets/test/fix-event-listeners.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
// Add helper to fix event listeners on a given target
|
||||||
|
|
||||||
|
export function fixEventListeners(t: EventTarget) {
|
||||||
|
let eventListeners: Record<string, unknown[]>;
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
beforeAll(() => {
|
||||||
|
eventListeners = {};
|
||||||
|
const oldAddEventListener = t.addEventListener;
|
||||||
|
|
||||||
|
t.addEventListener = function(type: string, listener: any, options: any): void {
|
||||||
|
eventListeners[type] = eventListeners[type] || [];
|
||||||
|
eventListeners[type].push(listener);
|
||||||
|
return oldAddEventListener(type, listener, options);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key in eventListeners) {
|
||||||
|
for (const listener of eventListeners[key]) {
|
||||||
|
(t.removeEventListener as any)(key, listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventListeners = {};
|
||||||
|
});
|
||||||
|
}
|
|
@ -7,7 +7,7 @@ all: import_es
|
||||||
import_es: dump_jsonl
|
import_es: dump_jsonl
|
||||||
$(ELASTICDUMP) --input=images.jsonl --output=http://localhost:9200/ --output-index=images --limit 10000 --retryAttempts=5 --type=data --transform="doc._source = Object.assign({},doc); doc._id = doc.id"
|
$(ELASTICDUMP) --input=images.jsonl --output=http://localhost:9200/ --output-index=images --limit 10000 --retryAttempts=5 --type=data --transform="doc._source = Object.assign({},doc); doc._id = doc.id"
|
||||||
|
|
||||||
dump_jsonl: metadata true_uploaders uploaders deleters galleries tags hides upvotes downvotes faves tag_names
|
dump_jsonl: metadata true_uploaders uploaders deleters galleries tags sources hides upvotes downvotes faves tag_names
|
||||||
psql $(DATABASE) -v ON_ERROR_STOP=1 <<< 'copy (select temp_images.jsonb_object_agg(object) from temp_images.image_search_json group by image_id) to stdout;' > images.jsonl
|
psql $(DATABASE) -v ON_ERROR_STOP=1 <<< 'copy (select temp_images.jsonb_object_agg(object) from temp_images.image_search_json group by image_id) to stdout;' > images.jsonl
|
||||||
psql $(DATABASE) -v ON_ERROR_STOP=1 <<< 'drop schema temp_images cascade;'
|
psql $(DATABASE) -v ON_ERROR_STOP=1 <<< 'drop schema temp_images cascade;'
|
||||||
sed -i images.jsonl -e 's/\\\\/\\/g'
|
sed -i images.jsonl -e 's/\\\\/\\/g'
|
||||||
|
@ -15,6 +15,8 @@ dump_jsonl: metadata true_uploaders uploaders deleters galleries tags hides upvo
|
||||||
metadata: image_search_json
|
metadata: image_search_json
|
||||||
psql $(DATABASE) -v ON_ERROR_STOP=1 <<-SQL
|
psql $(DATABASE) -v ON_ERROR_STOP=1 <<-SQL
|
||||||
insert into temp_images.image_search_json (image_id, object) select id, jsonb_build_object(
|
insert into temp_images.image_search_json (image_id, object) select id, jsonb_build_object(
|
||||||
|
'approved', approved,
|
||||||
|
'animated', is_animated,
|
||||||
'anonymous', anonymous,
|
'anonymous', anonymous,
|
||||||
'aspect_ratio', nullif(image_aspect_ratio, 'NaN'::float8),
|
'aspect_ratio', nullif(image_aspect_ratio, 'NaN'::float8),
|
||||||
'comment_count', comments_count,
|
'comment_count', comments_count,
|
||||||
|
@ -23,6 +25,7 @@ metadata: image_search_json
|
||||||
'description', description,
|
'description', description,
|
||||||
'downvotes', downvotes_count,
|
'downvotes', downvotes_count,
|
||||||
'duplicate_id', duplicate_id,
|
'duplicate_id', duplicate_id,
|
||||||
|
'duration', (case when is_animated then image_duration else 0::float end),
|
||||||
'faves', faves_count,
|
'faves', faves_count,
|
||||||
'file_name', image_name,
|
'file_name', image_name,
|
||||||
'fingerprint', fingerprint,
|
'fingerprint', fingerprint,
|
||||||
|
@ -35,10 +38,11 @@ metadata: image_search_json
|
||||||
'orig_sha512_hash', image_orig_sha512_hash,
|
'orig_sha512_hash', image_orig_sha512_hash,
|
||||||
'original_format', image_format,
|
'original_format', image_format,
|
||||||
'pixels', cast(image_width as bigint)*cast(image_height as bigint),
|
'pixels', cast(image_width as bigint)*cast(image_height as bigint),
|
||||||
|
'processed', processed,
|
||||||
'score', score,
|
'score', score,
|
||||||
'size', image_size,
|
'size', image_size,
|
||||||
'sha512_hash', image_sha512_hash,
|
'sha512_hash', image_sha512_hash,
|
||||||
'source_url', lower(source_url),
|
'thumbnails_generated', thumbnails_generated,
|
||||||
'updated_at', updated_at,
|
'updated_at', updated_at,
|
||||||
'upvotes', upvotes_count,
|
'upvotes', upvotes_count,
|
||||||
'width', image_width,
|
'width', image_width,
|
||||||
|
@ -64,33 +68,49 @@ deleters: image_search_json
|
||||||
galleries: image_search_json
|
galleries: image_search_json
|
||||||
psql $(DATABASE) -v ON_ERROR_STOP=1 <<-SQL
|
psql $(DATABASE) -v ON_ERROR_STOP=1 <<-SQL
|
||||||
insert into temp_images.image_search_json (image_id, object) select gi.image_id, jsonb_build_object('gallery_interactions', jsonb_agg(jsonb_build_object('id', gi.gallery_id, 'position', gi.position))) from gallery_interactions gi group by image_id;
|
insert into temp_images.image_search_json (image_id, object) select gi.image_id, jsonb_build_object('gallery_interactions', jsonb_agg(jsonb_build_object('id', gi.gallery_id, 'position', gi.position))) from gallery_interactions gi group by image_id;
|
||||||
insert into temp_images.image_search_json (image_id, object) select gi.image_id, jsonb_build_object('gallery_id', jsonb_agg(gi.gallery_id)) from gallery_interactions gi group by image_id;
|
|
||||||
insert into temp_images.image_search_json (image_id, object) select gi.image_id, jsonb_build_object('gallery_position', jsonb_object_agg(gi.gallery_id, gi.position)) from gallery_interactions gi group by image_id;
|
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
tags: image_search_json
|
tags: image_search_json
|
||||||
psql $(DATABASE) -v ON_ERROR_STOP=1 <<-SQL
|
psql $(DATABASE) -v ON_ERROR_STOP=1 <<-SQL
|
||||||
insert into temp_images.image_search_json (image_id, object) select it.image_id, jsonb_build_object('tag_ids', jsonb_agg(it.tag_id), 'tag_count', count(*)) from image_taggings it group by image_id;
|
insert into temp_images.image_search_json (image_id, object) select it.image_id, jsonb_build_object(
|
||||||
|
'tag_ids', jsonb_agg(it.tag_id),
|
||||||
|
'tag_count', count(*),
|
||||||
|
'error_tag_count', count(case when t.category = 'error' then t.category else null end),
|
||||||
|
'rating_tag_count', count(case when t.category = 'rating' then t.category else null end),
|
||||||
|
'origin_tag_count', count(case when t.category = 'origin' then t.category else null end),
|
||||||
|
'character_tag_count', count(case when t.category = 'character' then t.category else null end),
|
||||||
|
'oc_tag_count', count(case when t.category = 'oc' then t.category else null end),
|
||||||
|
'species_tag_count', count(case when t.category = 'species' then t.category else null end),
|
||||||
|
'body_type_tag_count', count(case when t.category = 'body-type' then t.category else null end),
|
||||||
|
'content_fanmade_tag_count', count(case when t.category = 'content-fanmade' then t.category else null end),
|
||||||
|
'content_official_tag_count', count(case when t.category = 'content-official' then t.category else null end),
|
||||||
|
'spoiler_tag_count', count(case when t.category = 'spoiler' then t.category else null end),
|
||||||
|
) from image_taggings it inner join tags t on t.id = it.tag_id group by image_id;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
sources: image_search_json
|
||||||
|
psql $(DATABASE) -v ON_ERROR_STOP=1 <<-SQL
|
||||||
|
insert into temp_images.image_search_json (image_id, object) select s.image_id, jsonb_build_object('source_url', jsonb_agg(lower(s.source)), 'source_count', count(*)) from image_sources s group by image_id;
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
hides: image_search_json
|
hides: image_search_json
|
||||||
psql $(DATABASE) -v ON_ERROR_STOP=1 <<-SQL
|
psql $(DATABASE) -v ON_ERROR_STOP=1 <<-SQL
|
||||||
insert into temp_images.image_search_json (image_id, object) select ih.image_id, jsonb_build_object('hidden_by_ids', jsonb_agg(ih.user_id), 'hidden_by', jsonb_agg(lower(u.name))) from image_hides ih inner join users u on u.id = ih.user_id group by image_id;
|
insert into temp_images.image_search_json (image_id, object) select ih.image_id, jsonb_build_object('hidden_by_user_ids', jsonb_agg(ih.user_id), 'hidden_by_users', jsonb_agg(lower(u.name))) from image_hides ih inner join users u on u.id = ih.user_id group by image_id;
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
downvotes: image_search_json
|
downvotes: image_search_json
|
||||||
psql $(DATABASE) -v ON_ERROR_STOP=1 <<-SQL
|
psql $(DATABASE) -v ON_ERROR_STOP=1 <<-SQL
|
||||||
insert into temp_images.image_search_json (image_id, object) select iv.image_id, jsonb_build_object('downvoted_by_ids', jsonb_agg(iv.user_id), 'downvoted_by', jsonb_agg(lower(u.name))) from image_votes iv inner join users u on u.id = iv.user_id where iv.up = false group by image_id;
|
insert into temp_images.image_search_json (image_id, object) select iv.image_id, jsonb_build_object('downvoter_ids', jsonb_agg(iv.user_id), 'downvoters', jsonb_agg(lower(u.name))) from image_votes iv inner join users u on u.id = iv.user_id where iv.up = false group by image_id;
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
upvotes: image_search_json
|
upvotes: image_search_json
|
||||||
psql $(DATABASE) -v ON_ERROR_STOP=1 <<-SQL
|
psql $(DATABASE) -v ON_ERROR_STOP=1 <<-SQL
|
||||||
insert into temp_images.image_search_json (image_id, object) select iv.image_id, jsonb_build_object('upvoted_by_ids', jsonb_agg(iv.user_id), 'upvoted_by', jsonb_agg(lower(u.name))) from image_votes iv inner join users u on u.id = iv.user_id where iv.up = true group by image_id;
|
insert into temp_images.image_search_json (image_id, object) select iv.image_id, jsonb_build_object('upvoter_ids', jsonb_agg(iv.user_id), 'upvoters', jsonb_agg(lower(u.name))) from image_votes iv inner join users u on u.id = iv.user_id where iv.up = true group by image_id;
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
faves: image_search_json
|
faves: image_search_json
|
||||||
psql $(DATABASE) -v ON_ERROR_STOP=1 <<-SQL
|
psql $(DATABASE) -v ON_ERROR_STOP=1 <<-SQL
|
||||||
insert into temp_images.image_search_json (image_id, object) select if.image_id, jsonb_build_object('faved_by_ids', jsonb_agg(if.user_id), 'faved_by', jsonb_agg(lower(u.name))) from image_faves if inner join users u on u.id = if.user_id group by image_id;
|
insert into temp_images.image_search_json (image_id, object) select if.image_id, jsonb_build_object('favourited_by_user_ids', jsonb_agg(if.user_id), 'favourited_by_users', jsonb_agg(lower(u.name))) from image_faves if inner join users u on u.id = if.user_id group by image_id;
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
tag_names: tags_with_aliases
|
tag_names: tags_with_aliases
|
||||||
|
|
|
@ -56,6 +56,7 @@ defmodule Philomena.Images.ElasticsearchIndex do
|
||||||
size: %{type: "integer"},
|
size: %{type: "integer"},
|
||||||
sha512_hash: %{type: "keyword"},
|
sha512_hash: %{type: "keyword"},
|
||||||
source_url: %{type: "keyword"},
|
source_url: %{type: "keyword"},
|
||||||
|
source_count: %{type: "integer"},
|
||||||
tag_count: %{type: "integer"},
|
tag_count: %{type: "integer"},
|
||||||
tag_ids: %{type: "keyword"},
|
tag_ids: %{type: "keyword"},
|
||||||
tags: %{type: "text", analyzer: "keyword"},
|
tags: %{type: "text", analyzer: "keyword"},
|
||||||
|
@ -87,7 +88,17 @@ defmodule Philomena.Images.ElasticsearchIndex do
|
||||||
namespace: %{type: "keyword"}
|
namespace: %{type: "keyword"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
approved: %{type: "boolean"}
|
approved: %{type: "boolean"},
|
||||||
|
error_tag_count: %{type: "integer"},
|
||||||
|
rating_tag_count: %{type: "integer"},
|
||||||
|
origin_tag_count: %{type: "integer"},
|
||||||
|
character_tag_count: %{type: "integer"},
|
||||||
|
oc_tag_count: %{type: "integer"},
|
||||||
|
species_tag_count: %{type: "integer"},
|
||||||
|
body_type_tag_count: %{type: "integer"},
|
||||||
|
content_fanmade_tag_count: %{type: "integer"},
|
||||||
|
content_official_tag_count: %{type: "integer"},
|
||||||
|
spoiler_tag_count: %{type: "integer"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -120,6 +131,7 @@ defmodule Philomena.Images.ElasticsearchIndex do
|
||||||
uploader: if(!!image.user and !image.anonymous, do: String.downcase(image.user.name)),
|
uploader: if(!!image.user and !image.anonymous, do: String.downcase(image.user.name)),
|
||||||
true_uploader: if(!!image.user, do: String.downcase(image.user.name)),
|
true_uploader: if(!!image.user, do: String.downcase(image.user.name)),
|
||||||
source_url: image.sources |> Enum.map(&String.downcase(&1.source)),
|
source_url: image.sources |> Enum.map(&String.downcase(&1.source)),
|
||||||
|
source_count: length(image.sources),
|
||||||
file_name: image.image_name,
|
file_name: image.image_name,
|
||||||
original_format: image.image_format,
|
original_format: image.image_format,
|
||||||
processed: image.processed,
|
processed: image.processed,
|
||||||
|
@ -151,7 +163,17 @@ defmodule Philomena.Images.ElasticsearchIndex do
|
||||||
upvoters: image.upvoters |> Enum.map(&String.downcase(&1.name)),
|
upvoters: image.upvoters |> Enum.map(&String.downcase(&1.name)),
|
||||||
downvoters: image.downvoters |> Enum.map(&String.downcase(&1.name)),
|
downvoters: image.downvoters |> Enum.map(&String.downcase(&1.name)),
|
||||||
deleted_by_user: if(!!image.deleter, do: image.deleter.name),
|
deleted_by_user: if(!!image.deleter, do: image.deleter.name),
|
||||||
approved: image.approved
|
approved: image.approved,
|
||||||
|
error_tag_count: Enum.count(image.tags, &(&1.category == "error")),
|
||||||
|
rating_tag_count: Enum.count(image.tags, &(&1.category == "rating")),
|
||||||
|
origin_tag_count: Enum.count(image.tags, &(&1.category == "origin")),
|
||||||
|
character_tag_count: Enum.count(image.tags, &(&1.category == "character")),
|
||||||
|
oc_tag_count: Enum.count(image.tags, &(&1.category == "oc")),
|
||||||
|
species_tag_count: Enum.count(image.tags, &(&1.category == "species")),
|
||||||
|
body_type_tag_count: Enum.count(image.tags, &(&1.category == "body-type")),
|
||||||
|
content_fanmade_tag_count: Enum.count(image.tags, &(&1.category == "content-fanmade")),
|
||||||
|
content_official_tag_count: Enum.count(image.tags, &(&1.category == "content-official")),
|
||||||
|
spoiler_tag_count: Enum.count(image.tags, &(&1.category == "spoiler"))
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -66,10 +66,26 @@ defmodule Philomena.Images.Query do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp tag_count_fields do
|
||||||
|
[
|
||||||
|
"body_type_tag_count",
|
||||||
|
"error_tag_count",
|
||||||
|
"character_tag_count",
|
||||||
|
"content_fanmade_tag_count",
|
||||||
|
"content_official_tag_count",
|
||||||
|
"oc_tag_count",
|
||||||
|
"origin_tag_count",
|
||||||
|
"rating_tag_count",
|
||||||
|
"species_tag_count",
|
||||||
|
"spoiler_tag_count"
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
defp anonymous_fields do
|
defp anonymous_fields do
|
||||||
[
|
[
|
||||||
int_fields:
|
int_fields:
|
||||||
~W(id width height comment_count score upvotes downvotes faves uploader_id faved_by_id tag_count pixels size),
|
~W(id width height score upvotes downvotes faves uploader_id faved_by_id pixels size comment_count source_count tag_count) ++
|
||||||
|
tag_count_fields(),
|
||||||
float_fields: ~W(aspect_ratio wilson_score duration),
|
float_fields: ~W(aspect_ratio wilson_score duration),
|
||||||
date_fields: ~W(created_at updated_at first_seen_at),
|
date_fields: ~W(created_at updated_at first_seen_at),
|
||||||
literal_fields:
|
literal_fields:
|
||||||
|
|
|
@ -3,6 +3,7 @@ defmodule PhilomenaWeb.Profile.TagChangeController do
|
||||||
|
|
||||||
alias Philomena.Users.User
|
alias Philomena.Users.User
|
||||||
alias Philomena.Images.Image
|
alias Philomena.Images.Image
|
||||||
|
alias Philomena.Tags.Tag
|
||||||
alias Philomena.TagChanges.TagChange
|
alias Philomena.TagChanges.TagChange
|
||||||
alias Philomena.Repo
|
alias Philomena.Repo
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
@ -16,19 +17,27 @@ defmodule PhilomenaWeb.Profile.TagChangeController do
|
||||||
tag_changes =
|
tag_changes =
|
||||||
TagChange
|
TagChange
|
||||||
|> join(:inner, [tc], i in Image, on: tc.image_id == i.id)
|
|> join(:inner, [tc], i in Image, on: tc.image_id == i.id)
|
||||||
|
|> only_tag_join(params)
|
||||||
|> where(
|
|> where(
|
||||||
[tc, i],
|
[tc, i],
|
||||||
tc.user_id == ^user.id and not (i.user_id == ^user.id and i.anonymous == true)
|
tc.user_id == ^user.id and not (i.user_id == ^user.id and i.anonymous == true)
|
||||||
)
|
)
|
||||||
|> added_filter(params)
|
|> added_filter(params)
|
||||||
|
|> only_tag_filter(params)
|
||||||
|> preload([:tag, :user, image: [:user, :sources, tags: :aliases]])
|
|> preload([:tag, :user, image: [:user, :sources, tags: :aliases]])
|
||||||
|> order_by(desc: :id)
|
|> order_by(desc: :id)
|
||||||
|> Repo.paginate(conn.assigns.scrivener)
|
|> Repo.paginate(conn.assigns.scrivener)
|
||||||
|
|
||||||
|
# params.permit(:added, :only_tag) ...
|
||||||
|
pagination_params =
|
||||||
|
[added: conn.params["added"], only_tag: conn.params["only_tag"]]
|
||||||
|
|> Keyword.filter(fn {_k, v} -> not is_nil(v) and v != "" end)
|
||||||
|
|
||||||
render(conn, "index.html",
|
render(conn, "index.html",
|
||||||
title: "Tag Changes for User `#{user.name}'",
|
title: "Tag Changes for User `#{user.name}'",
|
||||||
user: user,
|
user: user,
|
||||||
tag_changes: tag_changes
|
tag_changes: tag_changes,
|
||||||
|
pagination_params: pagination_params
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -40,4 +49,18 @@ defmodule PhilomenaWeb.Profile.TagChangeController do
|
||||||
|
|
||||||
defp added_filter(query, _params),
|
defp added_filter(query, _params),
|
||||||
do: query
|
do: query
|
||||||
|
|
||||||
|
defp only_tag_join(query, %{"only_tag" => only_tag})
|
||||||
|
when is_binary(only_tag) and only_tag != "",
|
||||||
|
do: join(query, :inner, [tc], t in Tag, on: tc.tag_id == t.id)
|
||||||
|
|
||||||
|
defp only_tag_join(query, _params),
|
||||||
|
do: query
|
||||||
|
|
||||||
|
defp only_tag_filter(query, %{"only_tag" => only_tag})
|
||||||
|
when is_binary(only_tag) and only_tag != "",
|
||||||
|
do: where(query, [_, _, t], t.name == ^only_tag)
|
||||||
|
|
||||||
|
defp only_tag_filter(query, _params),
|
||||||
|
do: query
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,7 +3,6 @@ defmodule PhilomenaWeb.MarkdownRenderer do
|
||||||
alias Philomena.Images.Image
|
alias Philomena.Images.Image
|
||||||
alias Philomena.Repo
|
alias Philomena.Repo
|
||||||
alias PhilomenaWeb.ImageView
|
alias PhilomenaWeb.ImageView
|
||||||
import Phoenix.HTML
|
|
||||||
import Phoenix.HTML.Link
|
import Phoenix.HTML.Link
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
@ -84,7 +83,6 @@ defmodule PhilomenaWeb.MarkdownRenderer do
|
||||||
size: ImageView.select_version(img, :medium),
|
size: ImageView.select_version(img, :medium),
|
||||||
conn: conn
|
conn: conn
|
||||||
)
|
)
|
||||||
|> safe_to_string()
|
|
||||||
|
|
||||||
[_id, "t"] when not img.hidden_from_users and img.approved ->
|
[_id, "t"] when not img.hidden_from_users and img.approved ->
|
||||||
Phoenix.View.render(ImageView, "_image_target.html",
|
Phoenix.View.render(ImageView, "_image_target.html",
|
||||||
|
@ -93,7 +91,6 @@ defmodule PhilomenaWeb.MarkdownRenderer do
|
||||||
size: ImageView.select_version(img, :small),
|
size: ImageView.select_version(img, :small),
|
||||||
conn: conn
|
conn: conn
|
||||||
)
|
)
|
||||||
|> safe_to_string()
|
|
||||||
|
|
||||||
[_id, "s"] when not img.hidden_from_users and img.approved ->
|
[_id, "s"] when not img.hidden_from_users and img.approved ->
|
||||||
Phoenix.View.render(ImageView, "_image_target.html",
|
Phoenix.View.render(ImageView, "_image_target.html",
|
||||||
|
@ -102,18 +99,15 @@ defmodule PhilomenaWeb.MarkdownRenderer do
|
||||||
size: ImageView.select_version(img, :thumb_small),
|
size: ImageView.select_version(img, :thumb_small),
|
||||||
conn: conn
|
conn: conn
|
||||||
)
|
)
|
||||||
|> safe_to_string()
|
|
||||||
|
|
||||||
[_id, suffix] when not img.approved ->
|
[_id, suffix] when not img.approved ->
|
||||||
">>#{img.id}#{suffix}#{link_suffix(img)}"
|
">>#{img.id}#{suffix}#{link_suffix(img)}"
|
||||||
|
|
||||||
[_id, ""] ->
|
[_id, ""] ->
|
||||||
link(">>#{img.id}#{link_suffix(img)}", to: "/images/#{img.id}")
|
link(">>#{img.id}#{link_suffix(img)}", to: "/images/#{img.id}")
|
||||||
|> safe_to_string()
|
|
||||||
|
|
||||||
[_id, suffix] when suffix in ["t", "s", "p"] ->
|
[_id, suffix] when suffix in ["t", "s", "p"] ->
|
||||||
link(">>#{img.id}#{suffix}#{link_suffix(img)}", to: "/images/#{img.id}")
|
link(">>#{img.id}#{suffix}#{link_suffix(img)}", to: "/images/#{img.id}")
|
||||||
|> safe_to_string()
|
|
||||||
|
|
||||||
# This condition should never trigger, but let's leave it here just in case.
|
# This condition should never trigger, but let's leave it here just in case.
|
||||||
[id, suffix] ->
|
[id, suffix] ->
|
||||||
|
@ -124,7 +118,12 @@ defmodule PhilomenaWeb.MarkdownRenderer do
|
||||||
">>#{text}"
|
">>#{text}"
|
||||||
end
|
end
|
||||||
|
|
||||||
[text, rendered]
|
string_contents =
|
||||||
|
rendered
|
||||||
|
|> Phoenix.HTML.Safe.to_iodata()
|
||||||
|
|> IO.iodata_to_binary()
|
||||||
|
|
||||||
|
[text, string_contents]
|
||||||
end)
|
end)
|
||||||
|> Map.new(fn [id, html] -> {id, html} end)
|
|> Map.new(fn [id, html] -> {id, html} end)
|
||||||
end
|
end
|
||||||
|
|
|
@ -45,13 +45,15 @@ defmodule PhilomenaWeb.StatsUpdater do
|
||||||
distinct_creators: distinct_creators,
|
distinct_creators: distinct_creators,
|
||||||
images_in_galleries: images_in_galleries
|
images_in_galleries: images_in_galleries
|
||||||
)
|
)
|
||||||
|
|> Phoenix.HTML.Safe.to_iodata()
|
||||||
|
|> IO.iodata_to_binary()
|
||||||
|
|
||||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
|
|
||||||
static_page = %{
|
static_page = %{
|
||||||
title: "Statistics",
|
title: "Statistics",
|
||||||
slug: "stats",
|
slug: "stats",
|
||||||
body: Phoenix.HTML.safe_to_string(result),
|
body: result,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now
|
updated_at: now
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,6 +37,6 @@ p
|
||||||
strong> Q: Do you host streams?
|
strong> Q: Do you host streams?
|
||||||
| A: No, we cheat and just link to streams on Picarto since that's where (almost) everyone is already. This is simply a nice way to track streaming artists.
|
| A: No, we cheat and just link to streams on Picarto since that's where (almost) everyone is already. This is simply a nice way to track streaming artists.
|
||||||
p
|
p
|
||||||
strong> Q: How do I get my stream/a friend's stream/<artist>'s stream here?
|
strong> Q: How do I get my stream/a friend's stream/<artist>'s stream here?
|
||||||
' A: Send a private message to a site administrator
|
' A: Send a private message to a site administrator
|
||||||
' with a link to the stream and the artist tag if applicable.
|
' with a link to the stream and the artist tag if applicable.
|
||||||
|
|
|
@ -4,16 +4,16 @@ h1
|
||||||
= @user.name
|
= @user.name
|
||||||
|
|
||||||
- route = fn p -> Routes.profile_tag_change_path(@conn, :index, @user, p) end
|
- route = fn p -> Routes.profile_tag_change_path(@conn, :index, @user, p) end
|
||||||
- params = if @conn.params["added"], do: [added: @conn.params["added"]]
|
- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @tag_changes, route: route, conn: @conn, params: @pagination_params
|
||||||
- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @tag_changes, route: route, conn: @conn, params: params
|
|
||||||
|
|
||||||
.block
|
.block
|
||||||
.block__header
|
.block__header
|
||||||
span
|
= form_for @conn, Routes.profile_tag_change_path(@conn, :index, @user), [method: "get", enforce_utf8: false], fn f ->
|
||||||
| Display only:
|
= text_input f, :only_tag, class: "input", placeholder: "Tag", title: "Only show this tag", autocapitalize: "none"
|
||||||
|
= submit "Search", class: "button", title: "Search"
|
||||||
|
|
||||||
= link "Removed", to: Routes.profile_tag_change_path(@conn, :index, @user, added: 0)
|
= link "Removed", to: Routes.profile_tag_change_path(@conn, :index, @user, Keyword.merge(@pagination_params, added: 0))
|
||||||
= link "Added", to: Routes.profile_tag_change_path(@conn, :index, @user, added: 1)
|
= link "Added", to: Routes.profile_tag_change_path(@conn, :index, @user, Keyword.merge(@pagination_params, added: 1))
|
||||||
= link "All", to: Routes.profile_tag_change_path(@conn, :index, @user)
|
= link "All", to: Routes.profile_tag_change_path(@conn, :index, @user, Keyword.delete(@pagination_params, :added))
|
||||||
|
|
||||||
= render PhilomenaWeb.TagChangeView, "index.html", conn: @conn, tag_changes: @tag_changes, pagination: pagination
|
= render PhilomenaWeb.TagChangeView, "index.html", conn: @conn, tag_changes: @tag_changes, pagination: pagination
|
||||||
|
|
|
@ -103,6 +103,8 @@ defmodule PhilomenaWeb.TagView do
|
||||||
{tags, shipping, data}
|
{tags, shipping, data}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# This is a rendered template, so raw/1 has no effect on safety
|
||||||
|
# sobelow_skip ["XSS.Raw"]
|
||||||
defp render_quick_tags({tags, shipping, data}, conn) do
|
defp render_quick_tags({tags, shipping, data}, conn) do
|
||||||
render(PhilomenaWeb.TagView, "_quick_tag_table.html",
|
render(PhilomenaWeb.TagView, "_quick_tag_table.html",
|
||||||
tags: tags,
|
tags: tags,
|
||||||
|
@ -110,6 +112,8 @@ defmodule PhilomenaWeb.TagView do
|
||||||
data: data,
|
data: data,
|
||||||
conn: conn
|
conn: conn
|
||||||
)
|
)
|
||||||
|
|> Phoenix.HTML.Safe.to_iodata()
|
||||||
|
|> Phoenix.HTML.raw()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp names_in_tab("default", data) do
|
defp names_in_tab("default", data) do
|
||||||
|
|
Loading…
Reference in a new issue