create-noy-db 0.1.0-pre.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +283 -0
  3. package/dist/bin/create.d.ts +1 -0
  4. package/dist/bin/create.js +815 -0
  5. package/dist/bin/create.js.map +1 -0
  6. package/dist/bin/noy-db.d.ts +1 -0
  7. package/dist/bin/noy-db.js +555 -0
  8. package/dist/bin/noy-db.js.map +1 -0
  9. package/dist/index.d.ts +697 -0
  10. package/dist/index.js +967 -0
  11. package/dist/index.js.map +1 -0
  12. package/package.json +74 -0
  13. package/templates/electron/README.md +85 -0
  14. package/templates/electron/_gitignore +31 -0
  15. package/templates/electron/electron/main.ts +51 -0
  16. package/templates/electron/index.html +12 -0
  17. package/templates/electron/package.json +32 -0
  18. package/templates/electron/src/App.vue +75 -0
  19. package/templates/electron/src/components/AddInvoiceForm.vue +39 -0
  20. package/templates/electron/src/components/InvoiceTable.vue +41 -0
  21. package/templates/electron/src/main.ts +48 -0
  22. package/templates/electron/src/stores/invoices.ts +35 -0
  23. package/templates/electron/src/style.css +119 -0
  24. package/templates/electron/tsconfig.json +19 -0
  25. package/templates/electron/vite.config.ts +21 -0
  26. package/templates/nuxt-default/README.md +38 -0
  27. package/templates/nuxt-default/_gitignore +32 -0
  28. package/templates/nuxt-default/app/app.vue +37 -0
  29. package/templates/nuxt-default/app/pages/index.vue +21 -0
  30. package/templates/nuxt-default/app/pages/invoices.vue +62 -0
  31. package/templates/nuxt-default/app/stores/invoices.ts +23 -0
  32. package/templates/nuxt-default/nuxt.config.ts +30 -0
  33. package/templates/nuxt-default/package.json +28 -0
  34. package/templates/nuxt-default/tsconfig.json +3 -0
  35. package/templates/vanilla/README.md +77 -0
  36. package/templates/vanilla/_gitignore +29 -0
  37. package/templates/vanilla/index.html +54 -0
  38. package/templates/vanilla/package.json +21 -0
  39. package/templates/vanilla/src/main.ts +153 -0
  40. package/templates/vanilla/src/style.css +127 -0
  41. package/templates/vanilla/tsconfig.json +17 -0
  42. package/templates/vanilla/vite.config.ts +10 -0
  43. package/templates/vite-vue/README.md +95 -0
  44. package/templates/vite-vue/_gitignore +29 -0
  45. package/templates/vite-vue/index.html +12 -0
  46. package/templates/vite-vue/package.json +28 -0
  47. package/templates/vite-vue/src/App.vue +75 -0
  48. package/templates/vite-vue/src/components/AddInvoiceForm.vue +39 -0
  49. package/templates/vite-vue/src/components/InvoiceTable.vue +41 -0
  50. package/templates/vite-vue/src/main.ts +55 -0
  51. package/templates/vite-vue/src/stores/invoices.ts +35 -0
  52. package/templates/vite-vue/src/style.css +119 -0
  53. package/templates/vite-vue/tsconfig.json +18 -0
  54. package/templates/vite-vue/vite.config.ts +12 -0
