feat: add shareable meme permalinks
This commit is contained in:
+133
-7
@@ -19,15 +19,23 @@ const submitUpload = document.querySelector('#submit-upload');
|
||||
const lightbox = document.querySelector('#lightbox');
|
||||
const lightboxImage = document.querySelector('#lightbox-image');
|
||||
const lightboxDownload = document.querySelector('#lightbox-download');
|
||||
const lightboxShare = document.querySelector('#lightbox-share');
|
||||
const lightboxId = document.querySelector('#lightbox-id');
|
||||
const shareModal = document.querySelector('#share-modal');
|
||||
const shareLink = document.querySelector('#share-link');
|
||||
const shareStatus = document.querySelector('#share-status');
|
||||
const copyShareLinkButton = document.querySelector('#copy-share-link');
|
||||
const scrollIndicator = document.querySelector('#scroll-indicator');
|
||||
|
||||
let currentPage = 0;
|
||||
let totalPages = 1;
|
||||
let isLoading = false;
|
||||
let latestValidationRun = 0;
|
||||
let activeShareTrigger = null;
|
||||
let shareStatusTimer = null;
|
||||
const viewedThisSession = new Set();
|
||||
const pendingViewTimers = new Map();
|
||||
const copiedFeedbackTimers = new WeakMap();
|
||||
|
||||
document.querySelector('#open-upload').addEventListener('click', () => {
|
||||
formStatus.textContent = '';
|
||||
@@ -36,6 +44,11 @@ document.querySelector('#open-upload').addEventListener('click', () => {
|
||||
document.querySelector('#close-upload').addEventListener('click', closeUpload);
|
||||
document.querySelector('#cancel-upload').addEventListener('click', closeUpload);
|
||||
document.querySelector('#close-lightbox').addEventListener('click', () => lightbox.close());
|
||||
document.querySelector('#close-share').addEventListener('click', () => shareModal.close());
|
||||
copyShareLinkButton.addEventListener('click', copyActiveShareLink);
|
||||
lightboxShare.addEventListener('click', async () => {
|
||||
await openShareDialog(lightboxShare.dataset.shareId, lightboxShare);
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', async () => {
|
||||
const validationRun = ++latestValidationRun;
|
||||
@@ -82,15 +95,21 @@ uploadForm.addEventListener('submit', async (event) => {
|
||||
});
|
||||
|
||||
grid.addEventListener('click', async (event) => {
|
||||
const shareButton = event.target.closest('[data-share-id]');
|
||||
if (shareButton) {
|
||||
await openShareDialog(shareButton.dataset.shareId, shareButton);
|
||||
return;
|
||||
}
|
||||
|
||||
const viewButton = event.target.closest('[data-view-id]');
|
||||
if (!viewButton) return;
|
||||
const card = viewButton.closest('.meme-card');
|
||||
lightboxImage.src = viewButton.dataset.viewUrl;
|
||||
lightboxImage.alt = card.querySelector('img').alt;
|
||||
lightboxDownload.href = viewButton.dataset.downloadUrl;
|
||||
lightboxId.textContent = `ID: ${shortId(viewButton.dataset.viewId)}`;
|
||||
lightbox.showModal();
|
||||
await recordViewOnce(viewButton.dataset.viewId);
|
||||
await openLightbox({
|
||||
id: viewButton.dataset.viewId,
|
||||
url: viewButton.dataset.viewUrl,
|
||||
downloadUrl: viewButton.dataset.downloadUrl,
|
||||
alt: card.querySelector('img').alt
|
||||
});
|
||||
});
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
@@ -109,6 +128,7 @@ window.addEventListener('scroll', updateScrollIndicator, { passive: true });
|
||||
window.addEventListener('resize', updateScrollIndicator);
|
||||
refreshStatus();
|
||||
window.setInterval(refreshStatus, 5000);
|
||||
await openSharedMemeFromUrl();
|
||||
await loadMemes(1, { reset: true });
|
||||
observer.observe(feedLoader);
|
||||
|
||||
@@ -170,7 +190,8 @@ function renderMemes(memes, options = {}) {
|
||||
</div>
|
||||
<div class="action-group">
|
||||
<a class="text-action icon-only-action" href="${meme.downloadUrl}" aria-label="Download meme" title="Download meme"><span class="material-symbols-outlined" aria-hidden="true">download</span></a>
|
||||
<button class="text-action text-action-accent icon-only-action" type="button" data-view-id="${meme.id}" data-view-url="${meme.url}" data-download-url="${meme.downloadUrl}" aria-label="View full meme" title="View full meme"><span class="material-symbols-outlined" aria-hidden="true">visibility</span></button>
|
||||
<button class="text-action icon-only-action" type="button" data-share-id="${meme.id}" aria-label="Share meme" title="Share meme"><span class="material-symbols-outlined" aria-hidden="true">share</span></button>
|
||||
<button class="text-action icon-only-action" type="button" data-view-id="${meme.id}" data-view-url="${meme.url}" data-download-url="${meme.downloadUrl}" aria-label="View full meme" title="View full meme"><span class="material-symbols-outlined" aria-hidden="true">visibility</span></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -187,6 +208,111 @@ function renderMemes(memes, options = {}) {
|
||||
observeViewedImages(cards);
|
||||
}
|
||||
|
||||
async function openSharedMemeFromUrl() {
|
||||
const match = window.location.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/);
|
||||
if (!match) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/memes/${match[1]}`);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || 'Meme not found.');
|
||||
await openLightbox({
|
||||
...payload.meme,
|
||||
alt: `Shared meme ${shortId(payload.meme.id)}`
|
||||
});
|
||||
} catch {
|
||||
window.location.replace('/');
|
||||
}
|
||||
}
|
||||
|
||||
async function openLightbox({ id, url, downloadUrl, alt }) {
|
||||
lightboxImage.src = url;
|
||||
lightboxImage.alt = alt;
|
||||
lightboxDownload.href = downloadUrl;
|
||||
lightboxShare.dataset.shareId = id;
|
||||
lightboxId.textContent = `ID: ${shortId(id)}`;
|
||||
if (!lightbox.open) lightbox.showModal();
|
||||
await recordViewOnce(id);
|
||||
}
|
||||
|
||||
async function openShareDialog(id, triggerButton = null) {
|
||||
if (!isMemeId(id)) return;
|
||||
activeShareTrigger = triggerButton;
|
||||
shareLink.value = new URL(`/meme/${id}`, window.location.origin).href;
|
||||
window.clearTimeout(shareStatusTimer);
|
||||
shareStatusTimer = null;
|
||||
shareStatus.textContent = '';
|
||||
resetCopiedFeedback(copyShareLinkButton);
|
||||
if (!shareModal.open) shareModal.showModal();
|
||||
}
|
||||
|
||||
async function copyActiveShareLink() {
|
||||
if (!shareLink.value) return;
|
||||
copyShareLinkButton.disabled = true;
|
||||
try {
|
||||
await copyText(shareLink.value);
|
||||
shareStatus.textContent = 'LINK_COPIED_TO_CLIPBOARD';
|
||||
showCopiedFeedback(copyShareLinkButton);
|
||||
if (activeShareTrigger) showCopiedFeedback(activeShareTrigger);
|
||||
window.clearTimeout(shareStatusTimer);
|
||||
shareStatusTimer = window.setTimeout(() => {
|
||||
shareStatus.textContent = '';
|
||||
shareStatusTimer = null;
|
||||
}, 2600);
|
||||
} catch {
|
||||
shareLink.focus();
|
||||
shareLink.select();
|
||||
shareStatus.textContent = 'COPY_UNAVAILABLE — SELECT_AND_COPY_THE_LINK';
|
||||
} finally {
|
||||
copyShareLinkButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showCopiedFeedback(button) {
|
||||
const icon = button.querySelector('.material-symbols-outlined');
|
||||
const label = button.querySelector('[data-copy-label]');
|
||||
if (!icon) return;
|
||||
|
||||
button.dataset.defaultIcon ||= icon.textContent.trim();
|
||||
if (label) button.dataset.defaultLabel ||= label.textContent.trim();
|
||||
button.dataset.defaultAriaLabel ||= button.getAttribute('aria-label') || '';
|
||||
button.dataset.defaultTitle ||= button.getAttribute('title') || '';
|
||||
|
||||
icon.textContent = 'check';
|
||||
if (label) label.textContent = 'COPIED';
|
||||
if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', 'Share link copied');
|
||||
if (button.dataset.defaultTitle) button.setAttribute('title', 'Share link copied');
|
||||
button.classList.add('is-copied');
|
||||
|
||||
window.clearTimeout(copiedFeedbackTimers.get(button));
|
||||
copiedFeedbackTimers.set(button, window.setTimeout(() => {
|
||||
resetCopiedFeedback(button);
|
||||
}, 2600));
|
||||
}
|
||||
|
||||
function resetCopiedFeedback(button) {
|
||||
const icon = button.querySelector('.material-symbols-outlined');
|
||||
const label = button.querySelector('[data-copy-label]');
|
||||
window.clearTimeout(copiedFeedbackTimers.get(button));
|
||||
copiedFeedbackTimers.delete(button);
|
||||
if (icon && button.dataset.defaultIcon) icon.textContent = button.dataset.defaultIcon;
|
||||
if (label && button.dataset.defaultLabel) label.textContent = button.dataset.defaultLabel;
|
||||
if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', button.dataset.defaultAriaLabel);
|
||||
if (button.dataset.defaultTitle) button.setAttribute('title', button.dataset.defaultTitle);
|
||||
button.classList.remove('is-copied');
|
||||
}
|
||||
|
||||
async function copyText(value) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
|
||||
shareLink.focus();
|
||||
shareLink.select();
|
||||
if (!document.execCommand('copy')) throw new Error('Clipboard unavailable.');
|
||||
}
|
||||
|
||||
async function validateClientFile(file) {
|
||||
if (!file) return 'SELECT A FILE.';
|
||||
if (!SAFE_TYPES.has(file.type)) return 'ONLY PNG AND JPEG ARE ACCEPTED.';
|
||||
|
||||
@@ -339,12 +339,14 @@ a {
|
||||
}
|
||||
|
||||
.text-action {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #1a1a1a;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
@@ -371,6 +373,12 @@ a {
|
||||
background: var(--surface-high);
|
||||
}
|
||||
|
||||
.text-action.is-copied {
|
||||
border-color: var(--primary);
|
||||
background: rgb(0 255 65 / 12%);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.feed-loader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -523,6 +531,10 @@ dialog::backdrop {
|
||||
box-shadow: 0 0 18px rgb(0 255 65 / 12%);
|
||||
}
|
||||
|
||||
.share-panel {
|
||||
width: min(640px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.lightbox-toolbar {
|
||||
display: flex;
|
||||
@@ -576,6 +588,60 @@ dialog::backdrop {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.share-body {
|
||||
padding: 18px 16px 16px;
|
||||
}
|
||||
|
||||
.share-body label,
|
||||
.share-status {
|
||||
color: var(--outline);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.share-link-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.share-link-row input {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: 0;
|
||||
outline: 0;
|
||||
background: var(--surface-lowest);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.share-link-row input:focus {
|
||||
border-color: var(--primary-dim);
|
||||
}
|
||||
|
||||
.share-copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.share-copy:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.share-status {
|
||||
min-height: 16px;
|
||||
margin: 10px 0 0;
|
||||
color: var(--primary-dim);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -641,12 +707,11 @@ dialog::backdrop {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.lightbox-download {
|
||||
.lightbox-actions {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
margin: 12px 12px 12px auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -752,6 +817,15 @@ dialog::backdrop {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.share-link-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.share-copy {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.shell {
|
||||
padding-top: 84px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user