From c7b02f02ad0a7e2888f2d7d3599719e59bbd1ee2 Mon Sep 17 00:00:00 2001 From: Nathan Reiner Date: Thu, 13 Nov 2025 14:56:02 +0100 Subject: frontend: design prototype --- README.md | 16 +++++ static/api/index.js | 11 +++ static/icons/192.png | Bin 0 -> 3608 bytes static/icons/512.png | Bin 0 -> 10138 bytes static/icons/add.js | 3 + static/icons/calendar.js | 3 + static/icons/check.js | 3 + static/icons/close.js | 3 + static/icons/edit.js | 3 + static/icons/home.js | 4 ++ static/icons/icon.svg | 51 ++++++++++++++ static/icons/index.js | 31 ++++++++ static/icons/search.js | 3 + static/icons/settings.js | 3 + static/icons/shuffle.js | 4 ++ static/index.css | 53 ++++++++++++++ static/index.html | 13 +++- static/index.js | 92 +++++++++++++++++++++++- static/manifest.json | 17 +++++ static/month.js | 85 ++++++++++++++++++++++ static/pages/image-viewer/index.css | 17 +++++ static/pages/image-viewer/index.js | 20 ++++++ static/pages/login/index.css | 129 ++++++++++++++++++++++++++++++++++ static/pages/login/index.js | 48 +++++++++++++ static/pages/main/index.css | 69 ++++++++++++++++++ static/pages/main/index.js | 97 +++++++++++++++++++++++++ static/pages/settings/index.css | 83 ++++++++++++++++++++++ static/pages/settings/index.js | 62 ++++++++++++++++ static/pages/shuffle/index.css | 6 ++ static/pages/shuffle/index.js | 17 +++++ static/widgets/editable/index.css | 36 ++++++++++ static/widgets/editable/index.js | 65 +++++++++++++++++ static/widgets/month-select/index.css | 125 ++++++++++++++++++++++++++++++++ static/widgets/month-select/index.js | 75 ++++++++++++++++++++ static/widgets/search/index.css | 50 +++++++++++++ static/widgets/search/index.js | 67 ++++++++++++++++++ 36 files changed, 1362 insertions(+), 2 deletions(-) create mode 100644 README.md create mode 100644 static/api/index.js create mode 100644 static/icons/192.png create mode 100644 static/icons/512.png create mode 100644 static/icons/add.js create mode 100644 static/icons/calendar.js create mode 100644 static/icons/check.js create mode 100644 static/icons/close.js create mode 100644 static/icons/edit.js create mode 100644 static/icons/home.js create mode 100644 static/icons/icon.svg create mode 100644 static/icons/index.js create mode 100644 static/icons/search.js create mode 100644 static/icons/settings.js create mode 100644 static/icons/shuffle.js create mode 100644 static/index.css create mode 100644 static/manifest.json create mode 100644 static/month.js create mode 100644 static/pages/image-viewer/index.css create mode 100644 static/pages/image-viewer/index.js create mode 100644 static/pages/login/index.css create mode 100644 static/pages/login/index.js create mode 100644 static/pages/main/index.css create mode 100644 static/pages/main/index.js create mode 100644 static/pages/settings/index.css create mode 100644 static/pages/settings/index.js create mode 100644 static/pages/shuffle/index.css create mode 100644 static/pages/shuffle/index.js create mode 100644 static/widgets/editable/index.css create mode 100644 static/widgets/editable/index.js create mode 100644 static/widgets/month-select/index.css create mode 100644 static/widgets/month-select/index.js create mode 100644 static/widgets/search/index.css create mode 100644 static/widgets/search/index.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f496bd --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Memora + +## TODO + +### Frontend +- [ ] Shuffle-Mode + - [ ] Something like *X* years/months/days ago this happened. +- [ ] Settings + - [x] Profile picture + - [x] Profile name + - [ ] Birthday such we can flag pictures as birthday pictures. +- [ ] Upload progress bar + +### Backend +- [ ] Not all files are delivered. +- [ ] Cache shuffle data in backend and drop them when next day starts. diff --git a/static/api/index.js b/static/api/index.js new file mode 100644 index 0000000..50b9b26 --- /dev/null +++ b/static/api/index.js @@ -0,0 +1,11 @@ +import * as sfw from 'sfw'; +const { Input } = sfw.element.native; + +export async function upload_images() { + const input = Input.new({ + type: 'file', + multiple: true, + accept: 'image/jpeg', + }) + input.click(); +} diff --git a/static/icons/192.png b/static/icons/192.png new file mode 100644 index 0000000..802e580 Binary files /dev/null and b/static/icons/192.png differ diff --git a/static/icons/512.png b/static/icons/512.png new file mode 100644 index 0000000..3a3ceed Binary files /dev/null and b/static/icons/512.png differ diff --git a/static/icons/add.js b/static/icons/add.js new file mode 100644 index 0000000..12650a8 --- /dev/null +++ b/static/icons/add.js @@ -0,0 +1,3 @@ +export default ` + +`; diff --git a/static/icons/calendar.js b/static/icons/calendar.js new file mode 100644 index 0000000..9d9b2ec --- /dev/null +++ b/static/icons/calendar.js @@ -0,0 +1,3 @@ +export default ` + +`; diff --git a/static/icons/check.js b/static/icons/check.js new file mode 100644 index 0000000..dfc2763 --- /dev/null +++ b/static/icons/check.js @@ -0,0 +1,3 @@ +export default ` + +` diff --git a/static/icons/close.js b/static/icons/close.js new file mode 100644 index 0000000..8c96dbd --- /dev/null +++ b/static/icons/close.js @@ -0,0 +1,3 @@ +export default ` + +`; diff --git a/static/icons/edit.js b/static/icons/edit.js new file mode 100644 index 0000000..e13bb8a --- /dev/null +++ b/static/icons/edit.js @@ -0,0 +1,3 @@ +export default ` + +`; diff --git a/static/icons/home.js b/static/icons/home.js new file mode 100644 index 0000000..a6b4886 --- /dev/null +++ b/static/icons/home.js @@ -0,0 +1,4 @@ +export default ` + + +`; diff --git a/static/icons/icon.svg b/static/icons/icon.svg new file mode 100644 index 0000000..87b8771 --- /dev/null +++ b/static/icons/icon.svg @@ -0,0 +1,51 @@ + + + + + + + + + + diff --git a/static/icons/index.js b/static/icons/index.js new file mode 100644 index 0000000..08ab7ea --- /dev/null +++ b/static/icons/index.js @@ -0,0 +1,31 @@ +import * as sfw from 'sfw'; +const { I } = sfw.element.native; + +const icons = [ + 'settings', + 'shuffle', + 'calendar', + 'search', + 'close', + 'home', + 'add', + 'edit', + 'check', +]; + +const target = { + icons: await Promise.all( + icons.map(async v => [v, await import(`./${v}.js`)]) + ).then(r => r.reduce((obj, [v, m]) => (obj[v] = m, obj), {})), +}; + +const handler = { + get(target, prop, receiver) { + return I.new({ + innerHTML: target.icons[prop]?.default ?? '?', + className: 'icon', + }); + } +}; + +export default new Proxy(target, handler); diff --git a/static/icons/search.js b/static/icons/search.js new file mode 100644 index 0000000..a35ff2a --- /dev/null +++ b/static/icons/search.js @@ -0,0 +1,3 @@ +export default ` + +`; diff --git a/static/icons/settings.js b/static/icons/settings.js new file mode 100644 index 0000000..258c8f8 --- /dev/null +++ b/static/icons/settings.js @@ -0,0 +1,3 @@ +export default ` + +`; diff --git a/static/icons/shuffle.js b/static/icons/shuffle.js new file mode 100644 index 0000000..d538fd8 --- /dev/null +++ b/static/icons/shuffle.js @@ -0,0 +1,4 @@ +export default ` + + +`; diff --git a/static/index.css b/static/index.css new file mode 100644 index 0000000..dea1dd7 --- /dev/null +++ b/static/index.css @@ -0,0 +1,53 @@ +:host, :root { + --primary: #726eff; + --fg-primary: #fff; + --fg: #212b38; + --fg-disabled: #37465b; + --bg-label: #dedee8; + --border-radius: 4px; + --card-background: #fff; + --page-background: #dfdfdf; + --shadow: #223223aa 1px 1px 4px; +} + +html, body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; +} + +body { + font-family: "Noto Sans", sans-serif; + color: var(--fg); +} + +button { + font-family: "Noto Sans", sans-serif; + background: var(--primary); + color: var(--fg-primary); + padding: 10px; + border: none; + border-radius: var(--border-radius); + font-weight: bold; + cursor: pointer; +} + +input { + font-family: "Noto Sans", sans-serif; + color: var(--fg); + padding: 10px; + border: none; + outline: none; + background: var(--bg-label); + border-radius: var(--border-radius); +} + +* { + box-sizing: border-box; +} + +.icon svg { + width: 100%; + height: 100%; +} diff --git a/static/index.html b/static/index.html index cd0d6de..7450237 100644 --- a/static/index.html +++ b/static/index.html @@ -1,8 +1,19 @@ - Storyboard + + + Memora + + + diff --git a/static/index.js b/static/index.js index 8764baf..870e3bb 100644 --- a/static/index.js +++ b/static/index.js @@ -1 +1,91 @@ -console.log('hello, world!'); +import * as sfw from 'sfw'; + +import * as api from './api/index.js'; + +import { literal as m } from './month.js'; + +import LoginView from './pages/login/index.js'; +import MainView from './pages/main/index.js'; +import ImageViewer from './pages/image-viewer/index.js'; +import Search from './widgets/search/index.js'; +import MonthSelect from './widgets/month-select/index.js'; +import SettingsView from './pages/settings/index.js'; +import ShuffleView from './pages/shuffle/index.js'; + +sfw.theme.add_css(await sfw.css(import.meta.url, './index.css')); + +const image_viewer = ImageViewer.new(); + +[ + '/images/0001.jpg', + '/images/0002.jpg', + '/images/0003.jpg', + '/images/0004.jpg', + '/images/0005.jpg', + '/images/0006.jpg', + '/images/0007.jpg', + '/images/0008.jpg', + '/images/0009.jpg', + '/images/0010.jpg', +].forEach(url => image_viewer.add(url)) + +const login = LoginView.new({ + onlogin: () => { + document.body.innerHTML = ''; + document.body.append( + main, + search, + month_select, + ) + } +}); + +const search = Search.new({ + onsubmit: (content) => console.log(content), + onhide: () => main.show(), +}); +const month_select = MonthSelect.new({ + months: m`2019-08`.to(m`2025-11`), +}); + +const settings = SettingsView.new({ + onlogout: () => { + document.body.innerHTML = ''; + document.body.append(login) + main.active_view = image_viewer; + }, +}); + +const shuffle = ShuffleView.new({ +}); + +const main = MainView.new({ + active_view: image_viewer, + active_kind: MainView.Kind.upload, + onsearch: () => { + main.active_kind = MainView.Kind.upload; + main.active_view = image_viewer; + search.toggle(); + main.hide(); + }, + onmonth: () => { + main.active_kind = MainView.Kind.upload; + main.active_view = image_viewer; + month_select.show(); + }, + onupload: () => api.upload_images(), + onshuffle: () => { + main.active_kind = MainView.Kind.home; + main.active_view = shuffle; + }, + onsettings: () => { + main.active_kind = MainView.Kind.home; + main.active_view = settings; + }, + onhome: () => { + main.active_kind = MainView.Kind.upload; + main.active_view = image_viewer; + }, +}); + +document.body.append(login); diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..ae569c2 --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "Memora", + "icons": [ + { + "src": "icons/512.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "icons/192.png", + "type": "image/png", + "sizes": "192x192" + } + ], + "start_url": "https://debug.dom0.nathanreiner.xyz", + "display": "standalone" +} diff --git a/static/month.js b/static/month.js new file mode 100644 index 0000000..9ed0467 --- /dev/null +++ b/static/month.js @@ -0,0 +1,85 @@ + +export default class Month { + constructor(month, year) { + this.month = month; + this.year = year; + } + + next(n) { + n = n ?? 1; + + const month = this.month + n - 1; + this.month = (month % 12) + 1; + this.year += Math.floor(month / 12); + + return this; + } + + previous(n) { + n = n ?? 1; + + let month = this.month - n - 1; + + if (month < 0) { + month += 12; + } + + this.month = (month % 12) + 1; + this.year += Math.floor(month / 12); + + return this; + } + + to(other) { + const inner = []; + const current = this.copy(); + + while (current.is_same(other) || current.is_before(other)) { + inner.push(current.copy()); + current.next(); + } + + return inner; + } + + copy() { + return new Month(this.month, this.year); + } + + is_before(other) { + return this.year < other.year || (this.year == other.year && this.month < other.month); + } + + is_after(other) { + return this.year > other.year || (this.year == other.year && this.month > other.month); + } + + is_same(other) { + return this.year == other.year || this.month == other.month; + } + + is_same_year(other) { + return this.year == other.year; + } + + get name() { + const date = new Date(); + date.setMonth(this.month - 1); + return date.toLocaleDateString('default', { month: 'long' }) + } + + static from_date(date) { + return new Month(date.getMonth() - 1, date.getFullYear()); + } + + static from_string(string) { + const [ year, month ] = string.split('-'); + return new Month(parseInt(month), parseInt(year)) + } + + static literal(strings) { + return Month.from_string(strings[0]); + } +} + +export const literal = Month.literal; diff --git a/static/pages/image-viewer/index.css b/static/pages/image-viewer/index.css new file mode 100644 index 0000000..6f53333 --- /dev/null +++ b/static/pages/image-viewer/index.css @@ -0,0 +1,17 @@ + +#container { + width: 100%; + height: 100vh; + overflow-y: auto; + display: grid; + gap: 10px; + padding: 10px; +} + +#container img { + margin: auto; + max-width: 700px; + width: 100%; + border-radius: var(--border-radius); + box-shadow: #223223aa 1px 1px 4px; +} diff --git a/static/pages/image-viewer/index.js b/static/pages/image-viewer/index.js new file mode 100644 index 0000000..76d720f --- /dev/null +++ b/static/pages/image-viewer/index.js @@ -0,0 +1,20 @@ +import * as sfw from 'sfw'; +const { Div, Img } = sfw.element.native; + +const css = await sfw.css(import.meta.url, './index.css') + +export default class ImageViewer extends sfw.element.Container { + #container + + constructor() { + super({ css }); + + this.body.append( + this.#container = Div.new({ id: 'container' }) + ); + } + + add(url) { + this.#container.append(Img.new({ src: url })); + } +} diff --git a/static/pages/login/index.css b/static/pages/login/index.css new file mode 100644 index 0000000..8a59d83 --- /dev/null +++ b/static/pages/login/index.css @@ -0,0 +1,129 @@ +#container { + background: var(--page-background); + overflow: hidden; + width: 100%; + height: 100%; +} + +#box { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-width: 300px; + width: 100%; + padding: 20px; + box-shadow: #223223aa 1px 1px 4px; + border-radius: var(--border-radius); + background: #ffffff99; + backdrop-filter: blur(10px); + z-index: 2000; +} + +#title { + font-family: 'Pacifico'; + position: absolute; + width: 100%; + height: 100%; + text-align: center; + top: 0px; + padding-top: 50px; + font-size: 2.5em; + z-index: 1000; + text-shadow: -1px -1px 0 var(--page-background), + 1px 1px 0 var(--page-background), + 1px -1px 0 var(--page-background), + -1px 1px 0 var(--page-background); + overflow: hidden; +} + +@keyframes left-bubble { + from { + width: 0px; + height: 0px; + } + to { + width: 100vw; + height: 100vw; + } +} + +#title:before { + content: ''; + display: block; + position: absolute; + top: -30px; + left: 0px; + background: var(--primary); + transform: translate(-50%, -50%); + border-radius: 100%; + z-index: -1; + animation: left-bubble 1s normal forwards ease; + width: 100vw; + height: 100vw; +} + +@keyframes right-bubble { + from { + width: 0px; + height: 0px; + } + to { + width: 200vw; + height: 200vw; + } +} + +#title:after { + content: ''; + display: block; + position: absolute; + bottom: 55%; + right: 0px; + width: 200vw; + height: 200vw; + transform: translate(50%, 50%); + background: var(--fg); + border-radius: 100%; + z-index: -2; + animation: right-bubble 1s normal forwards ease; +} + +#form { + display: grid; + grid-template-columns: 100px auto; + max-width: 300px; + width: 100%; + margin: 0; + margin-bottom: 20px; + border-radius: 3px; + gap: 10px; +} + +#form input { + width: 100%; +} + +#form label { + margin: auto 0px; + font-weight: bold; +} + +button { + width: 100%; +} + +#subtitle { + position: absolute; + bottom: 50px; + left: 50%; + transform: translate(-50%, 0); + white-space: nowrap; + font-family: 'Pacifico'; + color: #69717d; + z-index: 1000; + text-shadow: -1px -1px 0 var(--page-background), + 1px 1px 0 var(--page-background), + 1px -1px 0 var(--page-background), + -1px 1px 0 var(--page-background); +} diff --git a/static/pages/login/index.js b/static/pages/login/index.js new file mode 100644 index 0000000..fc14dbf --- /dev/null +++ b/static/pages/login/index.js @@ -0,0 +1,48 @@ +import * as sfw from 'sfw'; +const { Div, Label, H1: Title, Input, Button } = sfw.element.native; + +const css = await sfw.css(import.meta.url, './index.css'); + +export default class LoginView extends sfw.element.Container { + #user + #password + + constructor() { + super({ css }); + + this.onlogin = () => {}; + + this.body.append( + Div.new({ + id: 'container', + children: [ + Div.new({ id: 'title', innerText: 'Memora' }), + Div.new({ + id: 'box', + children: [ + + Div.new({ + id: 'form', + children: [ + Label.new({ innerText: 'User' }), + this.#user = Input.new({ }), + + Label.new({ innerText: 'Password' }), + this.#password = Input.new({ type: 'password' }), + ] + }), + Button.new({ + innerText: 'Login', + onclick: () => this.onlogin(this.#user.value, this.#password.value), + }), + ] + }), + Div.new({ + id: 'subtitle', + innerText: 'Where nostalgia is home.', + }), + ] + }) + ); + } +} diff --git a/static/pages/main/index.css b/static/pages/main/index.css new file mode 100644 index 0000000..421e689 --- /dev/null +++ b/static/pages/main/index.css @@ -0,0 +1,69 @@ + +:host { + display: grid; + height: 100%; + width: 100%; + background: var(--page-background); +} + +#bar { + position: fixed; + bottom: 5px; + left: 5px; + right: 5px; + background: #fff9; + box-shadow: #223223aa 1px 1px 4px; + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr ; + height: 50px; + backdrop-filter: blur(10px); + border-radius: var(--border-radius); + transition: bottom 0.1s ease; +} + +#bar.hidden { + bottom: -200px; +} + +.menu-button { + color: var(--fg-disabled); + width: 18px; + height: 18px; + border-radius: 100%; + margin: auto; + cursor: pointer; + user-select: none; + align-content: center; + text-align: center; +} + +.menu-button.add { + width: 60px; + height: 60px; + margin-top: -20px; + background: var(--primary); + transition: width 0.1s ease, height 0.1s ease, margin 0.1s ease; + color: var(--card-background); + font-weight: bold; + font-size: 2em; +} + +.menu-button.add .icon { + width: 30px; + height: 30px; + display: grid; + margin: auto; +} + +.menu-button.add .icon .bi-house-fill { + width: 20px; + height: 20px; + margin: auto; +} + +.menu-button .icon { + width: 100%; + height: 100%; + display: grid; + margin: auto; +} diff --git a/static/pages/main/index.js b/static/pages/main/index.js new file mode 100644 index 0000000..9bf0aae --- /dev/null +++ b/static/pages/main/index.js @@ -0,0 +1,97 @@ +import * as sfw from 'sfw'; +const { Div, Input } = sfw.element.native; + +import icons from '/icons/index.js'; + +const css = await sfw.css(import.meta.url, './index.css'); + +export default class MainView extends sfw.element.Container { + #active_kind + #active_button + #active_view + #bar + + static Kind = Object.freeze({ + 'upload': 0, + 'home': 1, + }); + + constructor() { + super({ css }); + + this.onsearch = () => {} + this.onmonth = () => {} + this.onupload = () => {} + this.onshuffle = () => {} + this.onhome = () => {} + + this.body.append( + this.#active_view = Div.new({ id: 'main' }), + + this.#bar = Div.new({ + id: 'bar', + children: [ + Div.new({ + className: 'menu-button', + children: [ icons.search ], + onclick: (e) => { + e.stopPropagation(); + this.onsearch(); + } + }), + + Div.new({ + className: 'menu-button', + children: [ icons.calendar ], + onclick: () => this.onmonth(), + }), + + this.#active_button = Div.new({ + className: 'menu-button add', + children: [ icons.add ], + onclick: () => this.onupload() + }), + + Div.new({ + className: 'menu-button', + children: [ icons.shuffle ], + onclick: () => this.onshuffle(), + }), + + Div.new({ + className: 'menu-button', + children: [ icons.settings ], + onclick: () => this.onsettings(), + }), + ], + }), + ); + } + + hide() { + this.#bar.classList.add('hidden'); + } + + show() { + this.#bar.classList.remove('hidden'); + } + + set active_view(element) { + this.#active_view.innerHTML = ''; + this.#active_view.append(element); + } + + set active_kind(kind) { + if (kind == MainView.Kind.home) { + this.#active_button.innerHTML = ''; + this.#active_button.append(icons.home); + this.#active_button.onclick = () => this.onhome(); + } else if (kind == MainView.Kind.upload) { + this.#active_button.innerHTML = ''; + this.#active_button.append(icons.add); + this.#active_button.onclick = () => this.onupload(); + } else { + console.error(`invalid kind ${kind}`); + } + } +} diff --git a/static/pages/settings/index.css b/static/pages/settings/index.css new file mode 100644 index 0000000..5128c18 --- /dev/null +++ b/static/pages/settings/index.css @@ -0,0 +1,83 @@ + +#container { + width: 100%; + height: 100%; + background: var(--page-background); + padding: 40px; + display: flex; + flex-flow: column; + gap: 20px; +} + +#profile-image { + width: 200px; + height: 200px; + position: relative; + margin: auto; + margin-top: 50px; + margin-bottom: 30px; +} + +#image-container { + position: relative; + border-radius: 100%; + width: 100%; + height: 100%; + top: 0px; + left: 0px; + overflow: hidden; +} + +#image-container img { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + min-width: 100%; + min-height: 100%; + width: 100%; + height: 100%; + object-fit: cover; + user-select: none; +} + +#profile-image #edit-container { + overflow: unset; +} + +#profile-image #edit { + position: absolute; + top: 15px; + right: 15px; + background: var(--card-background); + width: 30px; + height: 30px; + padding: 6px; + border-radius: 100%; + box-shadow: var(--shadow); + cursor: pointer; +} + +#info-box { + width: 100%; + text-align: center; + color: var(--fg-disabled); + font-weight: lighter; + user-select: none; +} + +#info-box #name { + font-family: 'Pacifico'; +} + +#logout { + padding: 10px; + width: 100%; + text-align: center; + background: #ee5151; + box-shadow: var(--shadow); + color: #fff; + font-weight: bold; + border-radius: var(--border-radius); + cursor: pointer; +} diff --git a/static/pages/settings/index.js b/static/pages/settings/index.js new file mode 100644 index 0000000..da30ba7 --- /dev/null +++ b/static/pages/settings/index.js @@ -0,0 +1,62 @@ +import * as sfw from 'sfw'; +const { Div, Img } = sfw.element.native; + +import Editable from '../../widgets/editable/index.js'; + +import icons from '../../icons/index.js'; + +const css = await sfw.css(import.meta.url, './index.css'); + +export default class SettingsView extends sfw.element.Container { + constructor() { + super({ css }); + + this.onlogout = () => {}; + + this.body.append( + Div.new({ + id: 'container', + children: [ + Div.new({ + id: 'profile-image', + children: [ + Div.new({ + id: 'image-container', + children: [ + Img.new({ + src: '/images/0010.jpg', + }), + ] + }), + Div.new({ + id: 'edit', + children: [ icons.edit ] + }), + ] + }), + Editable.new({ + title: 'Name', + value: 'Nathan Reiner' + }), + Editable.new({ + title: 'Birthday', + type: 'date', + value: '2002-08-06', + }), + Div.new({ + id: 'logout', + innerText: 'Log-out', + onclick: () => this.onlogout(), + }), + Div.new({ + id: 'info-box', + children: [ + Div.new({ id: 'name', innerText: 'Memora' }), + Div.new({ id: 'version', innerText: '0.0.1-unstable' }), + ] + }), + ] + }) + ) + } +} diff --git a/static/pages/shuffle/index.css b/static/pages/shuffle/index.css new file mode 100644 index 0000000..534a647 --- /dev/null +++ b/static/pages/shuffle/index.css @@ -0,0 +1,6 @@ + +#container { + width: 100%; + height: 100%; + background: var(--page-background); +} diff --git a/static/pages/shuffle/index.js b/static/pages/shuffle/index.js new file mode 100644 index 0000000..b282556 --- /dev/null +++ b/static/pages/shuffle/index.js @@ -0,0 +1,17 @@ +import * as sfw from 'sfw'; +const { Div } = sfw.element.native; + +const css = await sfw.css(import.meta.url, './index.css'); + +export default class ShuffleView extends sfw.element.Container { + constructor() { + super({ css }); + + this.body.append( + Div.new({ + id: 'container', + innerText: 'shuffle', + }) + ) + } +} diff --git a/static/widgets/editable/index.css b/static/widgets/editable/index.css new file mode 100644 index 0000000..b474370 --- /dev/null +++ b/static/widgets/editable/index.css @@ -0,0 +1,36 @@ + +#container { + width: 100%; + background: var(--card-background); + box-shadow: var(--shadow); + border-radius: var(--border-radius); + display: grid; + grid-template-columns: min-content auto min-content; +} + +#container label { + user-select: none; + font-weight: bold; + background: var(--primary); + color: var(--fg-primary); + align-content: center; + padding: 10px; + border-top-left-radius: var(--border-radius); + border-bottom-left-radius: var(--border-radius); +} + +#container input { + background: var(--card-background); + user-select: none; + font-size: 1em; +} + +#container input:read-only { + caret-color: transparent; +} + +#edit { + width: 35px; + padding: 10px; + cursor: pointer; +} diff --git a/static/widgets/editable/index.js b/static/widgets/editable/index.js new file mode 100644 index 0000000..8a72aff --- /dev/null +++ b/static/widgets/editable/index.js @@ -0,0 +1,65 @@ +import * as sfw from 'sfw'; +const { Div, Label, Input } = sfw.element.native; + +import icons from '../../icons/index.js'; + +const css = await sfw.css(import.meta.url, './index.css'); + +export default class Editable extends sfw.element.Container { + #label + #input + #edit + + constructor() { + super({ css }); + + this.body.append( + Div.new({ + id: 'container', + children: [ + this.#label = Label.new({ htmlFor: 'input' }), + this.#input = Input.new({ + readOnly: true, + onkeydown: (e) => { + if (e.key === 'Enter') { + this.#input.readOnly = true; + this.#update() + } + }, + }), + this.#edit = Div.new({ + id: 'edit', + children: [ icons.edit ], + onclick: () => { + this.#input.readOnly = !this.#input.readOnly; + this.#update(); + } + }), + ], + }) + ); + } + + #update() { + this.#edit.innerHTML = ''; + this.#edit.append( + this.#input.readOnly ? icons.edit : icons.check + ); + + if (!this.#input.readOnly) { + this.#input.select(); + } + } + + set title(value) { + this.#label.innerText = value; + } + + set value(value) { + this.#input.value = value; + } + + set type(type) { + this.#input.type = type; + } +} diff --git a/static/widgets/month-select/index.css b/static/widgets/month-select/index.css new file mode 100644 index 0000000..87b63c0 --- /dev/null +++ b/static/widgets/month-select/index.css @@ -0,0 +1,125 @@ +:host { + position: fixed; + top: 0; + left: 0; +} + +#container { + z-index: 1000; + position: fixed; + bottom: -100%; + left: 5px; + right: 5px; + background: #fff9; + backdrop-filter: blur(10px); + border-radius: var(--border-radius); + transition: bottom 0.1s ease; + height: calc(100% - 10px); + box-shadow: var(--shadow); + padding: 10px; +} + +#container.visible { + bottom: 5px; +} + +#close-button { + position: absolute; + top: 10px; + right: 10px; + width: 25px; + height: 25px; + border-radius: 100%; + background: var(--page-background); + cursor: pointer; +} + +#title { + font-size: 1.2em; + user-select: none; + margin-bottom: 10px; +} + +#month-container { + height: calc(100% - 30px); + overflow-y: auto; + overscroll-behavior: contain; +} + +.month-item { + padding: 10px; + padding-left: 20px; + margin: 5px; + border-radius: var(--border-radius); + user-select: none; + cursor: pointer; + position: relative; +} + +.month-item:before { + content: ''; + display: block; + position: absolute; + background: var(--primary); + top: 0px; + left: 0px; + width: 4px; + height: 47px; +} + +.month-item:after { + content: ''; + display: block; + position: absolute; + background: var(--primary); + top: 50%; + transform: translate(0, -50%); + left: 0px; + width: 12px; + height: 4px; +} + +.month-item:first-child:before { + top: 50%; + height: 27px; +} + +.month-item:hover { + background: var(--primary); + color: var(--fg-primary); +} + +.year-item { + padding: 10px 20px; + font-size: 1.1em; + margin: 5px; + position: relative; + font-weight: bold; + background: #fffa; + border-radius: var(--border-radius); + box-shadow: #22322355 1px 1px 2px; +} + +.year-item:before { + content: ''; + display: block; + position: absolute; + background: var(--primary); + top: 0px; + left: 0px; + width: 4px; + height: 50px; +} + +.year-item:after { + content: ''; + display: block; + position: absolute; + background: var(--primary); + top: 50%; + transform: translate(0, -50%); + left: -4px; + width: 12px; + height: 12px; + border-radius: 100%; +} diff --git a/static/widgets/month-select/index.js b/static/widgets/month-select/index.js new file mode 100644 index 0000000..d23469d --- /dev/null +++ b/static/widgets/month-select/index.js @@ -0,0 +1,75 @@ +import * as sfw from 'sfw'; +const { Div } = sfw.element.native; + +import icons from '../../icons/index.js'; + +const css = await sfw.css(import.meta.url, './index.css'); + +export default class MonthSelect extends sfw.element.Container { + #container + #month_container + + constructor() { + super({ css }); + + this.onscroll = (e) => e.stopPropagate(); + this.onmonth = () => {}; + + this.body.append( + this.#container = Div.new({ + id: 'container', + children: [ + Div.new({ id: 'title', innerText: 'Select Month' }), + Div.new({ + id: 'close-button', + children: [ icons.close ], + onclick: () => this.hide(), + }), + this.#month_container = Div.new({ + id: 'month-container', + }), + ], + }), + ); + } + + show() { + this.#container.classList.add('visible'); + } + + hide() { + this.#container.classList.remove('visible'); + } + + set months(months) { + months.sort((a, b) => a.is_before(b)); + + this.#month_container.innerHTML = ''; + + let last = null; + + for (const month of months) { + if (last != null && !last.is_same_year(month)) { + this.#month_container.append( + Div.new({ + className: 'year-item', + innerText: last.year, + }) + ); + } + + this.#month_container.append( + Div.new({ + className: 'month-item', + innerText: month.name, + onclick: () => { + this.hide(); + this.onmonth(month); + } + }) + ); + + last = month; + } + } +} diff --git a/static/widgets/search/index.css b/static/widgets/search/index.css new file mode 100644 index 0000000..2bb940e --- /dev/null +++ b/static/widgets/search/index.css @@ -0,0 +1,50 @@ +:host { + position: fixed; + top: 0; + left: 0; +} + +#container { + position: fixed; + top: -200px; + left: 5px; + right: 5px; + height: 100px; + background: #fff9; + backdrop-filter: blur(10px); + border-radius: var(--border-radius); + transition: top 0.1s ease; + padding: 10px; + display: grid; + gap: 10px; + box-shadow: var(--shadow); +} + +#container.visible { + top: 5px; +} + +#title { + font-size: 1.2em; + align-content: center; + padding-left: 5px; + user-select: none; +} + +#search-box { + width: 100%; + display: grid; + grid-template-columns: auto 50px; +} + +input { + border-top-right-radius: 0px !important; + border-bottom-right-radius: 0px !important; +} + +button { + width: 50px; + border-top-left-radius: 0px !important; + border-bottom-left-radius: 0px !important; + height: 42px; +} diff --git a/static/widgets/search/index.js b/static/widgets/search/index.js new file mode 100644 index 0000000..324141b --- /dev/null +++ b/static/widgets/search/index.js @@ -0,0 +1,67 @@ +import * as sfw from 'sfw'; +const { Div, Input, Button } = sfw.element.native; + +import icons from '../../icons/index.js'; + +const css = await sfw.css(import.meta.url, './index.css'); + +export default class Search extends sfw.element.Container { + #container + #search + + constructor() { + super({ css }); + + this.onsubmit = () => {} + this.onhide = () => {} + + this.onclick = (e) => e.stopPropagation(); + this.hide = () => { + this.#container.classList.remove('visible'); + document.removeEventListener('click', this.hide); + this.onhide(); + }; + + this.body.append( + this.#container = Div.new({ + id: 'container', + children: [ + Div.new({ innerText: 'Search', id: 'title' }), + Div.new({ + id: 'search-box', + children: [ + this.#search = Input.new({ + type: 'search', + onsearch: () => this.submit(), + onkeydown: (event) => { + if (event.key === 'Enter') { + this.submit(); + } + } + }), + Button.new({ + children: [ icons.search ], + onclick: () => this.submit(), + }), + ] + }) + ] + }) + ); + } + + submit() { + this.onsubmit(this.#search.value); + this.#search.blur(); + this.hide(); + } + + toggle() { + this.#container.classList.toggle('visible'); + + if (this.#container.classList.contains('visible')) { + this.#search.focus() + document.addEventListener('click', this.hide) + } + } +} -- cgit v1.2.3-70-g09d2