diff --git a/src/ep.json b/src/ep.json index 355a9e0b0..14016364d 100644 --- a/src/ep.json +++ b/src/ep.json @@ -58,6 +58,12 @@ "expressCreateServer": "ep_etherpad-lite/node/hooks/express/padurlsanitize" } }, + { + "name": "transferToken", + "hooks": { + "expressCreateServer": "ep_etherpad-lite/node/hooks/express/tokenTransfer" + } + }, { "name": "pwa", "hooks": { diff --git a/src/locales/de.json b/src/locales/de.json index 157226726..e5a5a852b 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -54,6 +54,19 @@ "admin_settings.page-title": "Einstellungen - Etherpad", "index.newPad": "Neues Pad", "index.createOpenPad": "Pad öffnen", + "index.settings": "Einstellungen", + "index.receiveSessionTitle": "Sitzung empfangen", + "index.receiveSessionDescription": "Hier kannst du eine Etherpad-Sitzung aus einem anderen Browser oder Gerät empfangen. Bedenke allerdings, dass dadurch deine aktuelle Sitzung, falls vorhanden gelöscht wird.", + "index.code": "Übertragungscode", + "index.transferSessionTitle": "Sitzung übertragen", + "index.transferSession": "1. Sitzung übertragen", + "index.copyLink": "2. Link kopieren", + "index.copyLinkButton": "Übertragungscode kopieren", + "index.copyLinkDescription": "Klicke auf den untenstehenden Button, um den Übertragungscode in deine Zwischenablage zu kopieren.", + "index.transferToSystem": "3. Sitzung einfügen", + "index.transferToSystemDescription": "Öffne den kopierten Link in dem neuen Browser oder Gerät, um deine aktuelle Etherpad-Sitzung zu übertragen.", + "index.transferSessionNow": "Jetzt übertragen", + "index.transferSessionDescription": "Übertrage deine aktuelle Etherpad-Sitzung zu einem anderen Browser oder Gerät, indem du den untenstehenden Button klickst. Dabei wird ein Link in deine Zwischenablage kopiert, den du im neuen Browser oder Gerät öffnen kannst, um deine Sitzung zu übertragen.", "index.openPad": "Öffne ein vorhandenes Pad mit folgendem Namen:", "index.recentPads": "Zuletzt bearbeitete Pads", "index.recentPadsEmpty": "Keine kürzlich bearbeiteten Pads gefunden.", diff --git a/src/locales/en.json b/src/locales/en.json index e341d8df8..51e07f302 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -34,6 +34,18 @@ "admin_settings.page-title": "Settings - Etherpad", "index.newPad": "New Pad", + "index.settings": "Settings", + "index.transferSessionTitle": "Transfer session", + "index.receiveSessionTitle": "Receive session", + "index.receiveSessionDescription": "Here you can receive an Etherpad session from another browser or device. Please note, however, that this will delete your current session, if any.", + "index.transferSession": "1. Transfer session", + "index.transferSessionNow": "Transfer session now", + "index.copyLink": "2. Copy link", + "index.copyLinkDescription": "Click on the button below to copy the link to your clipboard.", + "index.copyLinkButton": "Copy link to clipboard", + "index.transferToSystem": "3. Copy session to new system", + "index.transferToSystemDescription": "Open the copied link in the target browser or device to transfer your session.", + "index.transferSessionDescription": "Transfer your current session to browser or device by clicking the button below. This will copy a link to a page that will transfer your session when opened in the target browser or device.", "index.createOpenPad": "Open pad by name", "index.openPad": "open an existing Pad with the name:", "index.recentPads": "Recent Pads", diff --git a/src/node/hooks/express/tokenTransfer.ts b/src/node/hooks/express/tokenTransfer.ts new file mode 100644 index 000000000..9a6bb25f1 --- /dev/null +++ b/src/node/hooks/express/tokenTransfer.ts @@ -0,0 +1,45 @@ +import {ArgsExpressType} from "../../types/ArgsExpressType"; +const db = require('../../db/DB'); +import crypto from 'crypto' + + +type TokenTransferRequest = { + token: string; + prefsHttp: string, + createdAt?: number; +} + +const tokenTransferKey = "tokenTransfer:"; + +export const expressCreateServer = (hookName:string, {app}:ArgsExpressType) => { + app.post('/tokenTransfer', async (req, res) => { + const token = req.body as TokenTransferRequest; + if (!token || !token.token) { + return res.status(400).send({error: 'Invalid request'}); + } + + const id = crypto.randomUUID() + token.createdAt = Date.now(); + + await db.set(`${tokenTransferKey}:${id}`, token) + res.send({id}); + }) + + app.get('/tokenTransfer/:token', async (req, res) => { + const id = req.params.token; + if (!id) { + return res.status(400).send({error: 'Invalid request'}); + } + + const tokenData = await db.get(`${tokenTransferKey}:${id}`); + if (!tokenData) { + return res.status(404).send({error: 'Token not found'}); + } + + const token = await db.get(`${tokenTransferKey}:${id}`) + + res.cookie('token', tokenData.token, {path: '/', maxAge: 1000*60*60*24*365}); + res.cookie('prefsHttp', tokenData.prefsHttp, {path: '/', maxAge: 1000*60*60*24*365}); + res.send(token); + }) +} diff --git a/src/static/js/welcome.ts b/src/static/js/welcome.ts new file mode 100644 index 000000000..dacaed7bf --- /dev/null +++ b/src/static/js/welcome.ts @@ -0,0 +1,116 @@ +const checkmark = ''; + +function getCookie(name: string) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) { // @ts-ignore + return parts.pop().split(';').shift(); + } +} + + +function handleTransferOfSession() { + const transferNowButton = document.querySelector('[data-l10n-id="index.transferSessionNow"]')! as HTMLButtonElement; + + transferNowButton.addEventListener('click', async () => { + transferNowButton.style.display = 'inline-flex'; + transferNowButton.style.alignItems = 'center'; + transferNowButton.style.justifyContent = 'center'; + transferNowButton.innerHTML = `${checkmark}`; + transferNowButton.disabled = true; + + const responseWithId = await fetch("./tokenTransfer", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + prefsHttp: getCookie('prefsHttp'), + token: getCookie('token'), + }) + }) + + const copyLinkSection = document.getElementById('copy-link-section') + if (!copyLinkSection) return; + copyLinkSection.style.display = 'block'; + + const copyButton = document.querySelector('#copy-link-section .btn-secondary') as HTMLButtonElement + const responseData = await responseWithId.json(); + copyButton.addEventListener('click', async ()=>{ + await navigator.clipboard.writeText(responseData.id); + copyButton.style.display = 'inline-flex'; + copyButton.style.alignItems = 'center'; + copyButton.style.justifyContent = 'center'; + copyButton.innerHTML = `${checkmark}`; + copyButton.disabled = true; + }) + }); +} + + +const handleSettingsButtonClick = () => { + const settingsButton = document.querySelector('.settings-button')!; + const settingsDialog = document.getElementById('settings-dialog') as HTMLDialogElement; + let initialSettingsHtml: string; + + settingsDialog.addEventListener('click', (e) => { + if (e.target === settingsDialog) { + settingsDialog.close(); + settingsDialog.innerHTML = initialSettingsHtml; + handleMenuBarClicked(); + handleTransferOfSession(); + } + }); + + settingsButton.addEventListener('click', () => { + initialSettingsHtml = settingsDialog.innerHTML; + settingsDialog.showModal(); + }); +}; + + +const handleMenuBarClicked = () => { + const menuBar = document.getElementById('button-bar')!; + menuBar.querySelectorAll('button').forEach((button, index)=>{ + button.addEventListener('click', ()=>{ + menuBar.querySelectorAll('button').forEach((btn)=>btn.classList.remove('active-btn')); + button.classList.add('active-btn'); + + const sections: NodeListOf = document.querySelectorAll('#settings-dialog > div'); + sections.forEach((section, index)=>index >= 1 && (section.style.display = 'none')); + (sections[index +1] as HTMLElement).style.display = 'block'; + }); + }) + + const transferSessionButton = document.getElementById('transferSessionButton') + const codeInputField = document.getElementById('codeInput') as HTMLInputElement + if (transferSessionButton) { + transferSessionButton.addEventListener('click', ()=>{ + const code = codeInputField.value + fetch("./tokenTransfer/"+code, { + method: 'GET' + }) + .then(res => res.json()) + .then(()=>{ + window.location.reload() + }) + }); + } + + if (codeInputField) { + codeInputField.addEventListener('input', (e)=>{ + if ((e.target as HTMLInputElement).value?.length === 36) { + transferSessionButton?.removeAttribute('disabled'); + } else { + transferSessionButton?.setAttribute('disabled', 'true'); + } + }) + } + +} + +window.addEventListener('load', () => { + handleSettingsButtonClick(); + handleMenuBarClicked(); + handleTransferOfSession(); +}); diff --git a/src/static/skins/colibris/index.css b/src/static/skins/colibris/index.css index fc4a87f61..3d06464db 100644 --- a/src/static/skins/colibris/index.css +++ b/src/static/skins/colibris/index.css @@ -1,3 +1,5 @@ +@import url("./src/components/buttons.css"); + :root { --etherpad-color: #64d29b; --etherpad-color-dark: #4a5d5c; @@ -100,7 +102,7 @@ h1 { border-radius: 5px; } -#button, #button:hover, #go2Name [type="submit"] { +#button, #button:hover, #go2Name [type="submit"], #transferSessionButton { order: 2; margin-top: 0.5rem; line-height: 1.25rem; @@ -115,10 +117,14 @@ h1 { cursor: pointer; } -#go2Name [type="submit"]:hover { +#go2Name [type="submit"]:hover, #transferSessionButton { background-color: oklch(52.7% 0.154 150.069) } +#transferSessionButton:disabled { + opacity: 0.5; +} + #button, #button:hover { order: 2; } @@ -132,7 +138,7 @@ h1 { } -#go2Name [type="submit"] { +#go2Name [type="submit"], #transferSessionButton { display: block; background-color: var(--ep-color); color: white; @@ -234,10 +240,25 @@ a, a:visited, a:hover, a:active { border-bottom-color: #e5e7eb; } +#settings-dialog::backdrop { + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(2px); +} + .card-content { padding: 1.5rem; } +#codeInput { + height: auto; + position: static; + border: 1px solid var(--muted-border); + border-radius: 0.375rem; + font-size: 1rem; + outline: none; + transition: border 0.2s; +} + @media (max-width: 640px) { #inner { max-width: 100%; diff --git a/src/static/skins/colibris/index.js b/src/static/skins/colibris/index.js index 3001fa5f8..f36edf841 100644 --- a/src/static/skins/colibris/index.js +++ b/src/static/skins/colibris/index.js @@ -11,24 +11,25 @@ window.addEventListener('pageshow', (event) => { }); window.customStart = () => { - document.getElementById('recent-pads').replaceChildren() + const recentPadList = document.getElementById('recent-pads'); + if (recentPadList) { + recentPadList.replaceChildren(); + } // define your javascript here // jquery is available - except index.js // you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/ const divHoldingPlaceHolderLabel = document - .querySelector('[data-l10n-id="index.placeholderPadEnter"]'); + .querySelector('[data-l10n-id="index.placeholderPadEnter"]'); const observer = new MutationObserver(() => { document.querySelector('#go2Name input') - .setAttribute('placeholder', divHoldingPlaceHolderLabel.textContent); + .setAttribute('placeholder', divHoldingPlaceHolderLabel.textContent); }); observer - .observe(divHoldingPlaceHolderLabel, {childList: true, subtree: true, characterData: true}); + .observe(divHoldingPlaceHolderLabel, {childList: true, subtree: true, characterData: true}); - const recentPadList = document.getElementById('recent-pads'); - const parentStyle = recentPadList.parentElement.style; const recentPadListHeading = document.querySelector('[data-l10n-id="index.recentPads"]'); const recentPadsFromLocalStorage = localStorage.getItem('recentPads'); let recentPadListData = []; @@ -38,18 +39,18 @@ window.customStart = () => { // Remove duplicates based on pad name and sort by timestamp recentPadListData = recentPadListData.filter( - (pad, index, self) => - index === self.findIndex((p) => p.name === pad.name) + (pad, index, self) => index === self.findIndex((p) => p.name === pad.name) ).sort((a, b) => new Date(a.timestamp) > new Date(b.timestamp) ? -1 : 1); - if (recentPadListData.length === 0) { + if (recentPadList && recentPadListData.length === 0) { + const parentStyle = recentPadList.parentElement.style; recentPadListHeading.setAttribute('data-l10n-id', 'index.recentPadsEmpty'); parentStyle.display = 'flex'; parentStyle.justifyContent = 'center'; parentStyle.alignItems = 'center'; parentStyle.maxHeight = '100%'; recentPadList.remove(); - } else { + } else if (recentPadList) { /** * @typedef {Object} Pad * @property {string} name diff --git a/src/static/skins/colibris/src/components/buttons.css b/src/static/skins/colibris/src/components/buttons.css index 9a3445478..c9c3e0c2a 100644 --- a/src/static/skins/colibris/src/components/buttons.css +++ b/src/static/skins/colibris/src/components/buttons.css @@ -6,7 +6,6 @@ button, .btn width: auto; border: none; font-weight: bold; - text-transform: uppercase; position: relative; background: none; cursor: pointer; @@ -23,3 +22,36 @@ button, .btn color: #485365; color: var(--text-color); } + +/* Sekundär (outlined) */ +.btn-secondary { + background: transparent; + color: #1f8a3e; + border: 2px solid #1f8a3e; + box-shadow: none; +} + +.active-btn { + text-underline-offset: 10px; + text-decoration: underline; + text-decoration-style: solid; + text-decoration-thickness: 2px; + text-decoration-color: #1f8a3e; + cursor: pointer; +} + + +.btn-secondary:hover { + background: #1f8a3e; + color: #fff; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 1px 2px rgba(0,0,0,0.12); + transform: translateY(-1px); +} + +.btn-secondary:disabled { + background: transparent; + color: #aaa; + border-color: #aaa; + box-shadow: none; + cursor: not-allowed; +} diff --git a/src/templates/index.html b/src/templates/index.html index 8c475367a..7cac8e4a0 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -77,7 +77,7 @@ #padname{ max-width:280px; } - form { + #go2Name { height: 38px; background: #fff; border: 1px solid #bbb; @@ -109,13 +109,32 @@ display: none; } - @media only screen and (min-device-width: 320px) and (max-device-width: 800px) { + .settings-button { + color: inherit; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + outline: inherit; + } + + #settings-dialog { + border: none; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0,0,0,0.2); + padding: 20px; + } + + @media (min-device-width: 320px) and (min-device-width: 800px) { body { background: #bbb; background: -webkit-linear-gradient(#aaa,#eee 60%) center fixed; background: -moz-linear-gradient(#aaa,#eee 60%) center fixed; background: -ms-linear-gradient(#aaa,#eee 60%) center fixed; } + #settings-dialog { + max-width: 50%; + } #wrapper { margin-top: 0; } @@ -136,9 +155,54 @@

Etherpad

+
+ + + +
+ + +
+
+ + +

+
+ + + + +
+ + +
+ +
+ +
+

diff --git a/src/templates/indexBootstrap.js b/src/templates/indexBootstrap.js index faf6702e6..6836a460a 100644 --- a/src/templates/indexBootstrap.js +++ b/src/templates/indexBootstrap.js @@ -3,4 +3,5 @@ window.$ = window.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery; require('ep_etherpad-lite/static/js/l10n') require('ep_etherpad-lite/static/js/index') + require('ep_etherpad-lite/static/js/welcome') })()