Files
express-twtkpr/dist/src/client/script.js
2026-03-17 22:49:38 -04:00

507 lines
13 KiB
JavaScript

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');
})();