diff --git a/assets/js/__tests__/search.spec.ts b/assets/js/__tests__/search.spec.ts
new file mode 100644
index 000000000..abc033b54
--- /dev/null
+++ b/assets/js/__tests__/search.spec.ts
@@ -0,0 +1,99 @@
+import { $ } from '../utils/dom';
+import { assertNotNull } from '../utils/assert';
+import { setupSearch } from '../search';
+import { setupTagListener } from '../tagsinput';
+
+const formData = `
`;
+
+describe('Search form help', () => {
+ beforeAll(() => {
+ setupSearch();
+ setupTagListener();
+ });
+
+ let input: HTMLInputElement;
+ let prependAnchor: HTMLAnchorElement;
+ let idAnchor: HTMLAnchorElement;
+ let favesAnchor: HTMLAnchorElement;
+ let helpNumeric: HTMLDivElement;
+ let subjectSpan: HTMLElement;
+
+ beforeEach(() => {
+ document.body.innerHTML = formData;
+
+ input = assertNotNull($('input'));
+ prependAnchor = assertNotNull($('a[data-search-prepend]'));
+ idAnchor = assertNotNull($('a[data-search-add="id.lte:10"]'));
+ favesAnchor = assertNotNull($('a[data-search-add="my:faves"]'));
+ helpNumeric = assertNotNull($('[data-search-help="numeric"]'));
+ subjectSpan = assertNotNull($('span', helpNumeric));
+ });
+
+ it('should add text to input field', () => {
+ idAnchor.click();
+ expect(input.value).toBe('id.lte:10');
+
+ favesAnchor.click();
+ expect(input.value).toBe('id.lte:10, my:faves');
+ });
+
+ it('should focus and select text in input field when requested', () => {
+ idAnchor.click();
+ expect(input).toHaveFocus();
+ expect(input.selectionStart).toBe(7);
+ expect(input.selectionEnd).toBe(9);
+ });
+
+ it('should highlight subject name when requested', () => {
+ expect(helpNumeric).toHaveClass('hidden');
+ idAnchor.click();
+ expect(helpNumeric).not.toHaveClass('hidden');
+ expect(subjectSpan).toHaveTextContent('Numeric ID');
+ });
+
+ it('should not focus and select text in input field when unavailable', () => {
+ favesAnchor.click();
+ expect(input).not.toHaveFocus();
+ expect(input.selectionStart).toBe(8);
+ expect(input.selectionEnd).toBe(8);
+ });
+
+ it('should not highlight subject name when unavailable', () => {
+ favesAnchor.click();
+ expect(helpNumeric).toHaveClass('hidden');
+ });
+
+ it('should prepend to empty input', () => {
+ prependAnchor.click();
+ expect(input.value).toBe('-');
+ });
+
+ it('should prepend to single input', () => {
+ input.value = 'a';
+ prependAnchor.click();
+ expect(input.value).toBe('-a');
+ });
+
+ it('should prepend to comma-separated input', () => {
+ input.value = 'a,b';
+ prependAnchor.click();
+ expect(input.value).toBe('a,-b');
+ });
+
+ it('should prepend to comma and space-separated input', () => {
+ input.value = 'a, b';
+ prependAnchor.click();
+ expect(input.value).toBe('a, -b');
+ });
+});
diff --git a/assets/js/__tests__/tagsinput.spec.ts b/assets/js/__tests__/tagsinput.spec.ts
new file mode 100644
index 000000000..e32b2dfc3
--- /dev/null
+++ b/assets/js/__tests__/tagsinput.spec.ts
@@ -0,0 +1,188 @@
+import { $, $$, hideEl } from '../utils/dom';
+import { assertNotNull } from '../utils/assert';
+import { TermSuggestion } from '../utils/suggestions';
+import { setupTagsInput, addTag, reloadTagsInput } from '../tagsinput';
+
+const formData = ``;
+
+describe('Fancy tags input', () => {
+ let form: HTMLFormElement;
+ let tagBlock: HTMLDivElement;
+ let plainInput: HTMLTextAreaElement;
+ let fancyInput: HTMLDivElement;
+ let fancyText: HTMLInputElement;
+ let fancyShowButton: HTMLButtonElement;
+ let plainShowButton: HTMLButtonElement;
+
+ beforeEach(() => {
+ window.booru.fancyTagUpload = true;
+ window.booru.fancyTagEdit = true;
+ document.body.innerHTML = formData;
+
+ form = assertNotNull($('.tags-form'));
+ tagBlock = assertNotNull($('.js-tag-block'));
+ plainInput = assertNotNull($('.js-taginput-plain'));
+ fancyInput = assertNotNull($('.js-taginput-fancy'));
+ fancyText = assertNotNull($('.js-taginput-input'));
+ fancyShowButton = assertNotNull($('.js-taginput-show'));
+ plainShowButton = assertNotNull($('.js-taginput-hide'));
+
+ // prevent these from submitting the form
+ fancyShowButton.addEventListener('click', e => e.preventDefault());
+ plainShowButton.addEventListener('click', e => e.preventDefault());
+ });
+
+ for (let i = 0; i < 4; i++) {
+ const type = (i & 2) === 0 ? 'upload' : 'edit';
+ const name = (i & 2) === 0 ? 'fancyTagUpload' : 'fancyTagEdit';
+ const value = (i & 1) === 0;
+
+ // eslint-disable-next-line no-loop-func
+ it(`should imply ${name}:${value} <-> ${type}:${value} on setup`, () => {
+ window.booru.fancyTagEdit = false;
+ window.booru.fancyTagUpload = false;
+ window.booru[name] = value;
+
+ plainInput.value = 'a, b';
+ tagBlock.classList.remove('fancy-tag-edit', 'fancy-tag-upload');
+ tagBlock.classList.add(`fancy-tag-${type}`);
+ expect($$('span.tag', fancyInput)).toHaveLength(0);
+
+ setupTagsInput(tagBlock);
+ expect($$('span.tag', fancyInput)).toHaveLength(value ? 2 : 0);
+ });
+ }
+
+ it('should move tags from the plain to the fancy editor when the fancy editor is shown', () => {
+ expect($$('span.tag', fancyInput)).toHaveLength(0);
+
+ setupTagsInput(tagBlock);
+ plainInput.value = 'a, b';
+ fancyShowButton.click();
+ expect($$('span.tag', fancyInput)).toHaveLength(2);
+ });
+
+ it('should move tags from the plain to the fancy editor on reload event', () => {
+ expect($$('span.tag', fancyInput)).toHaveLength(0);
+
+ setupTagsInput(tagBlock);
+ plainInput.value = 'a, b';
+ reloadTagsInput(plainInput);
+ expect($$('span.tag', fancyInput)).toHaveLength(2);
+ });
+
+ it('should respond to addtag events', () => {
+ setupTagsInput(tagBlock);
+ addTag(plainInput, 'a');
+ expect($$('span.tag', fancyInput)).toHaveLength(1);
+ });
+
+ it('should not respond to addtag events if the container is hidden', () => {
+ setupTagsInput(tagBlock);
+ hideEl(fancyInput);
+ addTag(plainInput, 'a');
+ expect($$('span.tag', fancyInput)).toHaveLength(0);
+ });
+
+ it('should respond to autocomplete events', () => {
+ setupTagsInput(tagBlock);
+ fancyText.dispatchEvent(new CustomEvent('autocomplete', { detail: { value: 'a', label: 'a' } }));
+ expect($$('span.tag', fancyInput)).toHaveLength(1);
+ });
+
+ it('should allow removing previously added tags by clicking them', () => {
+ setupTagsInput(tagBlock);
+ addTag(plainInput, 'a');
+ assertNotNull($('span.tag a', fancyInput)).click();
+ expect($$('span.tag', fancyInput)).toHaveLength(0);
+ });
+
+ it('should allow removing previously added tags by adding one with a minus sign prepended', () => {
+ setupTagsInput(tagBlock);
+ addTag(plainInput, 'a');
+ expect($$('span.tag', fancyInput)).toHaveLength(1);
+ addTag(plainInput, '-a');
+ expect($$('span.tag', fancyInput)).toHaveLength(0);
+ });
+
+ it('should disallow adding empty tags', () => {
+ setupTagsInput(tagBlock);
+ addTag(plainInput, '');
+ expect($$('span.tag', fancyInput)).toHaveLength(0);
+ });
+
+ it('should disallow adding existing tags', () => {
+ setupTagsInput(tagBlock);
+ addTag(plainInput, 'a');
+ addTag(plainInput, 'a');
+ expect($$('span.tag', fancyInput)).toHaveLength(1);
+ });
+
+ it('should submit the form on ctrl+enter', () => {
+ setupTagsInput(tagBlock);
+
+ const ev = new KeyboardEvent('keydown', { keyCode: 13, ctrlKey: true, bubbles: true });
+
+ return new Promise(resolve => {
+ form.addEventListener('submit', e => {
+ e.preventDefault();
+ resolve();
+ });
+
+ fancyText.dispatchEvent(ev);
+ expect(ev.defaultPrevented).toBe(true);
+ });
+ });
+
+ it('does nothing when backspacing on empty input and there are no tags', () => {
+ setupTagsInput(tagBlock);
+
+ const ev = new KeyboardEvent('keydown', { keyCode: 8, bubbles: true });
+ fancyText.dispatchEvent(ev);
+
+ expect($$('span.tag', fancyInput)).toHaveLength(0);
+ });
+
+ it('erases the last added tag when backspacing on empty input', () => {
+ setupTagsInput(tagBlock);
+ addTag(plainInput, 'a');
+ addTag(plainInput, 'b');
+
+ const ev = new KeyboardEvent('keydown', { keyCode: 8, bubbles: true });
+ fancyText.dispatchEvent(ev);
+
+ expect($$('span.tag', fancyInput)).toHaveLength(1);
+ });
+
+ it('adds new tag when comma is pressed', () => {
+ setupTagsInput(tagBlock);
+
+ const ev = new KeyboardEvent('keydown', { keyCode: 188, bubbles: true });
+ fancyText.value = 'a';
+ fancyText.dispatchEvent(ev);
+
+ expect($$('span.tag', fancyInput)).toHaveLength(1);
+ expect(fancyText.value).toBe('');
+ });
+
+ it('adds new tag when enter is pressed', () => {
+ setupTagsInput(tagBlock);
+
+ const ev = new KeyboardEvent('keydown', { keyCode: 13, bubbles: true });
+ fancyText.value = 'a';
+ fancyText.dispatchEvent(ev);
+
+ expect($$('span.tag', fancyInput)).toHaveLength(1);
+ expect(fancyText.value).toBe('');
+ });
+});
diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts
index 489392c3a..61340b481 100644
--- a/assets/js/autocomplete.ts
+++ b/assets/js/autocomplete.ts
@@ -25,6 +25,14 @@ function restoreOriginalValue() {
if (isSearchField(inputField) && originalQuery) {
inputField.value = originalQuery;
+
+ if (selectedTerm) {
+ const [, selectedTermEnd] = selectedTerm[0];
+
+ inputField.setSelectionRange(selectedTermEnd, selectedTermEnd);
+ }
+
+ return;
}
if (originalTerm) {
diff --git a/assets/js/search.js b/assets/js/search.js
deleted file mode 100644
index 50733fd9b..000000000
--- a/assets/js/search.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import { $, $$ } from './utils/dom';
-import { addTag } from './tagsinput';
-
-function showHelp(subject, type) {
- $$('[data-search-help]').forEach(helpBox => {
- if (helpBox.getAttribute('data-search-help') === type) {
- $('.js-search-help-subject', helpBox).textContent = subject;
- helpBox.classList.remove('hidden');
- } else {
- helpBox.classList.add('hidden');
- }
- });
-}
-
-function prependToLast(field, value) {
- const separatorIndex = field.value.lastIndexOf(',');
- const advanceBy = field.value[separatorIndex + 1] === ' ' ? 2 : 1;
- field.value =
- field.value.slice(0, separatorIndex + advanceBy) + value + field.value.slice(separatorIndex + advanceBy);
-}
-
-function selectLast(field, characterCount) {
- field.focus();
-
- field.selectionStart = field.value.length - characterCount;
- field.selectionEnd = field.value.length;
-}
-
-function executeFormHelper(e) {
- const searchField = $('.js-search-field');
- const attr = name => e.target.getAttribute(name);
-
- attr('data-search-add') && addTag(searchField, attr('data-search-add'));
- attr('data-search-show-help') && showHelp(e.target.textContent, attr('data-search-show-help'));
- attr('data-search-select-last') && selectLast(searchField, parseInt(attr('data-search-select-last'), 10));
- attr('data-search-prepend') && prependToLast(searchField, attr('data-search-prepend'));
-}
-
-function setupSearch() {
- const form = $('.js-search-form');
-
- form && form.addEventListener('click', executeFormHelper);
-}
-
-export { setupSearch };
diff --git a/assets/js/search.ts b/assets/js/search.ts
new file mode 100644
index 000000000..eff8d98ae
--- /dev/null
+++ b/assets/js/search.ts
@@ -0,0 +1,85 @@
+import { assertNotNull, assertNotUndefined } from './utils/assert';
+import { $, $$, showEl, hideEl } from './utils/dom';
+import { delegate, leftClick } from './utils/events';
+import { addTag } from './tagsinput';
+
+function focusAndSelectLast(field: HTMLInputElement, characterCount: number) {
+ field.focus();
+ field.selectionStart = field.value.length - characterCount;
+ field.selectionEnd = field.value.length;
+}
+
+function prependToLast(field: HTMLInputElement, value: string) {
+ // Find the last comma in the input and advance past it
+ const separatorIndex = field.value.lastIndexOf(',');
+ const advanceBy = field.value[separatorIndex + 1] === ' ' ? 2 : 1;
+
+ // Insert the value string at the new location
+ field.value = [
+ field.value.slice(0, separatorIndex + advanceBy),
+ value,
+ field.value.slice(separatorIndex + advanceBy),
+ ].join('');
+}
+
+function getAssociatedData(target: HTMLElement) {
+ const form = assertNotNull(target.closest('form'));
+ const input = assertNotNull($('.js-search-field', form));
+ const helpBoxes = $$('[data-search-help]', form);
+
+ return { input, helpBoxes };
+}
+
+function showHelp(helpBoxes: HTMLDivElement[], typeName: string, subject: string) {
+ for (const helpBox of helpBoxes) {
+ // Get the subject name span
+ const subjectName = assertNotNull($('.js-search-help-subject', helpBox));
+
+ // Take the appropriate action for this help box
+ if (helpBox.dataset.searchHelp === typeName) {
+ subjectName.textContent = subject;
+ showEl(helpBox);
+ } else {
+ hideEl(helpBox);
+ }
+ }
+}
+
+function onSearchAdd(_event: Event, target: HTMLAnchorElement) {
+ // Load form
+ const { input, helpBoxes } = getAssociatedData(target);
+
+ // Get data for this link
+ const addValue = assertNotUndefined(target.dataset.searchAdd);
+ const showHelpValue = assertNotUndefined(target.dataset.searchShowHelp);
+ const selectLastValue = target.dataset.searchSelectLast;
+
+ // Add the tag
+ addTag(input, addValue);
+
+ // Show associated help, if available
+ showHelp(helpBoxes, showHelpValue, assertNotNull(target.textContent));
+
+ // Select last characters, if requested
+ if (selectLastValue) {
+ focusAndSelectLast(input, Number(selectLastValue));
+ }
+}
+
+function onSearchPrepend(_event: Event, target: HTMLAnchorElement) {
+ // Load form
+ const { input } = getAssociatedData(target);
+
+ // Get data for this link
+ const prependValue = assertNotUndefined(target.dataset.searchPrepend);
+
+ // Prepend
+ prependToLast(input, prependValue);
+}
+
+export function setupSearch() {
+ delegate(document, 'click', {
+ 'form.js-search-form a[data-search-add][data-search-show-help]': leftClick(onSearchAdd),
+ 'form.js-search-form a[data-search-prepend]': leftClick(onSearchPrepend),
+ });
+}
diff --git a/assets/js/tagsinput.js b/assets/js/tagsinput.ts
similarity index 63%
rename from assets/js/tagsinput.js
rename to assets/js/tagsinput.ts
index bc05b3d7d..377db60d3 100644
--- a/assets/js/tagsinput.js
+++ b/assets/js/tagsinput.ts
@@ -2,14 +2,20 @@
* Fancy tag editor.
*/
+import { assertNotNull, assertType } from './utils/assert';
import { $, $$, clearEl, removeEl, showEl, hideEl, escapeCss, escapeHtml } from './utils/dom';
+import { TermSuggestion } from './utils/suggestions';
-function setupTagsInput(tagBlock) {
- const [textarea, container] = $$('.js-taginput', tagBlock);
- const setup = $('.js-tag-block ~ button', tagBlock.parentNode);
- const inputField = $('input', container);
+export function setupTagsInput(tagBlock: HTMLDivElement) {
+ const form = assertNotNull(tagBlock.closest('form'));
+ const textarea = assertNotNull($('.js-taginput-plain', tagBlock));
+ const container = assertNotNull($('.js-taginput-fancy'));
+ const parentField = assertNotNull(tagBlock.parentElement);
+ const setup = assertNotNull($('.js-tag-block ~ button', parentField));
+ const inputField = assertNotNull($('input', container));
+ const submitButton = assertNotNull($('[type="submit"]', form));
- let tags = [];
+ let tags: string[] = [];
// Load in the current tag set from the textarea
setup.addEventListener('click', importTags);
@@ -27,7 +33,7 @@ function setupTagsInput(tagBlock) {
inputField.addEventListener('keydown', handleKeyEvent);
// Respond to autocomplete form clicks
- inputField.addEventListener('autocomplete', handleAutocomplete);
+ inputField.addEventListener('autocomplete', handleAutocomplete as EventListener);
// Respond to Ctrl+Enter shortcut
tagBlock.addEventListener('keydown', handleCtrlEnter);
@@ -35,19 +41,19 @@ function setupTagsInput(tagBlock) {
// TODO: Cleanup this bug fix
// Switch to fancy tagging if user settings want it
if (fancyEditorRequested(tagBlock)) {
- showEl($$('.js-taginput-fancy'));
- showEl($$('.js-taginput-hide'));
- hideEl($$('.js-taginput-plain'));
- hideEl($$('.js-taginput-show'));
+ showEl($$('.js-taginput-fancy'));
+ showEl($$('.js-taginput-hide'));
+ hideEl($$('.js-taginput-plain'));
+ hideEl($$('.js-taginput-show'));
importTags();
}
- function handleAutocomplete(event) {
+ function handleAutocomplete(event: CustomEvent) {
insertTag(event.detail.value);
inputField.focus();
}
- function handleAddTag(event) {
+ function handleAddTag(event: AddtagEvent) {
// Ignore if not in tag edit mode
if (container.classList.contains('hidden')) return;
@@ -55,14 +61,16 @@ function setupTagsInput(tagBlock) {
event.stopPropagation();
}
- function handleTagClear(event) {
- if (event.target.dataset.tagName) {
+ function handleTagClear(event: Event) {
+ const target = assertType(event.target, HTMLElement);
+
+ if (target.dataset.tagName) {
event.preventDefault();
- removeTag(event.target.dataset.tagName, event.target.parentNode);
+ removeTag(target.dataset.tagName, assertNotNull(target.parentElement));
}
}
- function handleKeyEvent(event) {
+ function handleKeyEvent(event: KeyboardEvent) {
const { keyCode, ctrlKey, shiftKey } = event;
// allow form submission with ctrl+enter if no text was typed
@@ -73,7 +81,7 @@ function setupTagsInput(tagBlock) {
// backspace on a blank input field
if (keyCode === 8 && inputField.value === '') {
event.preventDefault();
- const erased = $('.tag:last-of-type', container);
+ const erased = $('.tag:last-of-type', container);
if (erased) removeTag(tags[tags.length - 1], erased);
}
@@ -86,14 +94,14 @@ function setupTagsInput(tagBlock) {
}
}
- function handleCtrlEnter(event) {
+ function handleCtrlEnter(event: KeyboardEvent) {
const { keyCode, ctrlKey } = event;
if (keyCode !== 13 || !ctrlKey) return;
- $('[type="submit"]', tagBlock.closest('form')).click();
+ submitButton.click();
}
- function insertTag(name) {
+ function insertTag(name: string) {
name = name.trim(); // eslint-disable-line no-param-reassign
// Add if not degenerate or already present
@@ -102,9 +110,9 @@ function setupTagsInput(tagBlock) {
// Remove instead if the tag name starts with a minus
if (name[0] === '-') {
name = name.slice(1); // eslint-disable-line no-param-reassign
- const tagLink = $(`[data-tag-name="${escapeCss(name)}"]`, container);
+ const tagLink = assertNotNull($(`[data-tag-name="${escapeCss(name)}"]`, container));
- return removeTag(name, tagLink.parentNode);
+ return removeTag(name, assertNotNull(tagLink.parentElement));
}
tags.push(name);
@@ -116,7 +124,7 @@ function setupTagsInput(tagBlock) {
inputField.value = '';
}
- function removeTag(name, element) {
+ function removeTag(name: string, element: HTMLElement) {
removeEl(element);
// Remove the tag from the list
@@ -134,7 +142,7 @@ function setupTagsInput(tagBlock) {
}
}
-function fancyEditorRequested(tagBlock) {
+function fancyEditorRequested(tagBlock: HTMLDivElement) {
// Check whether the user made the fancy editor the default for each type of tag block.
return (
(window.booru.fancyTagUpload && tagBlock.classList.contains('fancy-tag-upload')) ||
@@ -142,19 +150,17 @@ function fancyEditorRequested(tagBlock) {
);
}
-function setupTagListener() {
+export function setupTagListener() {
document.addEventListener('addtag', event => {
if (event.target.value) event.target.value += ', ';
event.target.value += event.detail.name;
});
}
-function addTag(textarea, name) {
+export function addTag(textarea: HTMLInputElement | HTMLTextAreaElement, name: string) {
textarea.dispatchEvent(new CustomEvent('addtag', { detail: { name }, bubbles: true }));
}
-function reloadTagsInput(textarea) {
+export function reloadTagsInput(textarea: HTMLInputElement | HTMLTextAreaElement) {
textarea.dispatchEvent(new CustomEvent('reload'));
}
-
-export { setupTagsInput, setupTagListener, addTag, reloadTagsInput };
diff --git a/assets/test/vitest-setup.ts b/assets/test/vitest-setup.ts
index 7c0d2d629..ac349f860 100644
--- a/assets/test/vitest-setup.ts
+++ b/assets/test/vitest-setup.ts
@@ -7,6 +7,8 @@ import { fireEvent } from '@testing-library/dom';
window.booru = {
timeAgo: () => {},
csrfToken: 'mockCsrfToken',
+ fancyTagEdit: true,
+ fancyTagUpload: true,
hiddenTag: '/mock-tagblocked.svg',
hiddenTagList: [],
hideStaffTools: 'true',
diff --git a/assets/types/booru-object.d.ts b/assets/types/booru-object.d.ts
index 4154385c8..8b04cced7 100644
--- a/assets/types/booru-object.d.ts
+++ b/assets/types/booru-object.d.ts
@@ -73,6 +73,14 @@ interface BooruObject {
* List of image IDs in the current gallery.
*/
galleryImages?: number[];
+ /**
+ * Fancy tag setting for uploading images.
+ */
+ fancyTagUpload: boolean;
+ /**
+ * Fancy tag setting for editing images.
+ */
+ fancyTagEdit: boolean;
}
declare global {
diff --git a/assets/types/tags.ts b/assets/types/tags.ts
new file mode 100644
index 000000000..b132dbf08
--- /dev/null
+++ b/assets/types/tags.ts
@@ -0,0 +1,20 @@
+export {};
+
+declare global {
+ interface Addtag {
+ name: string;
+ }
+
+ interface AddtagEvent extends CustomEvent {
+ target: HTMLInputElement | HTMLTextAreaElement;
+ }
+
+ interface ReloadEvent extends CustomEvent {
+ target: HTMLInputElement | HTMLTextAreaElement;
+ }
+
+ interface GlobalEventHandlersEventMap {
+ addtag: AddtagEvent;
+ reload: ReloadEvent;
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 5b102408a..a12334188 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -64,16 +64,14 @@ services:
- POSTGRES_PASSWORD=postgres
volumes:
- postgres_data:/var/lib/postgresql/data
- logging:
- driver: "none"
+ attach: false
opensearch:
image: opensearchproject/opensearch:2.16.0
volumes:
- opensearch_data:/usr/share/opensearch/data
- ./docker/opensearch/opensearch.yml:/usr/share/opensearch/config/opensearch.yml
- logging:
- driver: "none"
+ attach: false
ulimits:
nofile:
soft: 65536
@@ -81,8 +79,7 @@ services:
valkey:
image: valkey/valkey:8.0-alpine
- logging:
- driver: "none"
+ attach: false
files:
image: andrewgaul/s3proxy:sha-4976e17
@@ -90,6 +87,7 @@ services:
- JCLOUDS_FILESYSTEM_BASEDIR=/srv/philomena/priv/s3
volumes:
- .:/srv/philomena
+ attach: false
web:
build:
@@ -106,8 +104,7 @@ services:
environment:
- AWS_ACCESS_KEY_ID=local-identity
- AWS_SECRET_ACCESS_KEY=local-credential
- logging:
- driver: "none"
+ attach: false
depends_on:
- app
ports:
diff --git a/lib/philomena_proxy/scrapers/bluesky.ex b/lib/philomena_proxy/scrapers/bluesky.ex
index 598d14706..e67672b21 100644
--- a/lib/philomena_proxy/scrapers/bluesky.ex
+++ b/lib/philomena_proxy/scrapers/bluesky.ex
@@ -19,23 +19,25 @@ defmodule PhilomenaProxy.Scrapers.Bluesky do
def scrape(_uri, url) do
[handle, id] = Regex.run(@url_regex, url, capture: :all_but_first)
- api_url_resolve_handle =
- "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=#{handle}"
-
- did = PhilomenaProxy.Http.get(api_url_resolve_handle) |> json!() |> Map.fetch!(:did)
+ did = fetch_did(handle)
api_url_get_posts =
"https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts?uris=at://#{did}/app.bsky.feed.post/#{id}"
- post_json = PhilomenaProxy.Http.get(api_url_get_posts) |> json!() |> Map.fetch!(:posts) |> hd
+ post_json =
+ api_url_get_posts
+ |> PhilomenaProxy.Http.get()
+ |> json!()
+ |> Map.fetch!("posts")
+ |> hd()
%{
source_url: url,
- author_name: post_json["author"]["handle"],
+ author_name: domain_first_component(post_json["author"]["handle"]),
description: post_json["record"]["text"],
images:
- post_json["embed"]["images"]
- |> Enum.map(
+ Enum.map(
+ post_json["embed"]["images"],
&%{
url: String.replace(&1["fullsize"], @fullsize_image_regex, @blob_image_url_pattern),
camo_url: PhilomenaProxy.Camo.image_url(&1["thumb"])
@@ -44,5 +46,31 @@ defmodule PhilomenaProxy.Scrapers.Bluesky do
}
end
+ defp fetch_did(handle) do
+ case handle do
+ <<"did:", _rest::binary>> ->
+ handle
+
+ _ ->
+ api_url_resolve_handle =
+ "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=#{handle}"
+
+ api_url_resolve_handle
+ |> PhilomenaProxy.Http.get()
+ |> json!()
+ |> Map.fetch!("did")
+ end
+ end
+
+ defp domain_first_component(domain) do
+ case String.split(domain, ".") do
+ [name | _] ->
+ name
+
+ _ ->
+ domain
+ end
+ end
+
defp json!({:ok, %{body: body, status: 200}}), do: Jason.decode!(body)
end
diff --git a/lib/philomena_web/controllers/image/source_controller.ex b/lib/philomena_web/controllers/image/source_controller.ex
index 373dcc108..2dbc4f475 100644
--- a/lib/philomena_web/controllers/image/source_controller.ex
+++ b/lib/philomena_web/controllers/image/source_controller.ex
@@ -4,6 +4,7 @@ defmodule PhilomenaWeb.Image.SourceController do
alias Philomena.SourceChanges.SourceChange
alias Philomena.UserStatistics
alias Philomena.Images.Image
+ alias Philomena.Images.Source
alias Philomena.Images
alias Philomena.Repo
import Ecto.Query
@@ -41,7 +42,9 @@ defmodule PhilomenaWeb.Image.SourceController do
PhilomenaWeb.Api.Json.ImageView.render("show.json", %{image: image, interactions: []})
)
- changeset = Images.change_image(image)
+ changeset =
+ %{image | sources: sources_for_edit(image.sources)}
+ |> Images.change_image()
source_change_count =
SourceChange
@@ -74,4 +77,9 @@ defmodule PhilomenaWeb.Image.SourceController do
)
end
end
+
+ # TODO: this is duplicated in ImageController
+ defp sources_for_edit(), do: [%Source{}]
+ defp sources_for_edit([]), do: sources_for_edit()
+ defp sources_for_edit(sources), do: sources
end
diff --git a/lib/philomena_web/controllers/image_controller.ex b/lib/philomena_web/controllers/image_controller.ex
index 990fcfd3b..4088fe560 100644
--- a/lib/philomena_web/controllers/image_controller.ex
+++ b/lib/philomena_web/controllers/image_controller.ex
@@ -219,6 +219,7 @@ defmodule PhilomenaWeb.ImageController do
end
end
+ # TODO: this is duplicated in Image.SourceController
defp sources_for_edit(), do: [%Source{}]
defp sources_for_edit([]), do: sources_for_edit()
defp sources_for_edit(sources), do: sources
diff --git a/lib/philomena_web/templates/commission/_directory_results.html.slime b/lib/philomena_web/templates/commission/_directory_results.html.slime
index a5e7ed0d2..3e8b1aebc 100644
--- a/lib/philomena_web/templates/commission/_directory_results.html.slime
+++ b/lib/philomena_web/templates/commission/_directory_results.html.slime
@@ -62,3 +62,6 @@ elixir:
- true ->
p We couldn't find any commission listings to display. Sorry!
+
+ .block__header.page__header
+ .page__pagination = pagination
diff --git a/lib/philomena_web/templates/search/_form.html.slime b/lib/philomena_web/templates/search/_form.html.slime
index efc065af7..7d0f080a5 100644
--- a/lib/philomena_web/templates/search/_form.html.slime
+++ b/lib/philomena_web/templates/search/_form.html.slime
@@ -1,7 +1,7 @@
h1 Search
= form_for :search, ~p"/search", [id: "searchform", method: "get", class: "js-search-form", enforce_utf8: false], fn f ->
- = text_input f, :q, class: "input input--wide js-search-field", placeholder: "Search terms are chained with commas", autocapitalize: "none", name: "q", value: @conn.params["q"]
+ = text_input f, :q, class: "input input--wide js-search-field", placeholder: "Search terms are chained with commas", autocapitalize: "none", name: "q", value: @conn.params["q"], data: [ac: "true", ac_min_length: 3, ac_mode: "search"]
.block
.block__header.flex