From c5aa364e0e372d0e29063a27bd17811446db8b6a Mon Sep 17 00:00:00 2001 From: Alexis Hovorka Date: Fri, 16 Feb 2024 23:48:02 -0700 Subject: [feat] Flesh out basic UI --- app/public/icons.svg | 1 + app/public/index.html | 71 +++++--- app/public/logo-icon.svg | 96 +++++++++++ app/public/main.js | 119 +++++++++++--- app/public/style.css | 414 ++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 653 insertions(+), 48 deletions(-) create mode 100644 app/public/icons.svg create mode 100644 app/public/logo-icon.svg (limited to 'app') diff --git a/app/public/icons.svg b/app/public/icons.svg new file mode 100644 index 0000000..47e13fb --- /dev/null +++ b/app/public/icons.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/public/index.html b/app/public/index.html index c7b44c7..593e6b3 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -56,26 +56,59 @@ -

Notes

+ +
+

Notes

+

+
+ + + + +
+ +
+
-
- - - - -
-
- - -
- - -
- - - -
- + + +
+
+ + + + + +
diff --git a/app/public/logo-icon.svg b/app/public/logo-icon.svg new file mode 100644 index 0000000..d66678f --- /dev/null +++ b/app/public/logo-icon.svg @@ -0,0 +1,96 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/app/public/main.js b/app/public/main.js index 5dc9531..5746142 100644 --- a/app/public/main.js +++ b/app/public/main.js @@ -28,13 +28,25 @@ function debounce(fn, delay) { //} function drawNote(note) { - const el = m(`
-

${note.id}

- -
`); + const el = m(`
+ + +
`); - $("textarea", el).addEventListener("input", - debounce(() => { saveNote(note.id, $("textarea", el).value); }, 500)); + const ta = $("textarea", el) + + ta.addEventListener("input", + debounce(() => { saveNote(note.id, ta.value); }, 500)); + + function resizeTextarea() { // TODO simplify + el.style.height = (el.scrollHeight) + "px"; + ta.style.height = ""; ta.style.height = (ta.scrollHeight) + "px"; + el.style.height = ""; + } + ta.addEventListener("input", resizeTextarea); + ta.addEventListener("focus", resizeTextarea); + window.addEventListener("resize", resizeTextarea); + setTimeout(resizeTextarea); $("#notes").appendChild(el); } @@ -51,9 +63,7 @@ async function getList() { const list = await res.json(); console.log(list); - for (let i=0;i getNote(id))); } async function saveNote(id, content) { @@ -77,13 +87,12 @@ async function newNote() { drawNote(note); } -$("#new-note").addEventListener("click", newNote); +$("#add-btn").addEventListener("click", newNote); async function getUserData() { const res = await fetch(`/user`); const user = await res.json(); console.log(user); - if (user.uid) $("#uid").value = user.uid; } async function getUserSessions() { @@ -92,26 +101,75 @@ async function getUserSessions() { console.log(sessions); } -$("#login").addEventListener("click", async () => { +function showSignIn() { + const hash = location.hash.slice(2).replace(/^sign-in(\/to\/)?/,""); + history.replaceState("sign-in","","#/sign-in"+(hash?"/to/"+hash:"")); + $("#sign-in-dialog").showModal(); +} + +$("#sign-in-dialog").addEventListener("cancel", e => e.preventDefault()); +$("#sign-in-username").addEventListener("input", e => e.target.classList.remove("error")); +$("#sign-in-password").addEventListener("input", e => e.target.classList.remove("error")); +$("#sign-in-basic-go").addEventListener("click", async e => { + e.preventDefault(); + $("#sign-in-error").textContent = ""; + + const inputs = [ + $("#sign-in-username"), + $("#sign-in-password"), + $("#sign-in-keep-session"), + $("#sign-in-basic-go"), + ]; + const validatedInputs = inputs.slice(0,2); + + if (validatedInputs.map(e => { + if (!e.checkValidity()) { + e.classList.add("error"); + return true; } + }).reduce((a,e) => a||e, false)) return; + + inputs.forEach(e => e.disabled = true); + $("#sign-in-form").classList.add("loading"); + const res = await fetch("/login", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ - username: $("#username").value, - password: $("#password").value, - keepSession: $("#keep-session").checked, + username: $("#sign-in-username").value, + password: $("#sign-in-password").value, + keepSession: $("#sign-in-keep-session").checked, }) }); - // TODO check return code + inputs.forEach(e => e.disabled = false); + $("#sign-in-form").classList.remove("loading"); + if (!res.ok) { + $("#sign-in-error").textContent = res.status+" "+res.statusText; + return; + } const json = await res.json(); console.log(json); - // TODO "Must Change Password" flow + if (!json.success) { + $("#sign-in-error").textContent = json.msg; + return; + } + + if (json.mustChangePassword) { + alert("Must change password (TODO)"); + // TODO + return; + } + + const hash = location.hash.slice(9); + history.replaceState("","","#/"+hash); + // TODO load location getUserData(); getList(); + + $("#sign-in-dialog").close(); }); $("#change-password").addEventListener("click", async () => { @@ -119,8 +177,8 @@ $("#change-password").addEventListener("click", async () => { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ - username: $("#username").value, - password: $("#password").value, + username: $("#username").value, // TODO + password: $("#password").value, // TODO newPassword: $("#new-password").value, keepSession: $("#keep-session").checked, }) @@ -138,7 +196,7 @@ $("#change-username").addEventListener("click", async () => { headers: {"Content-Type": "application/json"}, body: JSON.stringify({ newUsername: $("#new-username").value, - password: $("#password").value, + password: $("#password").value, // TODO }) }); @@ -148,7 +206,7 @@ $("#change-username").addEventListener("click", async () => { getUserData(); }); -$("#logout").addEventListener("click", async () => { +$("#log-out").addEventListener("click", async () => { const res = await fetch("/logout", { method: "POST", }); @@ -156,19 +214,28 @@ $("#logout").addEventListener("click", async () => { //const json = await res.json(); //console.log(json); console.log("Logged out"); - $("#uid").value = ""; + $("#sign-in-dialog").showModal(); }); -$("#logout-everywhere").addEventListener("click", async () => { +$("#log-out-everywhere").addEventListener("click", async () => { const res = await fetch("/deauth-all", { method: "POST", }); console.log("Logged out everywhere"); - $("#uid").value = ""; + showSignIn(); }); -try { getUserData(); getList(); getUserSessions(); } catch(e) {} +try { await getUserData(); await getList(); await getUserSessions(); } catch(e) { showSignIn(); } + +(() => { + let prevScroll = 0; + window.addEventListener("scroll", () => { + document.querySelector("#toolbar").classList[ + (window.scrollY && window.scrollY > prevScroll)?"add":"remove"]("hide"); + prevScroll = window.scrollY; + }); +})(); // TODO make sure to save unsynced delta even if token has expired, then sync when logged in // TODO Cookie consent on signup; "only necessary for maintaining user session and cached data" @@ -190,4 +257,6 @@ try { getUserData(); getList(); getUserSessions(); } catch(e) {} // https://github.com/cure53/DOMPurify/blob/main/src/attrs.js // https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html +// TODO note kebab menu could be a element? + }); diff --git a/app/public/style.css b/app/public/style.css index 655e181..e7dd049 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -3,12 +3,418 @@ padding: 0; box-sizing: border-box; font-family: "Roboto", "Noto Sans", sans-serif; - transition-timing-function: ease-in-out; + transition-timing-function: cubic-bezier(.4,0,.2,1); } -body { - user-select: none; - -webkit-user-select: none; +/* TODO + - normalize text size + - put all colors in variables + - checklists using ::marker? + - links + - CSS scroll snap for columns + - https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_scroll_snap + - box-decoration-break: clone; -webkit-box-decoration-break: clone; + - overscroll-behavior: contain; + - user-select: text; +*/ + +:root, ::backdrop { + --bg-color: #FFF; + --page-bg-color: #EEE; + --text-color: rgba(0,0,0,.87); + --error-triple: 176, 0, 32; + --error-color: rgb(var(--error-triple)); + --icon-opacity-active: .87; + --icon-opacity-inactive: .6; + --icon-opacity-disabled: .38; + --icon-inactive-filter: brightness(0); + --icon-checkbox-active-filter: brightness(1); + --icon-add-hover-filter: brightness(1); + --icon-menu-hover-filter: brightness(1); + --icon-search-hover-filter: brightness(1); + --icon-window-hover-filter: brightness(1); + --menu-button-hover-bg-color: #FBBC04; + --button-hover-bg-color: #FFF; + --button-hover-shadow: 0 2px 16px rgba(0,0,0,.1), 0 2px 4px rgba(0,0,0,.2); + --fab-bg: #FFF; + --fab-shadow: 0 2px 12px rgba(0,0,0,.15), 0 2px 6px rgba(0,0,0,.2); + --fab-shadow-hover: 0 4px 16px rgba(0,0,0,.2), 0 3px 6px rgba(0,0,0,.3); + --toolbar-shadow-mobile: 0 8px 8px 8px rgba(0,0,0,.2); + --toolbar-hide-distance-mobile: calc(-56px - 8px); + --toolbar-button-hover-bg-mobile: rgba(0,0,0,.1); +} + +/*@media (prefers-color-scheme: dark) { :root, ::backdrop { + --bg-color: #000; + --page-bg-color: #000; + --text-color: rgba(255,255,255,.87); + --error-triple: 245, 0, 45; + --error-color: rgb(var(--error-triple)); + --icon-opacity-active: .87; + --icon-opacity-inactive: .6; + --icon-opacity-disabled: .38; + --icon-inactive-filter: brightness(0) invert(1) hue-rotate(180deg); + --icon-add-hover-filter: brightness(1.4) invert(.1) hue-rotate(0); + --icon-menu-hover-filter: brightness(1) invert(1) hue-rotate(180deg); + --icon-search-hover-filter: brightness(1) invert(1) hue-rotate(180deg); + --icon-window-hover-filter: brightness(.75) invert(1) hue-rotate(180deg); + --menu-button-hover-bg-color: #FBBC04; + --button-hover-bg-color: #181818; + --button-hover-shadow: 0 0 0 transparent; + --fab-bg: #333; + --fab-shadow: 0 2px 12px rgba(0,0,0,.2), 0 2px 6px rgba(0,0,0,.24); + --fab-shadow-hover: 0 2px 12px rgba(0,0,0,.2), 0 2px 6px rgba(0,0,0,.24); + --toolbar-shadow-mobile: 0 8px 8px 8px #000, 0 56px 56px -56px rgba(255,255,255,.25) inset; + --toolbar-hide-distance-mobile: calc(-56px - 8px); + --toolbar-button-hover-bg-mobile: rgba(255,255,255,.1); +}}*/ + +html { + background: var(--page-bg-color); + color: var(--text-color); + overscroll-behavior: contain; +} + +body, dialog { /* TODO */ + user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; } + +#toolbar { position: fixed; font-size: 0; } +#toolbar > * { font-size: 16px; text-align: center; } +.toolbar-btn { height: 48px; width: 48px; } +.toolbar-btn, .toolbar-fab { + position: relative; + border-radius: 100%; + background: transparent; + border: none; + outline: none; +} + +.toolbar-btn:not(#conflict-btn)::after, .toolbar-fab::after { content: ""; display: block; height: 24px; width: 24px; background-image: url("icons.svg"); filter: var(--icon-inactive-filter); opacity: var(--icon-opacity-active); position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); } +#menu-btn::after { background-position: 0 0; } +#window-btn::after { background-position: -24px 0; } +#search-btn::after { background-position: -48px 0; } +#add-btn::after { background-position: -72px 0; } + +#conflict-btn { display: none; vertical-align: bottom; } +#conflict-btn::after { content: ""; position: absolute; top: 50%; left: 50%; display: block; height: 8px; width: 8px; border-radius: 4px; background: rgba(var(--error-triple), .8); transform: translate(-50%,-50%); } + +@media (hover:hover) { + .toolbar-btn, .toolbar-fab { cursor: pointer; transition: background-color .2s, box-shadow .2s; } + .toolbar-btn:not(#conflict-btn)::after, .toolbar-fab::after { transition: opacity .1s, filter .1s; filter: var(--icon-inactive-filter); } + .toolbar-btn:not(#conflict-btn):hover::after, .toolbar-fab:hover::after { opacity: 1; } + + #menu-btn:hover::after { filter: var(--icon-menu-hover-filter); } + #window-btn:hover::after { filter: var(--icon-window-hover-filter); } + #search-btn:hover::after { filter: var(--icon-search-hover-filter); } + #add-btn:hover::after { filter: var(--icon-add-hover-filter); } + + #conflict-btn::after { transition: box-shadow .1s; } + #conflict-btn:hover::after { box-shadow: 0 0 0 4px rgba(var(--error-triple),.25); } +} + +@media (hover:hover) and (min-width:601px) { + .notes-column { padding: 8px 80px 16px; } + #toolbar { top: 16px; left: 50%; transform: translate(-50%,0); width: 100%; max-width: calc(7in + 16px); pointer-events: none; } + #toolbar > * { pointer-events: auto; } + .toolbar-fab { height: 48px; width: 48px; } + .toolbar-btn, .toolbar-fab { display: block; margin: 0 16px; } + .toolbar-btn:not(#conflict-btn):hover, .toolbar-fab:hover { background-color: var(--button-hover-bg-color); box-shadow: var(--button-hover-shadow); } + .toolbar-btn:not(#conflict-btn)::after, .toolbar-fab::after { opacity: var(--icon-opacity-inactive) } + .toolbar-btn:not(#conflict-btn):hover::after, .toolbar-fab:hover::after { opacity: var(--icon-opacity-active) } + + #menu-btn:hover { background-color: var(--menu-button-hover-bg-color) !important; } + #search-btn { position: absolute; top: 0; right: 0; } + #add-btn { position: absolute; top: 48px; right: 0; } + #conflict-btn { height: 42px; } + #conflict-btn.show { display: block; } +} + +@media (hover:none), (max-width:600px) { + .notes-column { padding: 0 16px 16px; } + #toolbar { bottom: 0; left: 0; right: 0; width: 100%; box-shadow: var(--toolbar-shadow-mobile); padding: 0 4px; background: var(--bg-color); transition: bottom .25s; /* reentrance speed */ } + .toolbar-btn { display: inline-block; } + .toolbar-fab { position: absolute; display: block; right: 16px; top: -28px; width: 56px; height: 56px; box-shadow: var(--fab-shadow); background: var(--fab-bg); transition: top .25s; } + #conflict-btn { width: 36px; } + #conflict-btn.show { display: inline-block; } + + #toolbar.hide { bottom: var(--toolbar-hide-distance-mobile); transition: bottom .2s; /* exit speed */ } + #toolbar.hide .toolbar-fab { top: calc(var(--toolbar-hide-distance-mobile) - 16px); transition: top .2s; } + + @media (orientation:portrait) { + .toolbar-btn { margin: 4px 0; } + } + + @media (orientation:landscape) { + .toolbar-btn { margin: 0; } + } +} + +@media (hover:hover) and (max-width:600px) { + .toolbar-btn:not(#conflict-btn)::before { content: ""; display: block; position: absolute; top: 50%; left: 50%; height: 40px; width: 40px; border-radius: 20px; transform: translate(-50%,-50%); transition: background-color .2s; } + .toolbar-btn:not(#conflict-btn):hover::before { background-color: var(--toolbar-button-hover-bg-mobile); } + #menu-btn:hover::before { background-color: var(--menu-button-hover-bg-color) !important; } + .toolbar-fab { transition: top .25s, box-shadow .2s; } + #toolbar.hide .toolbar-fab { transition: top .2s, box-shadow .2s; } + .toolbar-fab:hover { box-shadow: var(--fab-shadow-hover); } + #add-btn::after { transition: opacity .15s, filter .15s, transform .2s; } + #add-btn:hover::after { filter: var(--icon-add-hover-filter); transform: translate(-50%,-50%) scale(1.25); } +} + +.logo { + font-weight: 500; + word-spacing: -.15em; +} +.logo-icon { + vertical-align: -.45em; + height: 1.6em; +} + +#sign-in-dialog { + margin: auto; + width: auto; + max-width: min(5.5in, 100vw - 32px); + outline: none; + border: none; + border-radius: 24px; + box-shadow: 0 2px 16px rgba(0,0,0,.05), 0 2px 4px rgba(0,0,0,.1); + transition: box-shadow .2s; +} +#sign-in-dialog::after { + content: ""; + position: absolute; + inset: 0; + box-shadow: 0 0 0 1px rgba(255,255,255,.2) inset; + border-radius: 24px; + pointer-events: none; + transition: box-shadow .2s; +} +#sign-in-dialog::backdrop { + background: var(--page-bg-color); +} +#sign-in-form { + display: block; + padding: 16px 16px 40px; + + & .logo { + margin: 16px 0; + padding: .1em .3em .1em 0; + font-size: min(48px, 12vw); + text-align: center; + } + + & .error-message { + text-align: center; + color: var(--error-color); + } + + & input[type=text], + & input[type=password], + & label, + & button { + display: block; + width: 100%; + max-width: 3.5in; + margin: 12px auto; + font-size: inherit; + letter-spacing: .3px; + color: var(--text-color); + border-radius: 16px; + } + + & input[type=text], + & input[type=password], + & button { + padding: 12px 18px; + outline: 0; + } + & input[type=text], + & input[type=password] { + position: relative; + background-color: #FFF; + border: 1px solid #FFF; + border-bottom: 1px solid rgba(0,0,0,.15); + box-shadow: 0 2px 1px -1px rgba(0,0,0,.1); + transition: border .2s, box-shadow .2s, opacity .2s; + } + & input[type=text]:hover:not(:disabled), + & input[type=password]:hover:not(:disabled), + & input[type=text]:focus, + & input[type=password]:focus { + border: 1px solid rgba(0,0,0,.07); + border-bottom: 1px solid rgba(0,0,0,.18); + box-shadow: 0 2px 16px rgba(0,0,0,.05), 0 2px 4px rgba(0,0,0,.15); + } + & input[type=text]::placeholder, + & input[type=password]::placeholder { + color: rgba(0,0,0,.6); + } + & input[type=text]:autofill, + & input[type=password]:autofill { + background-color: rgb(232,240,254); + border: 1px solid rgb(179,207,255); + } + & input[type=text].error, + & input[type=password].error { + background-color: rgb(254,232,232); + border: 1px solid rgb(255,191,191); + } + & input[type=text].error:focus, + & input[type=password].error:focus { + box-shadow: 0 2px 16px rgba(0,0,0,.05), 0 2px 4px rgba(0,0,0,.15), 0 0 1px 2px rgb(255,191,191) inset; + } + + & label { + width: fit-content; + padding: 8px 12px 8px 8px; + text-align: center; + cursor: pointer; + transition: background-color .2s, opacity .2s; + } + & label:focus-within:has(:focus-visible) { + background-color: rgb(232,240,254); + text-align: center; + cursor: pointer; + } + & input[type=checkbox] { + display: inline-block; + height: 0; + width: 0; + opacity: 0; + } + .checkbox-icon { + display: inline-block; + height: 24px; + width: 24px; + vertical-align: -.36em; + background-image: url("icons.svg"); + background-position: -24px -48px; + filter: var(--icon-inactive-filter); + opacity: var(--icon-opacity-inactive); + } + input[type=checkbox]:checked + .checkbox-icon { + background-position: 0 -48px; + filter: var(--icon-checkbox-active-filter); + opacity: var(--icon-opacity-active); + } + + & button { + position: relative; + font-weight: 500; + background-color: rgba(0,0,0,.06); + border: none; + cursor: pointer; + transition: background-color .2s, box-shadow .2s; + } + & button:hover, + & button:focus, + &.loading button { + background-color: #FBBC04; + } + & button:focus-visible { + box-shadow: 0 0 0 4px rgba(251,188,4,.4); + } + & input + button { + margin-top: 32px; + } + &.loading button { + color: transparent; + } + + & button .loading-spinner { + inset: 50%; + transform: translate(-50%,calc(-50% - 1em)); + display: none; + } + &.loading button .loading-spinner { + display: inline-block; + } + & .loading-spinner, + & .loading-spinner::before, + & .loading-spinner::after { + position: absolute; + border-radius: 50%; + width: .7em; + height: .7em; + animation-fill-mode: both; + animation: loading-spinner 1.8s infinite ease-in-out; + } + & .loading-spinner { + animation-delay: -0.16s; + } + & .loading-spinner::before, + & .loading-spinner::after { + content: ''; + top: 0; + } + & .loading-spinner::before { + left: -1.3em; + animation-delay: -0.32s; + } + & .loading-spinner::after { + left: 1.3em; + } + + &.loading input, + &.loading label { + opacity: .5; + } +} +@keyframes loading-spinner { + 0%, 80%, 100% { box-shadow: 0 1em 0 -1.3em #fff; } + 40% { box-shadow: 0 1em 0 0 #fff; } +} + +.notes-column { + overflow-y: auto; +} + +/* TODO :target? */ +.card-shadow { + position: relative; + margin: 16px auto; + max-width: 5.5in; + border-radius: 24px; + box-shadow: 0 2px 16px rgba(0,0,0,.05), 0 2px 4px rgba(0,0,0,.1); + transition: box-shadow .2s; +} +.card-shadow:hover, +.card-shadow:focus-within { + box-shadow: 0 2px 16px rgba(0,0,0,.15), 0 2px 4px rgba(0,0,0,.15); +} +.card-shadow::after { + content: ""; + position: absolute; + inset: 0; + box-shadow: 0 0 0 1px rgba(255,255,255,.2) inset; + border-radius: 24px; + pointer-events: none; + transition: box-shadow .2s; +} +.card-shadow:hover::after, +.card-shadow:focus-within::after { + box-shadow: 0 0 0 2px rgba(255,255,255,.25) inset; +} +.card { + /*padding-bottom: 16px;*/ + border-radius: 24px; + background-color: var(--bg-color); + color: var(--text-color); + overflow: hidden; +} + +.card > textarea { /* TODO */ + display: block; + width: 100%; + padding: 16px 24px; + border: none; + resize: none; + outline: none; + font-size: 16px; + line-height: 24px; + letter-spacing: .3px; + background: transparent; + color: inherit; +} -- cgit v1.2.3-70-g09d2