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 = `
+ + NOT + Numeric ID + My favorites + + +
`; + +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