diff --git a/docs/features.md b/docs/features.md index 9437cfc..633e75a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -88,6 +88,10 @@ Default reminders wait for required approval and payment. New default reminders ## Shared media +Choose **Photo sharing only** on the Mini App Create page, or use **Photo sharing** on the bot keyboard, to create a standalone collection with just a name. No event date, tickets or RSVP is needed. Each collection has a **viewer link** for browsing and a separate **contributor link** for browsing and uploading photos, videos and files. Share the appropriate link for the access you want to give. Opening either link does not add the person to an event guest list. + +The collection owner can turn **Show who uploaded media** on or off in the Mini App or with the bot's **Uploader names** button. Names are shown by default. Turning this off hides uploader attribution in gallery responses, previews and bot captions, including existing uploads; internal upload records remain intact. User-written captions, filenames and file contents remain visible. + Hosts can separately allow guest uploads and gallery browsing. Collect photos, videos, and files through Telegram, then use **Shared media** in the Mini App to browse previews. Supported images sent as documents and videos can use Telegram thumbnails or supported file previews. The gallery supports **Save** and **Send to my Telegram**. Sending delivers the chosen item to the user's bot chat. Mini App file downloads are capped at 20 MB; larger files can be sent to Telegram for retrieval in chat. Preview and download support depends on the file format and Telegram's available file data. diff --git a/public/app.js b/public/app.js index c817a89..0f25dda 100644 --- a/public/app.js +++ b/public/app.js @@ -96,6 +96,7 @@ function dateInZone(instant, zone) { return `${parts.year}-${parts.month}-${parts.day}`; } function format(e, zone = selectedZone()) { + if(e.kind==='photos')return 'Photo collection'; if (!e.startsAt) return e.when + '\nTimezone not set β€” shown as entered by the organiser.'; let text = new Intl.DateTimeFormat('en-AU', { timeZone: zone, weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }).format(new Date(e.startsAt)) + ` (${zone})`; if (e.endsAt) { @@ -108,6 +109,45 @@ function format(e, zone = selectedZone()) { return text; } function openTelegram(url) { if (tg?.initData) tg.openTelegramLink(url); else window.open(url, '_blank', 'noopener'); } +function appendPhotoLinks(node,event){ + if(event.isOwner){ + const label=element('label','','check-option'),input=element('input');input.type='checkbox';input.checked=event.showUploaders!==false; + input.onchange=async()=>{ + input.disabled=true; + try{const result=await api(`collections/${event.id}/settings`,{showUploaders:input.checked});Object.assign(event,result.event);await refresh();notice(input.checked?'Uploader names are shown.':'Uploader names are hidden.');} + catch(error){input.checked=event.showUploaders!==false;notice(error.message);} + finally{input.disabled=false;} + }; + label.append(input,element('span','Show who uploaded media'));node.append(label); + } + for(const [role,link] of Object.entries(event.photoLinks || {})){ + node.append(action('Share '+role+' link',()=>openTelegram('https://t.me/share/url?url='+encodeURIComponent(link)))); + const label=element('label',role==='viewer'?'Viewer β€” browse only':'Contributor β€” browse and add media'); + const input=element('input');input.value=link;input.readOnly=true;input.setAttribute('aria-label',role+' link');input.onclick=()=>input.select(); + label.append(input);node.append(label); + } +} +function setupPhotoCreation(){ + $('photo-create-panel').ontoggle=()=>{ + if(compactPicker || activeEvent)return; + const photos=$('photo-create-panel').open; + $('event-form').hidden=photos; + $('success').hidden=true; + $('form-title').textContent=photos?'Share your memories.':'Make a plan.'; + $('form-description').textContent=photos?'Create a collection and choose which link to share.':'Pick a date. Share an invite. Let the good times follow.'; + }; + let photoRequestId=crypto.randomUUID(); + $('photo-form').onsubmit=async event=>{ + event.preventDefault();const button=$('photo-create');if(button.disabled)return; + button.disabled=true;$('photo-error').hidden=true; + try{ + const result=await api('collections',{title:$('photo-title').value,showUploaders:$('photo-show-uploaders').checked,requestId:photoRequestId}); + const links=$('photo-links');links.replaceChildren(element('h3',result.event.title),action('Open collection',()=>openGallery(result.event.id)));appendPhotoLinks(links,result.event);links.hidden=false; + await refresh();photoRequestId=crypto.randomUUID(); + }catch(error){$('photo-error').textContent=error.message;$('photo-error').hidden=false;} + finally{button.disabled=false;} + }; +} let guestListBaseline=''; function invitationGuestLine(guest){return guest.name+(guest.participantMode==='ask' ? ' = ?' : guest.participants ? ' = '+guest.participants+(guest.participantMode==='confirm' ? '!' : guest.participantMode==='fixed' ? '*' : '') : '');} function guestNamesIn(text){return text.split('\n').map(line=>line.split('=')[0].trim()).filter(Boolean);} @@ -385,6 +425,10 @@ function renderEvents() { for (const e of events) { if (listFilter === 'all' && e.group !== lastGroup) { list.append(element('h2', e.group, 'event-group')); lastGroup = e.group; } const card = element('article', '', 'event-card');card.id='event-card-'+e.id;card.tabIndex=-1; + if(e.kind==='photos'){ + card.append(element('span','PHOTO SHARING','tag'),element('h3',e.title),action('Open collection',()=>openGallery(e.id))); + appendPhotoLinks(card,e);list.append(card);continue; + } if (e.hasBanner) { const img = document.createElement('img'); img.className = 'event-banner'; img.alt = `Banner for ${e.title}`; card.append(img); fetch(`/api/events/${e.id}/banner`, { headers: { Authorization: 'tma ' + initData } }).then(r => { if (!r.ok) throw new Error(); return r.blob(); }).then(blob => { if (!img.isConnected) return; const old = bannerUrls.get(e.id); if (old) URL.revokeObjectURL(old); const url = URL.createObjectURL(blob); bannerUrls.set(e.id,url); img.src=url; }).catch(() => img.remove()); @@ -619,6 +663,8 @@ async function loadBannerPreview(id,generation){ }catch(error){if(generation===bannerLoadGeneration)window.reportAppError?.(error,'Banner preview');} } function setupForm(event = null) { + $('photo-create-panel').hidden=compactPicker || !!event; + $('photo-create-panel').open=false; if(formSaving){notice('Your event is saving. Please wait.');return;} formReady=true; activeEvent = event; createdEvent = null; requestId = crypto.randomUUID(); @@ -1038,6 +1084,7 @@ $('profile-photo-form').onsubmit=async event=>{ }; $('profile-photo-remove').onclick=async()=>{const button=$('profile-photo-remove');button.disabled=true;try{const r=await fetch('/api/profile/photo',{method:'DELETE',headers:{Authorization:'tma '+initData}});if(!r.ok)throw Error('Could not remove photo.');state.preference.hasPhoto=false;button.hidden=true;await loadProfilePhoto();$('profile-photo-status').textContent='Photo removed.';}catch(error){$('profile-photo-status').textContent=error.message;}finally{button.disabled=false;}}; $('another-event').onclick = () => go('events'); +setupPhotoCreation(); if (!initData) { notice('This launch did not include your Telegram login. Reopen using App in the bot menu or the App button in a message.'); const empty=element('div','Your events are private. Use the authenticated App button to load them.','empty'); diff --git a/public/gallery.js b/public/gallery.js index 4a8d300..3accb32 100644 --- a/public/gallery.js +++ b/public/gallery.js @@ -40,13 +40,13 @@ export function setupGallery({ $, api, element, action, go, notice, openTelegram const kind=file.previewKind || (['photo','video'].includes(file.type)?file.type:null),previewFile={...file,type:kind}; const card=element('article','','gallery-item');const body=element('div','','gallery-item-body'); if(kind==='photo' || (kind==='video' && file.hasThumbnail)) { - const img=document.createElement('img');img.alt=file.caption || `${kind==='video'?'Video preview':'Photo'} shared by ${file.name}`;img.loading='lazy';card.append(img); + const img=document.createElement('img');img.alt=file.caption || `${kind==='video'?'Video preview':'Photo'}${file.name?' shared by '+file.name:''}`;img.loading='lazy';card.append(img); const observer=new IntersectionObserver(async entries=>{if(!entries.some(entry=>entry.isIntersecting))return;observer.disconnect();try{const blob=await mediaBlob(id,file.id,file.hasThumbnail);if(!img.isConnected || current!==generation)return;const url=URL.createObjectURL(blob);urls.add(url);img.src=url;img.onclick=async()=>{const pending=++popupGeneration;try{const full=file.hasThumbnail?await mediaBlob(id,file.id):blob;if(!img.isConnected || current!==generation || pending!==popupGeneration)return;const fullUrl=file.hasThumbnail?URL.createObjectURL(full):url;urls.add(fullUrl);showMedia(id,previewFile,full,fullUrl);}catch(error){if(current===generation && pending===popupGeneration)notice(error.message);}};}catch(error){if(current!==generation)return;img.remove();body.prepend(element('p',error.message,'small muted'));}});observer.observe(img); }else if(kind==='video'){ const video=document.createElement('video');video.muted=true;video.playsInline=true;video.preload='metadata';video.setAttribute('aria-label',file.caption || 'Video preview');card.append(video); const observer=new IntersectionObserver(async entries=>{if(!entries.some(entry=>entry.isIntersecting))return;observer.disconnect();try{const blob=await mediaBlob(id,file.id);if(!video.isConnected || current!==generation)return;const url=URL.createObjectURL(blob);urls.add(url);video.src=url;video.onloadeddata=()=>{try{video.currentTime=.1;}catch{}};video.onclick=()=>{if(current!==generation)return;++popupGeneration;showMedia(id,previewFile,blob,url);};}catch(error){if(current!==generation)return;video.remove();body.prepend(element('p',error.message,'small muted'));}});observer.observe(video); } - body.append(element('strong',file.filename==='photo'?'Photo':file.filename),element('p',`Shared by ${file.name}${file.caption?'\n'+file.caption:''}`,'small muted')); + body.append(element('strong',file.filename==='photo'?'Photo':file.filename),element('p',[file.name?'Shared by '+file.name:'',file.caption].filter(Boolean).join('\n'),'small muted')); const buttons=element('div','','event-actions'); buttons.append(action('Download',async()=>{try{saveBlob(await mediaBlob(id,file.id),file.filename==='photo'?'photo.jpg':file.filename==='video'?'video.mp4':file.filename);}catch(error){notice(error.message);}})); buttons.append(action('Send to Telegram',async()=>{try{await api(`events/${id}/media/${file.id}/send`,{});notice('File sent to your Telegram chat.');}catch(error){notice(error.message);}})); diff --git a/public/index.html b/public/index.html index c4aab93..ca1eb1b 100644 --- a/public/index.html +++ b/public/index.html @@ -27,6 +27,7 @@
Loading your plans…