feat(mediakit): add unsplash search filters and pagination
All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m16s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 4m50s
Build and Release / Lint (push) Successful in 4m58s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 2m54s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h4m25s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 7m44s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 7m46s
Build and Release / Build Binary (linux/arm64) (push) Successful in 8m1s
All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m16s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 4m50s
Build and Release / Lint (push) Successful in 4m58s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 2m54s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h4m25s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 7m44s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 7m46s
Build and Release / Build Binary (linux/arm64) (push) Successful in 8m1s
Add orientation filter (landscape, portrait, square) and color filter (12 color options including black & white) to Unsplash search. Implement pagination with prev/next buttons and page info display. Update API response structure to include total pages and current page. Add locale strings for all filter options. Update max image size message from 2MB to 5MB.
This commit is contained in:
@@ -4048,12 +4048,28 @@
|
||||
"repo.settings.media_kit.bg_image_help": "Upload a JPG or PNG image (max 2 MB) or search Unsplash.",
|
||||
"repo.settings.media_kit.upload": "Upload Image",
|
||||
"repo.settings.media_kit.upload_error": "Failed to upload image.",
|
||||
"repo.settings.media_kit.image_too_large": "Image must be smaller than 2 MB.",
|
||||
"repo.settings.media_kit.image_too_large": "Image must be smaller than 5 MB.",
|
||||
"repo.settings.media_kit.not_an_image": "File must be a JPG or PNG image.",
|
||||
"repo.settings.media_kit.image_uploaded": "Background image uploaded.",
|
||||
"repo.settings.media_kit.delete_image": "Remove Image",
|
||||
"repo.settings.media_kit.unsplash_search": "Search Unsplash",
|
||||
"repo.settings.media_kit.unsplash_placeholder": "Search for photos…",
|
||||
"repo.settings.media_kit.unsplash_orientation_any": "Any Orientation",
|
||||
"repo.settings.media_kit.unsplash_orientation_landscape": "Landscape",
|
||||
"repo.settings.media_kit.unsplash_orientation_portrait": "Portrait",
|
||||
"repo.settings.media_kit.unsplash_orientation_square": "Square",
|
||||
"repo.settings.media_kit.unsplash_color_any": "Any Color",
|
||||
"repo.settings.media_kit.unsplash_color_bw": "Black & White",
|
||||
"repo.settings.media_kit.unsplash_color_black": "Black",
|
||||
"repo.settings.media_kit.unsplash_color_white": "White",
|
||||
"repo.settings.media_kit.unsplash_color_yellow": "Yellow",
|
||||
"repo.settings.media_kit.unsplash_color_orange": "Orange",
|
||||
"repo.settings.media_kit.unsplash_color_red": "Red",
|
||||
"repo.settings.media_kit.unsplash_color_purple": "Purple",
|
||||
"repo.settings.media_kit.unsplash_color_magenta": "Magenta",
|
||||
"repo.settings.media_kit.unsplash_color_green": "Green",
|
||||
"repo.settings.media_kit.unsplash_color_teal": "Teal",
|
||||
"repo.settings.media_kit.unsplash_color_blue": "Blue",
|
||||
"repo.settings.media_kit.unsplash_attribution": "Photo by %s on Unsplash",
|
||||
"repo.settings.media_kit.preview": "Preview",
|
||||
"repo.settings.license": "License",
|
||||
|
||||
@@ -165,6 +165,35 @@ type unsplashSearchResult struct {
|
||||
AuthorURL string `json:"author_url"`
|
||||
}
|
||||
|
||||
// unsplashSearchResponse wraps results with pagination info.
|
||||
type unsplashSearchResponse struct {
|
||||
Results []unsplashSearchResult `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
// validUnsplashOrientations is the set of accepted orientation values.
|
||||
var validUnsplashOrientations = map[string]bool{
|
||||
"landscape": true,
|
||||
"portrait": true,
|
||||
"squarish": true,
|
||||
}
|
||||
|
||||
// validUnsplashColors is the set of accepted color filter values.
|
||||
var validUnsplashColors = map[string]bool{
|
||||
"black_and_white": true,
|
||||
"black": true,
|
||||
"white": true,
|
||||
"yellow": true,
|
||||
"orange": true,
|
||||
"red": true,
|
||||
"purple": true,
|
||||
"magenta": true,
|
||||
"green": true,
|
||||
"teal": true,
|
||||
"blue": true,
|
||||
}
|
||||
|
||||
// MediaKitUnsplashSearch proxies a search request to the Unsplash API.
|
||||
func MediaKitUnsplashSearch(ctx *context.Context) {
|
||||
if !setting.Unsplash.Enabled {
|
||||
@@ -174,7 +203,7 @@ func MediaKitUnsplashSearch(ctx *context.Context) {
|
||||
|
||||
query := ctx.FormString("q")
|
||||
if query == "" {
|
||||
ctx.JSON(http.StatusOK, []unsplashSearchResult{})
|
||||
ctx.JSON(http.StatusOK, unsplashSearchResponse{Results: []unsplashSearchResult{}})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -183,10 +212,17 @@ func MediaKitUnsplashSearch(ctx *context.Context) {
|
||||
page = "1"
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.unsplash.com/search/photos?query=%s&orientation=landscape&per_page=12&page=%s",
|
||||
apiURL := fmt.Sprintf("https://api.unsplash.com/search/photos?query=%s&per_page=12&page=%s",
|
||||
query, page)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if orientation := ctx.FormString("orientation"); validUnsplashOrientations[orientation] {
|
||||
apiURL += "&orientation=" + orientation
|
||||
}
|
||||
if color := ctx.FormString("color"); validUnsplashColors[color] {
|
||||
apiURL += "&color=" + color
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
ctx.ServerError("NewRequest", err)
|
||||
return
|
||||
@@ -220,6 +256,7 @@ func MediaKitUnsplashSearch(ctx *context.Context) {
|
||||
} `json:"links"`
|
||||
} `json:"user"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
@@ -238,7 +275,18 @@ func MediaKitUnsplashSearch(ctx *context.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, results)
|
||||
pageNum := 1
|
||||
if p := ctx.FormString("page"); p != "" {
|
||||
if n, err := fmt.Sscanf(p, "%d", &pageNum); n != 1 || err != nil {
|
||||
pageNum = 1
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, unsplashSearchResponse{
|
||||
Results: results,
|
||||
TotalPages: apiResp.TotalPages,
|
||||
Page: pageNum,
|
||||
})
|
||||
}
|
||||
|
||||
// MediaKitUnsplashSelect downloads an Unsplash photo and saves it as the bg image.
|
||||
|
||||
@@ -92,12 +92,39 @@
|
||||
{{svg "octicon-search" 16}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-2 tw-mt-2 tw-flex-wrap tw-items-center">
|
||||
<select id="unsplash-orientation" class="tw-text-sm" style="padding: 4px 8px; border: 1px solid var(--color-secondary); border-radius: 4px;">
|
||||
<option value="">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_orientation_any"}}</option>
|
||||
<option value="landscape">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_orientation_landscape"}}</option>
|
||||
<option value="portrait">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_orientation_portrait"}}</option>
|
||||
<option value="squarish">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_orientation_square"}}</option>
|
||||
</select>
|
||||
<select id="unsplash-color" class="tw-text-sm" style="padding: 4px 8px; border: 1px solid var(--color-secondary); border-radius: 4px;">
|
||||
<option value="">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_color_any"}}</option>
|
||||
<option value="black_and_white">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_color_bw"}}</option>
|
||||
<option value="black">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_color_black"}}</option>
|
||||
<option value="white">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_color_white"}}</option>
|
||||
<option value="yellow">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_color_yellow"}}</option>
|
||||
<option value="orange">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_color_orange"}}</option>
|
||||
<option value="red">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_color_red"}}</option>
|
||||
<option value="purple">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_color_purple"}}</option>
|
||||
<option value="magenta">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_color_magenta"}}</option>
|
||||
<option value="green">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_color_green"}}</option>
|
||||
<option value="teal">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_color_teal"}}</option>
|
||||
<option value="blue">{{ctx.Locale.Tr "repo.settings.media_kit.unsplash_color_blue"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="unsplash-results" class="tw-grid tw-gap-2 tw-mt-3"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));">
|
||||
</div>
|
||||
<div id="unsplash-loading" class="tw-hidden tw-text-center tw-py-4">
|
||||
<div class="ui active inline loader small"></div>
|
||||
</div>
|
||||
<div id="unsplash-pagination" class="tw-hidden tw-flex tw-justify-center tw-items-center tw-gap-3 tw-mt-3">
|
||||
<button class="ui mini button" type="button" id="unsplash-prev" disabled>{{svg "octicon-chevron-left" 14}}</button>
|
||||
<span id="unsplash-page-info" class="tw-text-sm"></span>
|
||||
<button class="ui mini button" type="button" id="unsplash-next">{{svg "octicon-chevron-right" 14}}</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,70 +205,120 @@
|
||||
const searchInput = document.getElementById('unsplash-query');
|
||||
const resultsDiv = document.getElementById('unsplash-results');
|
||||
const loadingDiv = document.getElementById('unsplash-loading');
|
||||
const orientationSel = document.getElementById('unsplash-orientation');
|
||||
const colorSel = document.getElementById('unsplash-color');
|
||||
const paginationDiv = document.getElementById('unsplash-pagination');
|
||||
const prevBtn = document.getElementById('unsplash-prev');
|
||||
const nextBtn = document.getElementById('unsplash-next');
|
||||
const pageInfo = document.getElementById('unsplash-page-info');
|
||||
|
||||
if (searchBtn) {
|
||||
async function doSearch() {
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
|
||||
function buildSearchURL(page) {
|
||||
let url = '{{.Link}}/unsplash/search?q=' + encodeURIComponent(searchInput.value.trim()) + '&page=' + page;
|
||||
if (orientationSel && orientationSel.value) url += '&orientation=' + orientationSel.value;
|
||||
if (colorSel && colorSel.value) url += '&color=' + colorSel.value;
|
||||
return url;
|
||||
}
|
||||
|
||||
function renderPhoto(photo) {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'cursor:pointer;border-radius:6px;overflow:hidden;border:2px solid transparent;position:relative;';
|
||||
el.innerHTML = '<img src="' + photo.thumb + '" style="width:100%;height:100px;object-fit:cover;display:block;" title="Photo by ' + photo.author + '">'
|
||||
+ '<div style="position:absolute;bottom:0;left:0;right:0;padding:2px 6px;background:rgba(0,0,0,0.6);color:#fff;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + photo.author + '</div>';
|
||||
el.addEventListener('click', async function() {
|
||||
resultsDiv.querySelectorAll('div').forEach(function(d) { d.style.borderColor = 'transparent'; });
|
||||
el.style.borderColor = 'var(--color-primary)';
|
||||
el.style.opacity = '0.7';
|
||||
el.style.pointerEvents = 'none';
|
||||
const statusEl = document.createElement('div');
|
||||
statusEl.textContent = 'Downloading image…';
|
||||
statusEl.style.cssText = 'padding:8px;color:var(--color-text);font-size:13px;';
|
||||
resultsDiv.parentNode.insertBefore(statusEl, resultsDiv.nextSibling);
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name=_csrf]')?.content || '';
|
||||
const fd = new FormData();
|
||||
fd.append('id', photo.id);
|
||||
fd.append('author', photo.author);
|
||||
fd.append('url', photo.regular);
|
||||
const resp = await fetch('{{.Link}}/unsplash/select', {
|
||||
method: 'POST',
|
||||
headers: {'X-Csrf-Token': csrfToken},
|
||||
body: fd,
|
||||
});
|
||||
if (resp.ok) {
|
||||
statusEl.textContent = 'Image saved! Reloading…';
|
||||
window.location.reload();
|
||||
} else {
|
||||
let detail = resp.status + ' ' + resp.statusText;
|
||||
try { const j = await resp.json(); if (j.error) detail = j.error; } catch (_) {}
|
||||
statusEl.textContent = 'Failed to save image: ' + detail;
|
||||
statusEl.style.color = 'var(--color-error)';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Unsplash select error:', e);
|
||||
statusEl.textContent = 'Network error: ' + e.message;
|
||||
statusEl.style.color = 'var(--color-error)';
|
||||
} finally {
|
||||
el.style.opacity = '1';
|
||||
el.style.pointerEvents = '';
|
||||
}
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function updatePagination() {
|
||||
if (totalPages <= 1) {
|
||||
paginationDiv.classList.add('tw-hidden');
|
||||
return;
|
||||
}
|
||||
paginationDiv.classList.remove('tw-hidden');
|
||||
pageInfo.textContent = currentPage + ' / ' + totalPages;
|
||||
prevBtn.disabled = currentPage <= 1;
|
||||
nextBtn.disabled = currentPage >= totalPages;
|
||||
}
|
||||
|
||||
async function doSearch(page) {
|
||||
const q = searchInput.value.trim();
|
||||
if (!q) return;
|
||||
if (!page) page = 1;
|
||||
currentPage = page;
|
||||
loadingDiv.classList.remove('tw-hidden');
|
||||
paginationDiv.classList.add('tw-hidden');
|
||||
resultsDiv.innerHTML = '';
|
||||
// Remove any leftover status messages
|
||||
const oldStatus = resultsDiv.parentNode.querySelector('div[style*="padding:8px"]');
|
||||
if (oldStatus) oldStatus.remove();
|
||||
try {
|
||||
const resp = await fetch('{{.Link}}/unsplash/search?q=' + encodeURIComponent(q));
|
||||
const photos = await resp.json();
|
||||
const resp = await fetch(buildSearchURL(page));
|
||||
const data = await resp.json();
|
||||
loadingDiv.classList.add('tw-hidden');
|
||||
totalPages = data.total_pages || 1;
|
||||
currentPage = data.page || page;
|
||||
const photos = data.results || [];
|
||||
photos.forEach(function(photo) {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'cursor:pointer;border-radius:6px;overflow:hidden;border:2px solid transparent;position:relative;';
|
||||
el.innerHTML = '<img src="' + photo.thumb + '" style="width:100%;height:100px;object-fit:cover;display:block;" title="Photo by ' + photo.author + '">'
|
||||
+ '<div style="position:absolute;bottom:0;left:0;right:0;padding:2px 6px;background:rgba(0,0,0,0.6);color:#fff;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + photo.author + '</div>';
|
||||
el.addEventListener('click', async function() {
|
||||
// Deselect all others
|
||||
resultsDiv.querySelectorAll('div').forEach(function(d) { d.style.borderColor = 'transparent'; });
|
||||
el.style.borderColor = 'var(--color-primary)';
|
||||
el.style.opacity = '0.7';
|
||||
el.style.pointerEvents = 'none';
|
||||
// Show a status message
|
||||
const statusEl = document.createElement('div');
|
||||
statusEl.textContent = 'Downloading image…';
|
||||
statusEl.style.cssText = 'padding:8px;color:var(--color-text);font-size:13px;';
|
||||
resultsDiv.parentNode.insertBefore(statusEl, resultsDiv.nextSibling);
|
||||
try {
|
||||
const csrf = document.querySelector('input[name="_csrf"]').value;
|
||||
const fd = new FormData();
|
||||
fd.append('_csrf', csrf);
|
||||
fd.append('id', photo.id);
|
||||
fd.append('author', photo.author);
|
||||
fd.append('url', photo.regular);
|
||||
const resp = await fetch('{{.Link}}/unsplash/select', {method: 'POST', body: fd});
|
||||
if (resp.ok) {
|
||||
statusEl.textContent = 'Image saved! Reloading…';
|
||||
window.location.reload();
|
||||
} else {
|
||||
let detail = resp.status + ' ' + resp.statusText;
|
||||
try { const j = await resp.json(); if (j.error) detail = j.error; } catch (_) {}
|
||||
statusEl.textContent = 'Failed to save image: ' + detail;
|
||||
statusEl.style.color = 'var(--color-error)';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Unsplash select error:', e);
|
||||
statusEl.textContent = 'Network error: ' + e.message;
|
||||
statusEl.style.color = 'var(--color-error)';
|
||||
} finally {
|
||||
el.style.opacity = '1';
|
||||
el.style.pointerEvents = '';
|
||||
}
|
||||
});
|
||||
resultsDiv.appendChild(el);
|
||||
resultsDiv.appendChild(renderPhoto(photo));
|
||||
});
|
||||
updatePagination();
|
||||
} catch (e) {
|
||||
loadingDiv.classList.add('tw-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
searchBtn.addEventListener('click', doSearch);
|
||||
searchBtn.addEventListener('click', function() { doSearch(1); });
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); doSearch(); }
|
||||
if (e.key === 'Enter') { e.preventDefault(); doSearch(1); }
|
||||
});
|
||||
|
||||
// Re-search on filter change if there's already a query
|
||||
if (orientationSel) orientationSel.addEventListener('change', function() { if (searchInput.value.trim()) doSearch(1); });
|
||||
if (colorSel) colorSel.addEventListener('change', function() { if (searchInput.value.trim()) doSearch(1); });
|
||||
|
||||
// Pagination
|
||||
if (prevBtn) prevBtn.addEventListener('click', function() { if (currentPage > 1) doSearch(currentPage - 1); });
|
||||
if (nextBtn) nextBtn.addEventListener('click', function() { if (currentPage < totalPages) doSearch(currentPage + 1); });
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user