@@ -0,0 +1,54 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>{{PROJECT_NAME}} — noy-db vanilla</title>
8
+ </head>
9
+ <body>
10
+ <div id="app">
11
+ <header>
12
+ <h1>{{PROJECT_NAME}}</h1>
13
+ <p class="subtitle">
14
+ A zero-knowledge encrypted invoice store. Open DevTools &rarr;
15
+ Application &rarr; IndexedDB to see the ciphertext.
16
+ </p>
17
+ </header>
18
+
19
+ <section id="status" class="status">
20
+ <p class="status-text">Loading…</p>
21
+ </section>
22
+
23
+ <section id="controls" class="controls" hidden>
24
+ <button id="add-invoice">Add invoice</button>
25
+ <button id="refresh">Refresh</button>
26
+ <button id="close-vault" class="danger">Close vault</button>
27
+ </section>
28
+
29
+ <section id="invoices" class="invoices" hidden>
30
+ <h2>Invoices</h2>
31
+ <table>
32
+ <thead>
33
+ <tr>
34
+ <th>ID</th>
35
+ <th>Client</th>
36
+ <th>Amount</th>
37
+ <th>Status</th>
38
+ </tr>
39
+ </thead>
40
+ <tbody id="invoices-body"></tbody>
41
+ </table>
42
+ </section>
43
+
44
+ <footer>
45
+ <p>
46
+ Encrypted with AES-256-GCM. Key derived from your passphrase via
47
+ PBKDF2-SHA256 (600 000 iterations). The only people who can read
48
+ your data are those who hold the passphrase.
49
+ </p>
50
+ </footer>
51
+ </div>
52
+ <script type="module" src="/src/main.ts"></script>
53
+ </body>
54
+ </html>
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "{{PROJECT_NAME}}",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview",
10
+ "verify": "noy-db verify"
11
+ },
12
+ "dependencies": {
13
+ "@noy-db/hub": "^0.12.0",
14
+ "@noy-db/to-browser-idb": "^0.12.0"
15
+ },
16
+ "devDependencies": {
17
+ "create-noy-db": "^0.12.0",
18
+ "typescript": "^5.6.0",
19
+ "vite": "^5.4.0"
20
+ }
21
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * {{PROJECT_NAME}} — noy-db vanilla starter.
3
+ *
4
+ * A minimal, no-framework integration of noy-db. Everything this file
5
+ * needs to do:
6
+ *
7
+ * 1. Prompt for a passphrase (derives the encryption key).
8
+ * 2. Open an encrypted vault backed by IndexedDB.
9
+ * 3. Render a table of invoices.
10
+ * 4. Let the user add, refresh, and lock.
11
+ *
12
+ * The entire app is ~200 lines — the library does the heavy lifting.
13
+ */
14
+
15
+ import { createNoydb, type Noydb, type Vault, type Collection } from '@noy-db/hub'
16
+ import { browserIdbStore } from '@noy-db/to-browser-idb'
17
+ import './style.css'
18
+
19
+ // ─── Domain type ────────────────────────────────────────────────────
20
+
21
+ interface Invoice {
22
+ id: string
23
+ client: string
24
+ amount: number
25
+ status: 'draft' | 'open' | 'paid' | 'overdue'
26
+ issueDate: string // ISO-8601
27
+ }
28
+
29
+ // ─── DOM references ────────────────────────────────────────────────
30
+
31
+ const statusEl = document.querySelector<HTMLElement>('#status')!
32
+ const statusTextEl = document.querySelector<HTMLElement>('.status-text')!
33
+ const controlsEl = document.querySelector<HTMLElement>('#controls')!
34
+ const invoicesSection = document.querySelector<HTMLElement>('#invoices')!
35
+ const invoicesBody = document.querySelector<HTMLTableSectionElement>('#invoices-body')!
36
+
37
+ const addBtn = document.querySelector<HTMLButtonElement>('#add-invoice')!
38
+ const refreshBtn = document.querySelector<HTMLButtonElement>('#refresh')!
39
+ const closeBtn = document.querySelector<HTMLButtonElement>('#close-vault')!
40
+
41
+ // ─── App state ─────────────────────────────────────────────────────
42
+
43
+ let db: Noydb | null = null
44
+ let vault: Vault | null = null
45
+ let invoices: Collection<Invoice> | null = null
46
+
47
+ // ─── Lifecycle ─────────────────────────────────────────────────────
48
+
49
+ async function unlock() {
50
+ // In a real app you would build a proper modal. For the starter we
51
+ // use the browser prompt — it blocks, it's ugly, and it gets the
52
+ // passphrase in two lines.
53
+ const passphrase = prompt(
54
+ 'Enter passphrase for {{PROJECT_NAME}}\n\n' +
55
+ 'This derives the master encryption key. Same passphrase every time.\n' +
56
+ 'Lose it and the data is unrecoverable (by design).',
57
+ )
58
+ if (!passphrase) {
59
+ showStatus('Cancelled — reload to try again.')
60
+ return
61
+ }
62
+
63
+ showStatus('Unlocking vault…')
64
+
65
+ db = await createNoydb({
66
+ store: browserIdbStore({ prefix: '{{PROJECT_NAME}}' }),
67
+ user: 'owner',
68
+ secret: passphrase,
69
+ })
70
+ vault = await db.openVault('demo')
71
+ invoices = vault.collection<Invoice>('invoices')
72
+
73
+ statusEl.hidden = true
74
+ controlsEl.hidden = false
75
+ invoicesSection.hidden = false
76
+ await render()
77
+ }
78
+
79
+ async function render() {
80
+ if (!invoices) return
81
+ const rows = await invoices.list()
82
+ rows.sort((a, b) => a.issueDate.localeCompare(b.issueDate))
83
+
84
+ invoicesBody.innerHTML = ''
85
+ if (rows.length === 0) {
86
+ const tr = document.createElement('tr')
87
+ tr.innerHTML = `<td colspan="4" class="empty">No invoices yet — click "Add invoice" to create one.</td>`
88
+ invoicesBody.appendChild(tr)
89
+ return
90
+ }
91
+ for (const inv of rows) {
92
+ const tr = document.createElement('tr')
93
+ tr.innerHTML = `
94
+ <td>${inv.id}</td>
95
+ <td>${escapeHtml(inv.client)}</td>
96
+ <td>${inv.amount.toLocaleString()}</td>
97
+ <td><span class="status-pill status-${inv.status}">${inv.status}</span></td>
98
+ `
99
+ invoicesBody.appendChild(tr)
100
+ }
101
+ }
102
+
103
+ async function addInvoice() {
104
+ if (!invoices) return
105
+ const client = prompt('Client name?') || 'Unnamed'
106
+ const amountStr = prompt('Amount?') || '0'
107
+ const amount = Number.parseFloat(amountStr)
108
+ if (Number.isNaN(amount)) {
109
+ alert('Amount must be a number.')
110
+ return
111
+ }
112
+ const id = `inv-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
113
+ await invoices.put(id, {
114
+ id,
115
+ client,
116
+ amount,
117
+ status: 'draft',
118
+ issueDate: new Date().toISOString().slice(0, 10),
119
+ })
120
+ await render()
121
+ }
122
+
123
+ async function closeVault() {
124
+ if (!db) return
125
+ await db.close()
126
+ db = null
127
+ vault = null
128
+ invoices = null
129
+ controlsEl.hidden = true
130
+ invoicesSection.hidden = true
131
+ showStatus('Vault closed. Reload the page to unlock again.')
132
+ }
133
+
134
+ // ─── Helpers ───────────────────────────────────────────────────────
135
+
136
+ function showStatus(text: string) {
137
+ statusEl.hidden = false
138
+ statusTextEl.textContent = text
139
+ }
140
+
141
+ function escapeHtml(s: string): string {
142
+ const div = document.createElement('div')
143
+ div.textContent = s
144
+ return div.innerHTML
145
+ }
146
+
147
+ // ─── Wire everything up ────────────────────────────────────────────
148
+
149
+ addBtn.addEventListener('click', () => void addInvoice())
150
+ refreshBtn.addEventListener('click', () => void render())
151
+ closeBtn.addEventListener('click', () => void closeVault())
152
+
153
+ window.addEventListener('DOMContentLoaded', () => void unlock())
@@ -0,0 +1,127 @@
1
+ :root {
2
+ color-scheme: light;
3
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
4
+ color: #1f2937;
5
+ background: #f9fafb;
6
+ }
7
+
8
+ * {
9
+ box-sizing: border-box;
10
+ }
11
+
12
+ body {
13
+ margin: 0;
14
+ padding: 1rem;
15
+ }
16
+
17
+ #app {
18
+ max-width: 900px;
19
+ margin: 0 auto;
20
+ }
21
+
22
+ header {
23
+ border-bottom: 1px solid #e5e7eb;
24
+ padding-bottom: 1rem;
25
+ margin-bottom: 1.5rem;
26
+ }
27
+
28
+ h1 {
29
+ font-size: 1.5rem;
30
+ margin: 0 0 0.5rem 0;
31
+ }
32
+
33
+ .subtitle {
34
+ color: #6b7280;
35
+ font-size: 0.9rem;
36
+ margin: 0;
37
+ }
38
+
39
+ .status {
40
+ background: white;
41
+ border: 1px solid #e5e7eb;
42
+ border-radius: 0.5rem;
43
+ padding: 1.5rem;
44
+ text-align: center;
45
+ }
46
+
47
+ .controls {
48
+ display: flex;
49
+ gap: 0.5rem;
50
+ margin-bottom: 1rem;
51
+ }
52
+
53
+ button {
54
+ padding: 0.5rem 1rem;
55
+ border: 1px solid #2563eb;
56
+ background: #2563eb;
57
+ color: white;
58
+ border-radius: 0.25rem;
59
+ cursor: pointer;
60
+ font-size: 0.875rem;
61
+ }
62
+
63
+ button:hover {
64
+ background: #1d4ed8;
65
+ }
66
+
67
+ button.danger {
68
+ background: #dc2626;
69
+ border-color: #dc2626;
70
+ }
71
+
72
+ .invoices h2 {
73
+ font-size: 1.1rem;
74
+ margin-top: 0;
75
+ }
76
+
77
+ table {
78
+ width: 100%;
79
+ border-collapse: collapse;
80
+ background: white;
81
+ border: 1px solid #e5e7eb;
82
+ border-radius: 0.5rem;
83
+ overflow: hidden;
84
+ }
85
+
86
+ th, td {
87
+ padding: 0.5rem 0.75rem;
88
+ text-align: left;
89
+ border-bottom: 1px solid #e5e7eb;
90
+ font-size: 0.875rem;
91
+ }
92
+
93
+ th {
94
+ background: #f3f4f6;
95
+ font-weight: 600;
96
+ }
97
+
98
+ tr:last-child td {
99
+ border-bottom: 0;
100
+ }
101
+
102
+ .empty {
103
+ color: #6b7280;
104
+ text-align: center;
105
+ font-style: italic;
106
+ }
107
+
108
+ .status-pill {
109
+ display: inline-block;
110
+ padding: 0.125rem 0.5rem;
111
+ border-radius: 0.25rem;
112
+ font-size: 0.75rem;
113
+ text-transform: uppercase;
114
+ font-weight: 600;
115
+ }
116
+ .status-draft { background: #f3f4f6; color: #4b5563; }
117
+ .status-open { background: #dbeafe; color: #1e40af; }
118
+ .status-paid { background: #d1fae5; color: #065f46; }
119
+ .status-overdue { background: #fee2e2; color: #b91c1c; }
120
+
121
+ footer {
122
+ margin-top: 3rem;
123
+ padding-top: 1rem;
124
+ border-top: 1px solid #e5e7eb;
125
+ color: #6b7280;
126
+ font-size: 0.85rem;
127
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "isolatedModules": true,
10
+ "resolveJsonModule": true,
11
+ "esModuleInterop": true,
12
+ "allowSyntheticDefaultImports": true,
13
+ "skipLibCheck": true,
14
+ "useDefineForClassFields": true
15
+ },
16
+ "include": ["src/**/*.ts"]
17
+ }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'vite'
2
+
3
+ export default defineConfig({
4
+ server: {
5
+ port: 5173,
6
+ },
7
+ build: {
8
+ target: 'es2022',
9
+ },
10
+ })
@@ -0,0 +1,95 @@
1
+ # {{PROJECT_NAME}}
2
+
3
+ A Vite + Vue 3 + Pinia + [noy-db](https://github.com/vLannaAi/noy-db)
4
+ starter. Pure client-side SPA — no Nuxt, no SSR. Scaffolded by
5
+ `create-noy-db`.
6
+
7
+ ## Stack
8
+
9
+ - **Vite** — dev server + build
10
+ - **Vue 3** — `<script setup>` + composition API
11
+ - **Pinia** — reactive state management
12
+ - **@noy-db/hub** — zero-knowledge encrypted document store
13
+ - **@noy-db/in-pinia** — `defineNoydbStore` wires a Pinia store to an
14
+ encrypted collection
15
+ - **@noy-db/to-browser-idb** — IndexedDB adapter (atomic CAS, large
16
+ quota)
17
+
18
+ Everything stored is encrypted with AES-256-GCM before it touches
19
+ IndexedDB. The adapter only ever sees ciphertext.
20
+
21
+ ## When to pick this template
22
+
23
+ - You want **Vue + Pinia** reactivity without a full Nuxt / SSR setup.
24
+ - You're building a **client-side SPA**, a desktop-wrapped
25
+ (Electron / Tauri) app, or embedding into an existing static host.
26
+ - You want a shorter install graph and faster dev server startup than
27
+ Nuxt provides.
28
+
29
+ If you want SSR + file-based routing, pick the `nuxt-default`
30
+ template instead (`npm create noy-db@latest -- --template nuxt-default`).
31
+ If you want no framework at all, pick `vanilla`.
32
+
33
+ ## Getting started
34
+
35
+ ```bash
36
+ pnpm install # or npm/yarn/bun
37
+ pnpm dev # vite dev on http://localhost:5173
38
+ pnpm build # production build
39
+ pnpm preview # preview the production build
40
+ pnpm verify # run the noy-db integrity check
41
+ pnpm typecheck # vue-tsc
42
+ ```
43
+
44
+ First time the page loads it will prompt for a **passphrase** — that
45
+ passphrase derives the master key. Lose it and the data is
46
+ unrecoverable (by design).
47
+
48
+ ## Project layout
49
+
50
+ ```
51
+ src/
52
+ main.ts — app bootstrap: pinia + noydb + mount
53
+ App.vue — root component
54
+ components/
55
+ InvoiceTable.vue — reactive table bound to the store
56
+ AddInvoiceForm.vue — form that dispatches store.add()
57
+ stores/
58
+ invoices.ts — defineNoydbStore<Invoice>('invoices', { vault: 'demo' })
59
+ ```
60
+
61
+ The store exposes:
62
+
63
+ | Member | Description |
64
+ |--------|-------------|
65
+ | `items` | reactive array of records |
66
+ | `count` | reactive getter |
67
+ | `$ready` | promise resolved on first hydration |
68
+ | `add(id, record)` | encrypt + persist + update reactive state |
69
+ | `update(id, record)` | upsert (`Collection.put` semantics) |
70
+ | `remove(id)` | delete + update reactive state |
71
+ | `refresh()` | re-hydrate from the adapter |
72
+ | `query()` | chainable query DSL |
73
+
74
+ ## Verifying zero-knowledge
75
+
76
+ Open DevTools → Application → IndexedDB → your origin. Every record
77
+ shows `{ _noydb: 1, _v, _ts, _iv, _data }`. The `_data` blob is
78
+ AES-GCM ciphertext — the literal record contents never appear.
79
+
80
+ ## Adding a collection
81
+
82
+ ```bash
83
+ npx noy-db add clients
84
+ ```
85
+
86
+ Scaffolds `src/stores/clients.ts` with a typed Client interface +
87
+ a `defineNoydbStore` binding. Edit the generated interface to match
88
+ your domain.
89
+
90
+ ## Documentation
91
+
92
+ - [noy-db getting started](https://github.com/vLannaAi/noy-db/blob/main/docs/choose-your-path.md)
93
+ - [Topology matrix](https://github.com/vLannaAi/noy-db/blob/main/docs/topologies.md)
94
+ - [Architecture](https://github.com/vLannaAi/noy-db/blob/main/docs/architecture.md)
95
+ - [Roadmap](https://github.com/vLannaAi/noy-db/blob/main/ROADMAP.md)
@@ -0,0 +1,29 @@
1
+ # Vite / build output
2
+ dist
3
+ .vite
4
+
5
+ # Node
6
+ node_modules
7
+ *.log
8
+ .npm
9
+
10
+ # Env files
11
+ .env
12
+ .env.local
13
+ .env.*.local
14
+
15
+ # OS
16
+ .DS_Store
17
+ Thumbs.db
18
+
19
+ # IDE
20
+ .vscode/*
21
+ !.vscode/extensions.json
22
+ .idea
23
+ *.suo
24
+ *.ntvs*
25
+ *.njsproj
26
+ *.sln
27
+
28
+ # Test coverage
29
+ coverage
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>{{PROJECT_NAME}} — noy-db + Vue 3 + Pinia</title>
7
+ </head>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script type="module" src="/src/main.ts"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "{{PROJECT_NAME}}",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vue-tsc --noEmit && vite build",
9
+ "preview": "vite preview",
10
+ "typecheck": "vue-tsc --noEmit",
11
+ "verify": "noy-db verify"
12
+ },
13
+ "dependencies": {
14
+ "@noy-db/hub": "^0.12.0",
15
+ "@noy-db/in-pinia": "^0.12.0",
16
+ "@noy-db/in-vue": "^0.12.0",
17
+ "@noy-db/to-browser-idb": "^0.12.0",
18
+ "pinia": "^2.2.0",
19
+ "vue": "^3.5.0"
20
+ },
21
+ "devDependencies": {
22
+ "@vitejs/plugin-vue": "^5.1.0",
23
+ "create-noy-db": "^0.12.0",
24
+ "typescript": "^5.6.0",
25
+ "vite": "^5.4.0",
26
+ "vue-tsc": "^2.1.0"
27
+ }
28
+ }
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref } from 'vue'
3
+ import { useInvoices, DEFAULT_INVOICES, type Invoice } from './stores/invoices'
4
+ import InvoiceTable from './components/InvoiceTable.vue'
5
+ import AddInvoiceForm from './components/AddInvoiceForm.vue'
6
+
7
+ const store = useInvoices()
8
+ const ready = ref(false)
9
+
10
+ onMounted(async () => {
11
+ await store.$ready
12
+
13
+ // Seed the collection on first load so the demo shows something.
14
+ if (store.items.length === 0) {
15
+ for (const inv of DEFAULT_INVOICES) {
16
+ await store.add(inv.id, inv)
17
+ }
18
+ }
19
+ ready.value = true
20
+ })
21
+
22
+ async function onAdd(invoice: Invoice) {
23
+ await store.add(invoice.id, invoice)
24
+ }
25
+
26
+ async function onRemove(id: string) {
27
+ await store.remove(id)
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <div class="app">
33
+ <header>
34
+ <h1>{{PROJECT_NAME}}</h1>
35
+ <p class="subtitle">
36
+ Encrypted invoices backed by noy-db + Vue 3 + Pinia.
37
+ Open DevTools → Application → IndexedDB to see ciphertext only.
38
+ </p>
39
+ </header>
40
+
41
+ <main v-if="ready">
42
+ <section class="summary">
43
+ <div class="stat">
44
+ <div class="label">Invoices</div>
45
+ <div class="value">{{ store.count }}</div>
46
+ </div>
47
+ <div class="stat">
48
+ <div class="label">Total (open)</div>
49
+ <div class="value">
50
+ {{
51
+ store.items
52
+ .filter((i) => i.status === 'open')
53
+ .reduce((n, i) => n + i.amount, 0)
54
+ .toLocaleString()
55
+ }}
56
+ </div>
57
+ </div>
58
+ </section>
59
+
60
+ <AddInvoiceForm @submit="onAdd" />
61
+
62
+ <InvoiceTable :invoices="store.items" @remove="onRemove" />
63
+ </main>
64
+
65
+ <p v-else class="loading">Unlocking vault…</p>
66
+
67
+ <footer>
68
+ <p>
69
+ AES-256-GCM at rest. Key derived from your passphrase via
70
+ PBKDF2-SHA256 (600 000 iterations). Lose the passphrase and
71
+ the data is unrecoverable — by design.
72
+ </p>
73
+ </footer>
74
+ </div>
75
+ </template>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import type { Invoice } from '../stores/invoices'
4
+
5
+ const emit = defineEmits<{ (e: 'submit', invoice: Invoice): void }>()
6
+
7
+ const client = ref('')
8
+ const amount = ref<number | null>(null)
9
+ const status = ref<Invoice['status']>('draft')
10
+
11
+ function submit() {
12
+ if (!client.value || amount.value === null || Number.isNaN(amount.value)) return
13
+ const id = `inv-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
14
+ emit('submit', {
15
+ id,
16
+ client: client.value,
17
+ amount: amount.value,
18
+ status: status.value,
19
+ issueDate: new Date().toISOString().slice(0, 10),
20
+ })
21
+ client.value = ''
22
+ amount.value = null
23
+ status.value = 'draft'
24
+ }
25
+ </script>
26
+
27
+ <template>
28
+ <form class="add-form" @submit.prevent="submit">
29
+ <input v-model="client" placeholder="Client name" required />
30
+ <input v-model.number="amount" type="number" min="0" step="0.01" placeholder="Amount" required />
31
+ <select v-model="status">
32
+ <option value="draft">draft</option>
33
+ <option value="open">open</option>
34
+ <option value="paid">paid</option>
35
+ <option value="overdue">overdue</option>
36
+ </select>
37
+ <button type="submit">Add invoice</button>
38
+ </form>
39
+ </template>