const DEBUG_ON = true; // served from same path as TWTXT file const TWTXT_FILE_URL = window.location.pathname; const REMEMBER_LOGIN_STORAGE_KEY = 'rememberLogin'; const ACCESS_TOKEN_COOKIE_KEY = 'accessToken'; const debug = (...vals) => { if (DEBUG_ON) console.log(...vals); }; export default (async () => { /* DOM Elements */ const twtForm = document.getElementById('twtForm'), loginForm = document.getElementById('loginControls-form'), fileBox = document.getElementById('fileBox'), fileContentsSection = document.getElementById('fileContentsSection'), toastContainer = document.getElementById('toast-container'), twtControlsContentInput = document.getElementById( 'twtControlsContentInput' ), twtSubmitButton = document.querySelector('.twtControls-submitButton'), twtLogoutButton = document.getElementById('twtControlsLogoutButton'), twtFileEditButton = document.getElementById('twtControlsEditButton'), menuCheckbox = document.getElementById('hamburgerToggleCheckbox'), twtxtEditFormText = document.getElementById('twtxtEditFormText'), uploadInputs = document.querySelectorAll('.twtControls-uploadInput'), rememberToggle = document.getElementById('loginControls-rememberToggle'); const lastModifiedDates = {}; let isEditing = false, cookie, fileText, token; const showToast = (message, type = 'success') => { const toast = document.createElement('div'); toast.classList.add('toast'); if (type === 'error') toast.classList.add('error'); toast.textContent = message; toastContainer.appendChild(toast); setTimeout(() => { toast.style.animation = 'fadeOut 0.5s forwards'; setTimeout(() => toast.remove(), 500); }, 3000); }; const beginEditMode = () => { isEditing = true; if (menuCheckbox) menuCheckbox.checked = false; if (twtSubmitButton) twtSubmitButton.setAttribute('disabled', 'disabled'); document.body.classList.add('js-editMode'); }; const endEditMode = () => { isEditing = false; if (twtSubmitButton) twtSubmitButton.removeAttribute('disabled'); document.body.classList.remove('js-editMode'); }; const getCookie = (name) => { const cookies = document.cookie.split('; '); for (const cookie of cookies) { const [key, value] = cookie.split('='); if (key === name) return decodeURIComponent(value); } return null; }; const setCookie = (name, value, expireDays) => { const isSecure = window.location.protocol === 'https'; const expireDate = new Date(); // current date expireDate.setTime( expireDate.getTime() + (expireDays ?? 0) * 24 * 60 * 60 * 1000 ); let expires = expireDays !== undefined ? `expires=${expireDate.toUTCString()}; ` : ''; document.cookie = `${name}=${encodeURIComponent(value)}; ${expires}${isSecure ? 'Secure; ' : ''}SameSite=Strict; Path=/`; }; const loadTwtxtFile = async (filePath = TWTXT_FILE_URL) => { debug('loadTwtxtFile start'); let response; const fileURL = `${filePath.charAt(0) !== '/' ? '/' : ''}${filePath}`; const headers = new Headers(); headers.set( 'Accept', 'application/twtxt+txt,text/twtxt,text/plain;q=0.9,*/*;q=0.8' ); if (lastModifiedDates[fileURL]) { headers['If-Modified-Since'] = lastModifiedDates[fileURL]; } try { response = await fetch(fileURL, { method: 'GET', headers, mode: 'same-origin', }); if (response?.body) fileText = await new Response(response.body).text(); if (response?.headers.get('Last-Modified')) { lastModifiedDates[fileURL] = response?.headers.get('Last-Modified'); } if (fileText && fileBox) { fileBox.textContent = fileText; } } catch (err) { showToast('Unable to load file, please try again later.', 'error'); console.error('Error loading file', err); } debug('loadTwtxtFile end'); }; const refreshToken = async (hideToast = false) => { debug('refreshToken start', hideToast); const rememberToggleVal = localStorage.getItem(REMEMBER_LOGIN_STORAGE_KEY) === 'true'; const res = await fetch(`${TWTXT_FILE_URL}`, { method: 'POST', body: new URLSearchParams({ rememberToggle: rememberToggleVal, type: 'refresh', }), credentials: 'include', // Include cookies }); if (res.ok && res?.body) { token = await new Response(res.body).text(); if (rememberToggleVal) { debug('refreshToken set new accessToken cookie'); setCookie(ACCESS_TOKEN_COOKIE_KEY, token); } debug('refreshToken end OK'); return; } // Handle refresh failure if (!hideToast) showToast('Unable to refresh token, please try again later.', 'error'); token = undefined; document.body.classList.remove('js-authorized'); debug('refreshToken end error'); throw new Error('Failed to refresh token'); }; const uploadFiles = async (files, uploadRoute, secondAttempt = false) => { if (!uploadRoute) return; debug('uploadFiles', token, files, uploadRoute, secondAttempt); const formData = new FormData(); for (let i = 0; i < files.length; i++) { formData.append('files', files[i]); } try { const res = await fetch(uploadRoute, { method: 'POST', body: formData, headers: { Authorization: `Bearer ${token}`, }, credentials: 'include', }); if (res.ok) { showToast(`File${files.length !== 1 ? 's' : ''} uploaded`); const filePath = await res.text(); twtControlsContentInput.value += filePath .split('\n') .map((currFilePath) => [ ' ', location.protocol, '//', location.hostname, location.protocol !== 'https' && location.port !== 80 ? ':' + location.port : '', currFilePath, ].join('') ) .join(''); return; } if (!secondAttempt) { await refreshToken(); return uploadFiles(files, uploadRoute, true); } showToast( `Unable to upload image${files.length !== 1 ? 's' : ''} refresh token, please try again later.`, 'error' ); } catch (err) { console.error(err); } }; /* Handlers */ const dragOverHandler = (ev) => { const files = [...ev.dataTransfer.items].filter( (item) => item.kind === 'file' ); if (files.length > 0) { ev.preventDefault(); ev.dataTransfer.dropEffect = 'copy'; } }; const dragOverWindowHandler = (ev) => { const files = [...ev.dataTransfer.items].filter( (item) => item.kind === 'file' ); if (files.length > 0) { ev.preventDefault(); if (!twtControlsContentInput.contains(ev.target)) { ev.dataTransfer.dropEffect = 'none'; } } }; const dropHandler = (ev) => { ev.preventDefault(); if (!uploadInputs.length) return; const files = [...ev.dataTransfer.items] .map((item) => item.getAsFile()) .filter((file) => file); debug('dropHandler', files); uploadFiles(files, uploadInputs[0].getAttribute('data-route')); }; const dropWindowHandler = (ev) => { if ([...ev.dataTransfer.items].some((item) => item.kind === 'file')) { ev.preventDefault(); } }; const editClickHandler = () => { if (isEditing) return; if (twtxtEditFormText) twtxtEditFormText.value = fileText; beginEditMode(); }; const editResetHandler = (ev) => { ev.preventDefault(); if (!isEditing && !confirm('Do you want to quit editing?')) return; if (fileText && fileBox) { fileBox.textContent = fileText; } endEditMode(); }; const editSubmitHandler = async (ev, secondAttempt = false) => { ev?.preventDefault(); try { const newFileText = twtxtEditFormText.value; debug('edit file submit', { newFileText }); if (!newFileText) return; const res = await fetch(`${TWTXT_FILE_URL}`, { method: 'PUT', body: new URLSearchParams({ fileContents: newFileText, }), headers: { Authorization: `Bearer ${token}`, }, credentials: 'include', }); if (!res.ok && !secondAttempt) { await refreshToken(); return editSubmitHandler(ev, true); } showToast('File updated'); if (newFileText && fileBox) { fileText = newFileText; fileBox.textContent = newFileText; } endEditMode(); await loadTwtxtFile(); } catch (err) { if (!secondAttempt) { await refreshToken(); return twtFormSubmitHandler(ev, true); } showToast('Unable to update file, please try again later.', 'error'); console.error('Error updating file', err); } return false; }; const loginFormSubmitHandler = async (ev) => { ev?.preventDefault(); let response; try { const loginData = new URLSearchParams(new FormData(loginForm)); debug('loginForm submit', { loginData }); response = await fetch(TWTXT_FILE_URL, { method: 'POST', body: loginData, mode: 'same-origin', credentials: 'include', }); debug('loginForm submit', { response }); if (!response.ok) throw new Error(response.statusText); showToast('Login complete'); } catch (err) { showToast('Unable to login, please try again later.', 'error'); console.error('Error logging in', err); return; } if (response?.body) token = await new Response(response.body).text(); if (token) document.body.classList.add('js-authorized'); if (token && rememberToggle.checked) setCookie(ACCESS_TOKEN_COOKIE_KEY, token, 7); debug('loginForm submit', { cookie, token, response }); return false; }; const logoutHandler = async () => { try { const res = await fetch(`${TWTXT_FILE_URL}`, { method: 'POST', body: new URLSearchParams({ type: 'logout', }), credentials: 'include', }); if (!res.ok) throw new Error(); document.cookie = `${ACCESS_TOKEN_COOKIE_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; if (menuCheckbox) menuCheckbox.checked = false; window.location.reload(); } catch { showToast('Unable to logout, please try again later.', 'error'); } }; const rememberToggleHandler = (ev) => { if (ev.target.checked) { localStorage.setItem(REMEMBER_LOGIN_STORAGE_KEY, 'true'); return; } localStorage.removeItem(REMEMBER_LOGIN_STORAGE_KEY); }; const twtContentKeyupHandler = (ev) => { const hasContent = (twtControlsContentInput.value?.trim() ?? '').length !== 0; if (twtControlsContentInput) { requestAnimationFrame(() => { // Sync with browser repaint twtControlsContentInput.style.height = 'auto'; twtControlsContentInput.style.height = `${Math.max(twtControlsContentInput.scrollHeight, 80)}px`; }); } if (isEditing || !hasContent) twtSubmitButton.setAttribute('disabled', 'disabled'); else twtSubmitButton.removeAttribute('disabled'); if (hasContent && ev?.key === 'Enter' && ev?.ctrlKey) twtFormSubmitHandler(); }; const twtFormSubmitHandler = async (ev, secondAttempt = false) => { ev?.preventDefault(); try { const twtData = new FormData(twtForm); const twtContent = twtData.get('content').trim(); debug('twtForm submit data', { twtData }); if (!twtContent) return; twtData.set('content', twtContent.replaceAll('\n', '\u2028')); const twtBody = new URLSearchParams(twtData); debug('twtForm submit body', { twtBody }); const res = await fetch(TWTXT_FILE_URL, { method: 'POST', body: twtBody, headers: { Authorization: `Bearer ${token}`, }, credentials: 'include', }); if (!res.ok && !secondAttempt) { debug('twtForm submit - not OK, trying refresh'); await refreshToken(); return twtFormSubmitHandler(ev, true); } showToast('Twt sent'); twtForm.reset(); await loadTwtxtFile(); // scroll to bottom of file to show update fileContentsSection.scrollTop = fileBox.scrollHeight; } catch (err) { debug('twtForm submit - error, trying refresh', err, !secondAttempt); if (!secondAttempt) { await refreshToken(); return twtFormSubmitHandler(ev, true); } showToast('Unable to twt, please try again later.', 'error'); console.error('Error POSTing twt', err); } return false; }; const uploadChangeHandler = (ev) => { uploadFiles(ev.target.files, ev.target.getAttribute('data-route')); }; /* Attach Handlers to Listeners */ Array.from(uploadInputs).forEach((uploadInput) => { uploadInput.addEventListener('change', uploadChangeHandler); }); loginForm.addEventListener('submit', loginFormSubmitHandler); twtForm.addEventListener('submit', twtFormSubmitHandler); twtForm.addEventListener('keyup', twtContentKeyupHandler); twtControlsContentInput.addEventListener('drop', dropHandler); twtControlsContentInput.addEventListener('dragover', dragOverHandler); twtLogoutButton.addEventListener('click', logoutHandler); twtFileEditButton.addEventListener('click', editClickHandler); twtxtEditForm.addEventListener('reset', editResetHandler); twtxtEditForm.addEventListener('submit', editSubmitHandler); window.addEventListener('dragover', dragOverWindowHandler); window.addEventListener('drop', dropWindowHandler); rememberToggle.addEventListener('change', rememberToggleHandler); /* Start App*/ loadTwtxtFile().catch(() => {}); token = getCookie(ACCESS_TOKEN_COOKIE_KEY); if (token) document.body.classList.add('js-authorized'); debug('client loaded'); })();