first draft but not working yet
This commit is contained in:
commit
b54c89c9c4
10 changed files with 380 additions and 0 deletions
43
README.md
Normal file
43
README.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
## Architecture
|
||||
|
||||
* Lit-based web components
|
||||
|
||||
* No-build tooling (importmap only)
|
||||
|
||||
* Zustand-moderated state management with IndexedDB storage mechanism
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
store/
|
||||
index.js ← zustand store definition
|
||||
middleware/
|
||||
persistence.js ← idb-keyval persist adapter
|
||||
sync.js ← optional cross-tab broadcast
|
||||
components/
|
||||
app-root.js
|
||||
feature-a/
|
||||
feature-a.js ← Lit component
|
||||
feature-a.css ← adopted stylesheet or constructable
|
||||
controllers/
|
||||
fetch.js ← ReactiveController for API calls
|
||||
3d.js ← optional Three/WebGPU controller
|
||||
lib/
|
||||
idb.js ← thin idb-keyval wrapper/schema
|
||||
index.html
|
||||
importmap.json ← extracted importmap (referenced via <script src>)
|
||||
```
|
||||
|
||||
## The Complete Mental Model
|
||||
```
|
||||
IndexedDB
|
||||
↕ (idb-keyval adapter)
|
||||
Zustand persist middleware
|
||||
↕ (vanilla store)
|
||||
StoreController (ReactiveController)
|
||||
↕ (requestUpdate → selector)
|
||||
Lit Web Components
|
||||
↕ (events / store actions)
|
||||
User
|
||||
```
|
||||
46
components/app-root.js
Normal file
46
components/app-root.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { LitElement, html, css } from "lit";
|
||||
import { store} from "../store/index.js";
|
||||
import { StoreController } from "../controllers/store.js";
|
||||
import "./nav-bar.js";
|
||||
import "./page-home.js";
|
||||
import "./page-items.js";
|
||||
|
||||
class AppRoot extends LitElement {
|
||||
#hydrated = new StoreController(this, store, s => s._hydrated);
|
||||
#user = new StoreController(this, store, s => s.user);
|
||||
#route = new StoreController(this, store, s => s.route);
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100vh;
|
||||
opacity: 0.4;
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
if (!this.#hydrated.value) {
|
||||
return html`<div class="loading">loading...</div>`;
|
||||
}
|
||||
return html`
|
||||
<nav-bar .user=${this.#user.value}></nav-bar>
|
||||
<main>${this.#renderRoute()}</main>
|
||||
`;
|
||||
}
|
||||
|
||||
#renderRoute() {
|
||||
switch (this.#route.value) {
|
||||
case "home": return html`<page-home></page-home>`;
|
||||
case "items": return html`<page-items></page-items>`;
|
||||
default: return html`<page-home></page-home>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('app-root', AppRoot);
|
||||
64
components/nav-bar.js
Normal file
64
components/nav-bar.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// components/nav-bar.js
|
||||
import { LitElement, html, css } from 'lit'
|
||||
import { store } from '../store/index.js'
|
||||
import { StoreController } from '../controllers/store.js'
|
||||
|
||||
class NavBar extends LitElement {
|
||||
#route = new StoreController(this, store, s => s.route)
|
||||
|
||||
static properties = {
|
||||
user: { type: Object }
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 0 1.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 3.5rem;
|
||||
}
|
||||
.links { display: flex; gap: 1.5rem; }
|
||||
a {
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
}
|
||||
a[active] { color: #0f172a; font-weight: 600; }
|
||||
.user { font-size: 0.85rem; color: #94a3b8; }
|
||||
`
|
||||
|
||||
#navigate(route, e) {
|
||||
e.preventDefault()
|
||||
store.getState().navigate(route)
|
||||
}
|
||||
|
||||
#link(route, label) {
|
||||
return html`
|
||||
|
||||
href="/${route}"
|
||||
?active=${this.#route.value === route}
|
||||
@click=${(e) => this.#navigate(route, e)}
|
||||
>${label}</a>
|
||||
`
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<nav>
|
||||
<div class="links">
|
||||
${this.#link('home', 'Home')}
|
||||
${this.#link('items', 'Items')}
|
||||
</div>
|
||||
<span class="user">${this.user?.name ?? 'Guest'}</span>
|
||||
</nav>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('nav-bar', NavBar)
|
||||
44
components/page-home.js
Normal file
44
components/page-home.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// components/page-home.js
|
||||
import { LitElement, html, css } from 'lit'
|
||||
import { store } from '../store/index.js'
|
||||
import { StoreController } from '../controllers/store.js'
|
||||
|
||||
class PageHome extends LitElement {
|
||||
#user = new StoreController(this, store, s => s.user)
|
||||
#items = new StoreController(this, store, s => s.items)
|
||||
|
||||
static styles = css`
|
||||
:host { display: block; padding: 2rem 1.5rem; }
|
||||
h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
|
||||
p { color: #64748b; margin: 0 0 2rem; }
|
||||
.summary {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
.stat {
|
||||
padding: 1rem 1.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.stat-value { font-size: 2rem; font-weight: 700; }
|
||||
.stat-label { font-size: 0.8rem; color: #94a3b8; margin-top: 0.25rem; }
|
||||
`
|
||||
|
||||
render() {
|
||||
const name = this.#user.value?.name ?? 'there'
|
||||
const count = this.#items.value.length
|
||||
|
||||
return html`
|
||||
<h1>Hey, ${name}</h1>
|
||||
<p>Here's what's going on.</p>
|
||||
<div class="summary">
|
||||
<div class="stat">
|
||||
<div class="stat-value">${count}</div>
|
||||
<div class="stat-label">Items</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('page-home', PageHome)
|
||||
111
components/page-items.js
Normal file
111
components/page-items.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// components/page-items.js
|
||||
import { LitElement, html, css } from 'lit'
|
||||
import { store } from '../store/index.js'
|
||||
import { StoreController } from '../controllers/store.js'
|
||||
|
||||
class PageItems extends LitElement {
|
||||
#items = new StoreController(this, store, s => s.items)
|
||||
|
||||
static properties = {
|
||||
_draft: { type: String, state: true }
|
||||
}
|
||||
|
||||
_draft = ''
|
||||
|
||||
static styles = css`
|
||||
:host { display: block; padding: 2rem 1.5rem; }
|
||||
h1 { font-size: 1.5rem; margin: 0 0 1.5rem; }
|
||||
.add-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
input:focus { border-color: #94a3b8; }
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #0f172a;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #1e293b; }
|
||||
ul { list-style: none; padding: 0; margin: 0; }
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
.remove:hover { color: #ef4444; }
|
||||
.empty { color: #94a3b8; font-size: 0.9rem; }
|
||||
`
|
||||
|
||||
#add() {
|
||||
const name = this._draft.trim()
|
||||
if (!name) return
|
||||
store.getState().addItem({ id: crypto.randomUUID(), name })
|
||||
this._draft = ''
|
||||
}
|
||||
|
||||
#remove(id) {
|
||||
store.getState().removeItem(id)
|
||||
}
|
||||
|
||||
#onKeydown(e) {
|
||||
if (e.key === 'Enter') this.#add()
|
||||
}
|
||||
|
||||
render() {
|
||||
const items = this.#items.value
|
||||
|
||||
return html`
|
||||
<h1>Items</h1>
|
||||
|
||||
<div class="add-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New item…"
|
||||
.value=${this._draft}
|
||||
@input=${e => this._draft = e.target.value}
|
||||
@keydown=${this.#onKeydown}
|
||||
/>
|
||||
<button @click=${this.#add}>Add</button>
|
||||
</div>
|
||||
|
||||
${items.length === 0
|
||||
? html`<p class="empty">No items yet.</p>`
|
||||
: html`
|
||||
<ul>
|
||||
${items.map(item => html`
|
||||
<li>
|
||||
<span>${item.name}</span>
|
||||
<button class="remove" @click=${() => this.#remove(item.id)}>✕</button>
|
||||
</li>
|
||||
`)}
|
||||
</ul>
|
||||
`}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('page-items', PageItems)
|
||||
23
controllers/store.js
Normal file
23
controllers/store.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export class StoreController {
|
||||
constructor(host, store, selector) {
|
||||
this.host = host
|
||||
this.store = store
|
||||
this.selector = selector
|
||||
host.addController(this)
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
this._unsub = this.store.subscribe(
|
||||
(state) => {
|
||||
const next = this.selector(state)
|
||||
if (next !== this.value) {
|
||||
this.value = next
|
||||
this.host.requestUpdate()
|
||||
}
|
||||
}
|
||||
)
|
||||
this.value = this.selector(this.store.getState())
|
||||
}
|
||||
|
||||
hostDisconnected() { this._unsub?.() }
|
||||
}
|
||||
9
importmap.json
Normal file
9
importmap.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"imports": {
|
||||
"lit": "https://esm.sh/lit@3",
|
||||
"lit/decorators.js": "https://esm.sh/lit@3/decorators.js",
|
||||
"zustand": "https://esm.sh/zustand@5/vanilla",
|
||||
"zustand/middleware": "https://esm.sh/zustand@5/middleware",
|
||||
"idb-keyval": "https://esm.sh/idb-keyval@6"
|
||||
}
|
||||
}
|
||||
14
index.html
Normal file
14
index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
<script type="importmap" src="importmap.json"></script>
|
||||
<script src="https://ga.jspm.io/npm:es-module-shims@1.10.0/dist/es-module-shims.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="components/app-root.js"></script>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
18
store/index.js
Normal file
18
store/index.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { createStore } from 'zustand/vanilla'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { idbStorage } from './middleware/persistence.js'
|
||||
|
||||
export const store = createStore(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
items: [],
|
||||
setUser: (user) => set({ user }),
|
||||
addItem: (item) => set(s => ({ items: [...s.items, item] })),
|
||||
}),
|
||||
{
|
||||
name: 'app-store',
|
||||
storage: idbStorage, // custom adapter (see below)
|
||||
}
|
||||
)
|
||||
)
|
||||
8
store/middleware/persistance.js
Normal file
8
store/middleware/persistance.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { get, set, del } from 'idb-keyval'
|
||||
import { createJSONStorage } from 'zustand/middleware'
|
||||
|
||||
export const idbStorage = createJSONStorage(() => ({
|
||||
getItem: (name) => get(name),
|
||||
setItem: (name, value) => set(name, value),
|
||||
removeItem: (name) => del(name),
|
||||
}))
|
||||
Loading…
Add table
Add a link
Reference in a new issue