diff options
| author | Nathan Reiner <nathan@nathanreiner.xyz> | 2025-11-13 14:56:02 +0100 |
|---|---|---|
| committer | Nathan Reiner <nathan@nathanreiner.xyz> | 2025-11-13 14:56:02 +0100 |
| commit | c7b02f02ad0a7e2888f2d7d3599719e59bbd1ee2 (patch) | |
| tree | 9f782daf2e2ff78559958f15e0b9ffe5ece78334 /static | |
| parent | 7ee9d320e6ba9a84542d838892c43cf98b268552 (diff) | |
frontend: design prototype
Diffstat (limited to 'static')
35 files changed, 1346 insertions, 2 deletions
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 Binary files differnew file mode 100644 index 0000000..802e580 --- /dev/null +++ b/static/icons/192.png diff --git a/static/icons/512.png b/static/icons/512.png Binary files differnew file mode 100644 index 0000000..3a3ceed --- /dev/null +++ b/static/icons/512.png 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 `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus" viewBox="0 0 16 16"> + <path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"/> +</svg>`; 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 `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar-week-fill" viewBox="0 0 16 16"> + <path d="M4 .5a.5.5 0 0 0-1 0V1H2a2 2 0 0 0-2 2v1h16V3a2 2 0 0 0-2-2h-1V.5a.5.5 0 0 0-1 0V1H4zM16 14V5H0v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2M9.5 7h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5m3 0h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5M2 10.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm3.5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5"/> +</svg>`; 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 `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-lg" viewBox="0 0 16 16"> + <path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425z"/> +</svg>` 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 `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x" viewBox="0 0 16 16"> + <path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708"/> +</svg>`; 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 `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pen-fill" viewBox="0 0 16 16"> + <path d="m13.498.795.149-.149a1.207 1.207 0 1 1 1.707 1.708l-.149.148a1.5 1.5 0 0 1-.059 2.059L4.854 14.854a.5.5 0 0 1-.233.131l-4 1a.5.5 0 0 1-.606-.606l1-4a.5.5 0 0 1 .131-.232l9.642-9.642a.5.5 0 0 0-.642.056L6.854 4.854a.5.5 0 1 1-.708-.708L9.44.854A1.5 1.5 0 0 1 11.5.796a1.5 1.5 0 0 1 1.998-.001"/> +</svg>`; 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 `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-house-fill" viewBox="0 0 16 16"> + <path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L8 2.207l6.646 6.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293z"/> + <path d="m8 3.293 6 6V13.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 13.5V9.293z"/> +</svg>`; 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 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + width="512" + height="512" + viewBox="0 0 512 512" + version="1.1" + id="svg1" + inkscape:export-filename="192.png" + inkscape:export-xdpi="36" + inkscape:export-ydpi="36" + inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" + sodipodi:docname="icon.svg" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <sodipodi:namedview + id="namedview1" + pagecolor="#ffffff" + bordercolor="#000000" + borderopacity="0.25" + inkscape:showpageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" + inkscape:document-units="px" + inkscape:zoom="1.1960048" + inkscape:cx="267.55745" + inkscape:cy="277.59085" + inkscape:window-width="1914" + inkscape:window-height="1054" + inkscape:window-x="0" + inkscape:window-y="0" + inkscape:window-maximized="1" + inkscape:current-layer="layer1" /> + <defs + id="defs1" /> + <g + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1"> + <circle + style="fill:#000000;stroke-width:3.04152;stroke-linecap:round;stroke-linejoin:round" + id="path1" + cx="256" + cy="256" + r="227.42381" /> + </g> +</svg> 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 `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-search" viewBox="0 0 16 16"> + <path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"/> +</svg>`; 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 `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-gear-fill" viewBox="0 0 16 16"> + <path d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"/> +</svg>`; 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 `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-shuffle" viewBox="0 0 16 16"> + <path fill-rule="evenodd" d="M0 3.5A.5.5 0 0 1 .5 3H1c2.202 0 3.827 1.24 4.874 2.418.49.552.865 1.102 1.126 1.532.26-.43.636-.98 1.126-1.532C9.173 4.24 10.798 3 13 3v1c-1.798 0-3.173 1.01-4.126 2.082A9.6 9.6 0 0 0 7.556 8a9.6 9.6 0 0 0 1.317 1.918C9.828 10.99 11.204 12 13 12v1c-2.202 0-3.827-1.24-4.874-2.418A10.6 10.6 0 0 1 7 9.05c-.26.43-.636.98-1.126 1.532C4.827 11.76 3.202 13 1 13H.5a.5.5 0 0 1 0-1H1c1.798 0 3.173-1.01 4.126-2.082A9.6 9.6 0 0 0 6.444 8a9.6 9.6 0 0 0-1.317-1.918C4.172 5.01 2.796 4 1 4H.5a.5.5 0 0 1-.5-.5"/> + <path d="M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192m0 9v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192"/> +</svg>`; 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 @@ <!DOCTYPE html> <html> <head> - <title>Storyboard</title> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <script type="importmap"> + { + "imports": { + "sfw": "./sfw/index.js" + } + } + </script> + <title>Memora</title> <script type="module" src="index.js"></script> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&family=Pacifico&display=swap" rel="stylesheet"> </head> <body> </body> 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) + } + } +} |