aboutsummaryrefslogtreecommitdiff
path: root/static
diff options
context:
space:
mode:
authorNathan Reiner <nathan@nathanreiner.xyz>2025-11-13 14:56:02 +0100
committerNathan Reiner <nathan@nathanreiner.xyz>2025-11-13 14:56:02 +0100
commitc7b02f02ad0a7e2888f2d7d3599719e59bbd1ee2 (patch)
tree9f782daf2e2ff78559958f15e0b9ffe5ece78334 /static
parent7ee9d320e6ba9a84542d838892c43cf98b268552 (diff)
frontend: design prototype
Diffstat (limited to 'static')
-rw-r--r--static/api/index.js11
-rw-r--r--static/icons/192.pngbin0 -> 3608 bytes
-rw-r--r--static/icons/512.pngbin0 -> 10138 bytes
-rw-r--r--static/icons/add.js3
-rw-r--r--static/icons/calendar.js3
-rw-r--r--static/icons/check.js3
-rw-r--r--static/icons/close.js3
-rw-r--r--static/icons/edit.js3
-rw-r--r--static/icons/home.js4
-rw-r--r--static/icons/icon.svg51
-rw-r--r--static/icons/index.js31
-rw-r--r--static/icons/search.js3
-rw-r--r--static/icons/settings.js3
-rw-r--r--static/icons/shuffle.js4
-rw-r--r--static/index.css53
-rw-r--r--static/index.html13
-rw-r--r--static/index.js92
-rw-r--r--static/manifest.json17
-rw-r--r--static/month.js85
-rw-r--r--static/pages/image-viewer/index.css17
-rw-r--r--static/pages/image-viewer/index.js20
-rw-r--r--static/pages/login/index.css129
-rw-r--r--static/pages/login/index.js48
-rw-r--r--static/pages/main/index.css69
-rw-r--r--static/pages/main/index.js97
-rw-r--r--static/pages/settings/index.css83
-rw-r--r--static/pages/settings/index.js62
-rw-r--r--static/pages/shuffle/index.css6
-rw-r--r--static/pages/shuffle/index.js17
-rw-r--r--static/widgets/editable/index.css36
-rw-r--r--static/widgets/editable/index.js65
-rw-r--r--static/widgets/month-select/index.css125
-rw-r--r--static/widgets/month-select/index.js75
-rw-r--r--static/widgets/search/index.css50
-rw-r--r--static/widgets/search/index.js67
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
new file mode 100644
index 0000000..802e580
--- /dev/null
+++ b/static/icons/192.png
Binary files differ
diff --git a/static/icons/512.png b/static/icons/512.png
new file mode 100644
index 0000000..3a3ceed
--- /dev/null
+++ b/static/icons/512.png
Binary files 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 `<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)
+ }
+ }
+}