From 4471f4f5516471305cf15d495077dfd821651f28 Mon Sep 17 00:00:00 2001 From: hunixcode <211940501+hunixcode@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:45:39 -1000 Subject: [PATCH 1/5] chore: add react-router-dom dependency --- package-lock.json | 56 ++++++++++++++++++++++++++++++++++++++++++++++- package.json | 3 ++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 75000df..f07dafe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.2.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^7.18.1" }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", @@ -1643,6 +1644,18 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1952,6 +1965,42 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -2016,6 +2065,11 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 439b7e9..8ca0ab7 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.2.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^7.18.1" }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", From c89873830a22cc98d1b66a735bdaf7d1409e06ca Mon Sep 17 00:00:00 2001 From: hunixcode <211940501+hunixcode@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:45:53 -1000 Subject: [PATCH 2/5] refactor: modularize monolithic app into suite architecture Split the 1126-line App.tsx into a modular structure with: - React Router for multi-page navigation - AuthContext for shared session state - Layout + Sidebar + Topbar as persistent suite shell - PasswordManager extracted as its own page - Dashboard page with app cards - Gate component for setup/loading/auth screens - Storage helpers for Supabase blob operations --- src/App.css | 644 +++++++++++++++++ src/App.tsx | 1139 +----------------------------- src/components/Gate.tsx | 173 +++++ src/components/Icons.tsx | 66 ++ src/components/Layout.tsx | 21 + src/components/ResizeHandles.tsx | 43 ++ src/components/Sidebar.tsx | 45 ++ src/components/Topbar.tsx | 40 ++ src/contexts/AuthContext.tsx | 65 ++ src/lib/storage.ts | 53 ++ src/main.tsx | 14 +- src/pages/Dashboard.tsx | 41 ++ src/pages/PasswordManager.tsx | 424 +++++++++++ src/types.ts | 20 +- 14 files changed, 1669 insertions(+), 1119 deletions(-) create mode 100644 src/components/Gate.tsx create mode 100644 src/components/Icons.tsx create mode 100644 src/components/Layout.tsx create mode 100644 src/components/ResizeHandles.tsx create mode 100644 src/components/Sidebar.tsx create mode 100644 src/components/Topbar.tsx create mode 100644 src/contexts/AuthContext.tsx create mode 100644 src/lib/storage.ts create mode 100644 src/pages/Dashboard.tsx create mode 100644 src/pages/PasswordManager.tsx diff --git a/src/App.css b/src/App.css index 0a273ad..8350e8d 100644 --- a/src/App.css +++ b/src/App.css @@ -22,6 +22,87 @@ * { box-sizing: border-box; margin: 0; padding: 0; } +/* ---------- themes ---------- */ +[data-theme="latte"] { + --bg: #f5f0eb; + --surface: #fffaf5; + --surface-alt: #f0ebe5; + --border: #e0d8d0; + --border-strong: #c8bfb5; + --ink: #2a2420; + --ink-soft: #6b6055; + --ink-faint: #b0a898; + --accent: #c8956c; + --accent-hover: #b07a50; + --accent-soft: #f0e8e0; + --danger: #d94a3a; + --danger-soft: #fce8e5; +} + +[data-theme="espresso"] { + --bg: #0f0d0b; + --surface: #181512; + --surface-alt: #14110e; + --border: #2a241e; + --border-strong: #3a322a; + --ink: #e8e0d8; + --ink-soft: #a09080; + --ink-faint: #6a6055; + --accent: #d4a060; + --accent-hover: #b8884a; + --accent-soft: #221c15; + --danger: #e06050; + --danger-soft: #2e1c18; +} + +[data-theme="matcha"] { + --bg: #131a15; + --surface: #1a241e; + --surface-alt: #161f1a; + --border: #2a3a30; + --border-strong: #3a4a40; + --ink: #e5f0ea; + --ink-soft: #a5b5aa; + --ink-faint: #6a7a6e; + --accent: #7ab86a; + --accent-hover: #5a9a4a; + --accent-soft: #1a2a1e; + --danger: #d96a5a; + --danger-soft: #2e201c; +} + +[data-theme="midnight"] { + --bg: #12161a; + --surface: #1a1f24; + --surface-alt: #161a1f; + --border: #2a323a; + --border-strong: #3a424a; + --ink: #e5eaf0; + --ink-soft: #a5aab5; + --ink-faint: #6a6e7a; + --accent: #6a9fd4; + --accent-hover: #4a7fb4; + --accent-soft: #1a2230; + --danger: #d96a5a; + --danger-soft: #2e1c18; +} + +[data-theme="caramel"] { + --bg: #f8f0e5; + --surface: #fffaf2; + --surface-alt: #f5ede0; + --border: #e8dcc8; + --border-strong: #d8c8b0; + --ink: #2a2218; + --ink-soft: #6a5a48; + --ink-faint: #b0a090; + --accent: #d4a040; + --accent-hover: #b88830; + --accent-soft: #f8f0e0; + --danger: #d94a3a; + --danger-soft: #fce8e5; +} + /* ---------- animations ---------- */ @keyframes fade-in { from { opacity: 0; } @@ -539,3 +620,566 @@ label { background-clip: content-box; } ::-webkit-scrollbar-thumb:hover { background-color: var(--ink-faint); } + +/* ---------- suite layout ---------- */ +.suite-layout { + display: flex; + height: 100%; +} + +.sidebar-nav { + width: 200px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--surface); + border-right: 1px solid var(--border); + padding: 12px; + gap: 2px; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 15px; + padding: 10px 12px; + margin-bottom: 12px; + color: var(--accent); + border-bottom: 1px solid var(--border); +} + +.sidebar-links { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.nav-link { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-radius: var(--radius); + color: var(--ink-soft); + text-decoration: none; + font-size: 13px; + font-weight: 500; + transition: background 0.12s, color 0.12s; + background: transparent; + border: none; + width: 100%; + text-align: left; + cursor: pointer; +} +.nav-link:hover { background: var(--accent-soft); color: var(--ink); } +.nav-link.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } + +.sidebar-footer { + border-top: 1px solid var(--border); + padding-top: 6px; + margin-top: 6px; +} + +.suite-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.topbar-title { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.2px; +} + +.suite-content { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +/* ---------- dashboard ---------- */ +.dashboard { + max-width: 760px; + margin: 0 auto; + padding: 32px 24px; + animation: fade-in 0.2s ease-out; +} +.dashboard h1 { + font-size: 22px; + font-weight: 600; + margin-bottom: 6px; +} +.dashboard > p { + color: var(--ink-soft); + margin-bottom: 28px; + font-size: 14px; +} +.app-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 16px; +} +.app-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 28px 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + text-align: center; + cursor: pointer; + text-decoration: none; + color: var(--ink); + transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; + animation: rise-in 0.25s ease-out; +} +.app-card:hover { + border-color: var(--accent); + box-shadow: var(--shadow-md); +} +.app-card svg { color: var(--accent); } +.app-card h2 { font-size: 15px; font-weight: 600; } +.app-card p { font-size: 12px; color: var(--ink-soft); margin: 0; line-height: 1.4; } + +/* ---------- placeholder page (stubs) ---------- */ +.placeholder-page { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + animation: fade-in 0.2s ease-out; +} +.placeholder-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + color: var(--ink-faint); + text-align: center; +} +.placeholder-content .logo { color: var(--ink-faint); } +.placeholder-content h2 { font-size: 18px; font-weight: 600; color: var(--ink-soft); } +.placeholder-content p { font-size: 13px; } + +/* ---------- vault within suite ---------- */ +.vault { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + animation: fade-in 0.2s ease-out; +} + +.vault-gate { + flex: 1; + display: grid; + place-items: center; + animation: fade-in 0.2s ease-out; +} + +.vault-gate-card { + width: 360px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 36px 32px; + display: flex; + flex-direction: column; + gap: 12px; + text-align: center; + box-shadow: var(--shadow-md); + animation: rise-in 0.3s ease-out; +} +.vault-gate-card h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.2px; } +.vault-gate-card .btn { margin-top: 4px; padding: 9px 14px; } + +.vault-actions { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border-bottom: 1px solid var(--border); + background: var(--surface); + flex-shrink: 0; +} +.vault-actions .count { + margin-right: auto; +} + +/* ---------- authenticator ---------- */ +.auth-page { + flex: 1; + display: flex; + flex-direction: column; + padding: 24px; + max-width: 600px; + animation: fade-in 0.2s ease-out; +} + +.auth-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} +.auth-header h2 { font-size: 18px; font-weight: 600; } + +.auth-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.auth-item { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; + transition: border-color 0.15s; +} +.auth-item.expiring { border-color: var(--danger); } + +.auth-info { + display: flex; + flex-direction: column; + gap: 4px; +} +.auth-issuer { + font-size: 14px; + font-weight: 600; +} +.auth-code { + font-family: "Cascadia Code", "Consolas", monospace; + font-size: 22px; + font-weight: 700; + letter-spacing: 2px; + color: var(--accent); +} + +.auth-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.auth-timer-bar { + margin-top: 16px; + padding: 0 4px; +} +.auth-timer-track { + height: 4px; + background: var(--border); + border-radius: 2px; + overflow: hidden; +} +.auth-timer-fill { + height: 100%; + background: var(--accent); + border-radius: 2px; + transition: width 0.3s linear; +} + +/* ---------- subscriptions ---------- */ +.subs-page { + flex: 1; + display: flex; + flex-direction: column; + padding: 24px; + max-width: 720px; + animation: fade-in 0.2s ease-out; +} + +.subs-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} +.subs-header h2 { font-size: 18px; font-weight: 600; } + +.subs-summary { + display: flex; + gap: 12px; + margin-bottom: 24px; +} +.summary-card { + flex: 1; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + display: flex; + flex-direction: column; + gap: 6px; +} +.summary-card.accent { border-color: var(--accent); } +.summary-label { font-size: 12px; color: var(--ink-soft); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; } +.summary-value { font-size: 22px; font-weight: 700; } + +.subs-upcoming { + margin-bottom: 24px; +} +.subs-upcoming h3 { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--ink-soft); } +.subs-upcoming-list { + display: flex; + flex-direction: column; + gap: 4px; +} +.subs-upcoming-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 13px; +} +.subs-upcoming-name { font-weight: 600; flex: 1; } +.subs-upcoming-date { color: var(--ink-soft); } +.subs-upcoming-cost { font-weight: 600; font-family: "Cascadia Code", "Consolas", monospace; } + +.subs-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.subs-item { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + transition: border-color 0.12s; +} +.subs-item.due-soon { border-color: var(--danger); } + +.subs-item-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.subs-item-name { font-weight: 600; font-size: 14px; } +.subs-item-date { font-size: 12px; color: var(--ink-soft); } + +.subs-cat { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + display: inline-block; + width: fit-content; +} +.cat-streaming { background: #1a2e2a; color: #5fc9b8; } +.cat-cloud { background: #1e2a3a; color: #6b9fd4; } +.cat-productivity { background: #2a241e; color: #d4a86b; } +.cat-development { background: #2a1e2e; color: #b87ad4; } +.cat-music { background: #2e1e1e; color: #d46b6b; } +.cat-news { background: #2e2a1e; color: #c9b85f; } +.cat-other { background: var(--accent-soft); color: var(--accent); } + +.subs-item-cost { + text-align: right; + flex-shrink: 0; +} +.subs-cost-value { font-weight: 700; font-size: 16px; font-family: "Cascadia Code", "Consolas", monospace; } +.subs-cost-cycle { font-size: 12px; color: var(--ink-soft); } + +.subs-item-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +select { + font: inherit; + color: var(--ink); + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--surface); + outline: none; + cursor: pointer; + transition: border-color 0.12s; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23b5a99a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 32px; +} +select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(200, 149, 108, 0.15); +} + +/* ---------- settings ---------- */ +.settings-page { + max-width: 640px; + padding: 32px 24px; + animation: fade-in 0.2s ease-out; +} +.settings-page > h2 { + font-size: 20px; + font-weight: 600; + margin-bottom: 24px; +} + +.settings-section { + margin-bottom: 32px; +} +.settings-section > h3 { + font-size: 14px; + font-weight: 600; + color: var(--ink-soft); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.settings-field { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 20px; +} +.settings-field > label { + font-size: 13px; + font-weight: 600; + color: var(--ink); + flex-direction: row; +} +.settings-field select { max-width: 260px; } + +.theme-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 10px; +} + +.theme-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 14px 10px 10px; + background: var(--surface); + border: 2px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: border-color 0.15s; + font-size: 12px; + font-weight: 500; + color: var(--ink-soft); +} +.theme-card:hover { border-color: var(--ink-faint); } +.theme-card.active { border-color: var(--accent); color: var(--accent); font-weight: 600; } + +.theme-preview { + display: flex; + gap: 4px; + align-items: center; + justify-content: center; + height: 36px; +} +.tp-bg { + width: 22px; + height: 22px; + border-radius: 4px; +} +.tp-surface { + width: 22px; + height: 22px; + border-radius: 4px; +} +.tp-accent { + width: 10px; + height: 22px; + border-radius: 3px; +} + +.theme-card[data-theme-preview="mocha"] .tp-bg { background: #1a1512; } +.theme-card[data-theme-preview="mocha"] .tp-surface { background: #241e19; } +.theme-card[data-theme-preview="mocha"] .tp-accent { background: #c8956c; } + +.theme-card[data-theme-preview="latte"] .tp-bg { background: #f5f0eb; } +.theme-card[data-theme-preview="latte"] .tp-surface { background: #fffaf5; } +.theme-card[data-theme-preview="latte"] .tp-accent { background: #c8956c; } + +.theme-card[data-theme-preview="espresso"] .tp-bg { background: #0f0d0b; } +.theme-card[data-theme-preview="espresso"] .tp-surface { background: #181512; } +.theme-card[data-theme-preview="espresso"] .tp-accent { background: #d4a060; } + +.theme-card[data-theme-preview="matcha"] .tp-bg { background: #131a15; } +.theme-card[data-theme-preview="matcha"] .tp-surface { background: #1a241e; } +.theme-card[data-theme-preview="matcha"] .tp-accent { background: #7ab86a; } + +.theme-card[data-theme-preview="midnight"] .tp-bg { background: #12161a; } +.theme-card[data-theme-preview="midnight"] .tp-surface { background: #1a1f24; } +.theme-card[data-theme-preview="midnight"] .tp-accent { background: #6a9fd4; } + +.theme-card[data-theme-preview="caramel"] .tp-bg { background: #f8f0e5; } +.theme-card[data-theme-preview="caramel"] .tp-surface { background: #fffaf2; } +.theme-card[data-theme-preview="caramel"] .tp-accent { background: #d4a040; } + +/* ---------- subscription suggestions ---------- */ +.subs-modal .modal { max-width: 520px; } + +.suggestion-panel { + border-bottom: 1px solid var(--border); + padding-bottom: 12px; + margin-bottom: 4px; +} + +.suggestion-list { + max-height: 200px; + overflow-y: auto; + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.suggestion-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: none; + border-radius: var(--radius); + background: transparent; + color: var(--ink); + text-align: left; + cursor: pointer; + transition: background 0.1s; + width: 100%; +} +.suggestion-item:hover { background: var(--accent-soft); } + +.suggestion-name { flex: 1; font-weight: 600; font-size: 13px; } + +.suggestion-cycle { + font-size: 11px; + color: var(--ink-faint); + text-transform: uppercase; + letter-spacing: 0.3px; +} diff --git a/src/App.tsx b/src/App.tsx index 8f7616f..00de403 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,1126 +1,31 @@ -import { FormEvent, useEffect, useMemo, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import { open, save } from "@tauri-apps/plugin-dialog"; -import { User } from "@supabase/supabase-js"; -import { supabase, supabaseReady, saveSupabaseConfig } from "./supabase"; -import { Entry } from "./types"; +import { Routes, Route, Navigate } from "react-router-dom"; +import { useAuth } from "./contexts/AuthContext"; +import Gate from "./components/Gate"; +import Layout from "./components/Layout"; +import Dashboard from "./pages/Dashboard"; +import PasswordManager from "./pages/PasswordManager"; +import Authenticator from "./pages/Authenticator"; +import Subscriptions from "./pages/Subscriptions"; +import Settings from "./pages/Settings"; import "./App.css"; -type Screen = "loading" | "auth" | "create" | "unlock" | "vault"; -type AuthMode = "signin" | "signup"; - -const emptyForm: Entry = { - id: "", - name: "", - username: "", - password: "", - url: "", - notes: "", - folder: "", -}; - -function ActionIcon({ path }: { path: string }) { - return ( - - ); -} - -const ICON_EDIT = "M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"; -const ICON_FOLDER = - "M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2v11z"; -const ICON_TRASH = - "M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"; - -function Logo({ size = 40 }: { size?: number }) { - return ( - - ); -} - -const VAULT_PATH = "vault.mocha"; - -type ResizeDir = "North" | "South" | "East" | "West" | "NorthEast" | "NorthWest" | "SouthEast" | "SouthWest"; - -const CURSOR_MAP: Record = { - North: "n-resize", - South: "s-resize", - East: "e-resize", - West: "w-resize", - NorthEast: "ne-resize", - NorthWest: "nw-resize", - SouthEast: "se-resize", - SouthWest: "sw-resize", -}; - -function ResizeHandle({ dir, className }: { dir: ResizeDir; className?: string }) { - return ( -
{ - e.preventDefault(); - e.stopPropagation(); - getCurrentWindow().startResizeDragging(dir); - }} - /> - ); -} - -function ResizeHandles() { - return ( - <> - - - - - - - - - - ); -} - -async function uploadVault(userId: string, blob: string) { - const { error } = await supabase.storage - .from("vaults") - .upload(`${userId}/${VAULT_PATH}`, blob, { - contentType: "application/json", - upsert: true, - }); - if (error) throw error; -} - -async function downloadVault( - userId: string -): Promise { - const { data, error } = await supabase.storage - .from("vaults") - .download(`${userId}/${VAULT_PATH}`); - if (error) { - if (error.message.includes("not found") || error.status === 404) return null; - throw error; - } - return await data.text(); -} - export default function App() { - const [screen, setScreen] = useState("loading"); - const [user, setUser] = useState(null); - const [authMode, setAuthMode] = useState("signin"); - const [email, setEmail] = useState(""); - const [authPassword, setAuthPassword] = useState(""); - const [master, setMaster] = useState(""); - const [confirm, setConfirm] = useState(""); - const [error, setError] = useState(""); - const [entries, setEntries] = useState([]); - const [search, setSearch] = useState(""); - const [selectedId, setSelectedId] = useState(null); - const [editing, setEditing] = useState(false); - const [form, setForm] = useState(emptyForm); - const [showPw, setShowPw] = useState(false); - const [toast, setToast] = useState(""); - const [showImportInfo, setShowImportInfo] = useState(false); - const [activeFolder, setActiveFolder] = useState(null); - const [moveEntry, setMoveEntry] = useState(null); - const [moveFolder, setMoveFolder] = useState(""); - const [setupUrl, setSetupUrl] = useState(""); - const [setupKey, setSetupKey] = useState(""); - const [setupError, setSetupError] = useState(""); - const [setupSaving, setSetupSaving] = useState(false); - - function startDrag(e: React.MouseEvent) { - if ((e.target as HTMLElement).closest("button, input, textarea, a")) return; - getCurrentWindow().startDragging(); - } - - function handleSetup(e: FormEvent) { - e.preventDefault(); - setSetupError(""); - const url = setupUrl.trim(); - const key = setupKey.trim(); - if (!url) return setSetupError("Project URL is required."); - if (!key) return setSetupError("API key is required."); - try { - new URL(url); - } catch { - return setSetupError("Invalid URL. Make sure it includes https://"); - } - setSetupSaving(true); - saveSupabaseConfig(url, key); - window.location.reload(); - } - - useEffect(() => { - if (!supabaseReady) return; - supabase.auth.getSession().then(({ data: { session } }) => { - if (session?.user) { - setUser(session.user); - checkVault(session.user.id); - } else { - setScreen("auth"); - } - }); - }, []); - - async function checkVault(userId: string) { - try { - const vaultData = await downloadVault(userId); - if (vaultData) { - setScreen("unlock"); - } else { - setScreen("create"); - } - } catch { - setScreen("create"); - } - } - - const folders = useMemo( - () => - Array.from(new Set(entries.map((e) => e.folder).filter(Boolean))).sort( - (a, b) => a.localeCompare(b) - ), - [entries] - ); - const unfiledCount = useMemo( - () => entries.filter((e) => !e.folder).length, - [entries] - ); - - useEffect(() => { - if (activeFolder === null) return; - const gone = - activeFolder === "" ? unfiledCount === 0 : !folders.includes(activeFolder); - if (gone) setActiveFolder(null); - }, [activeFolder, folders, unfiledCount]); - - const filtered = useMemo(() => { - const inFolder = - activeFolder === null - ? entries - : entries.filter((e) => - activeFolder === "" ? !e.folder : e.folder === activeFolder - ); - const q = search.trim().toLowerCase(); - const list = q - ? inFolder.filter( - (e) => - e.name.toLowerCase().includes(q) || - e.username.toLowerCase().includes(q) || - e.url.toLowerCase().includes(q) - ) - : inFolder; - return [...list].sort((a, b) => a.name.localeCompare(b.name)); - }, [entries, search, activeFolder]); - - const selected = entries.find((e) => e.id === selectedId) ?? null; - - function notify(msg: string) { - setToast(msg); - window.setTimeout(() => setToast(""), 1800); - } + const { user, loading, supabaseReady } = useAuth(); - function enterVault(list: Entry[]) { - setEntries(list); - setScreen("vault"); - setMaster(""); - setConfirm(""); - setError(""); - } - - async function handleAuth(e: FormEvent) { - e.preventDefault(); - setError(""); - if (!email.trim() || !authPassword) { - return setError("Email and password are required."); - } - try { - const { data, error: authError } = - authMode === "signin" - ? await supabase.auth.signInWithPassword({ - email, - password: authPassword, - }) - : await supabase.auth.signUp({ - email, - password: authPassword, - }); - if (authError) throw authError; - if (data.user) { - setUser(data.user); - await checkVault(data.user.id); - } - } catch (err) { - setError(String(err)); - } - } - - async function handleSignOut() { - await supabase.auth.signOut(); - setUser(null); - setEntries([]); - setSelectedId(null); - setEditing(false); - setEmail(""); - setAuthPassword(""); - setScreen("auth"); - } - - async function handleCreate(e: FormEvent) { - e.preventDefault(); - setError(""); - if (master.length < 8) - return setError("Master password must be at least 8 characters."); - if (master !== confirm) return setError("Passwords do not match."); - if (!user) return setError("Not authenticated."); - try { - const [list, blob] = await invoke<[Entry[], string]>("create_vault", { - master, - }); - await uploadVault(user.id, blob); - enterVault(list); - } catch (err) { - setError(String(err)); - } - } - - async function handleUnlock(e: FormEvent) { - e.preventDefault(); - setError(""); - if (!user) return setError("Not authenticated."); - try { - const vaultData = await downloadVault(user.id); - if (!vaultData) return setError("No vault found on server."); - const list = await invoke("unlock_vault", { - master, - vaultData, - }); - enterVault(list); - } catch (err) { - setError(String(err)); - } - } - - async function handleLock() { - await invoke("lock_vault"); - setEntries([]); - setSelectedId(null); - setEditing(false); - setScreen("unlock"); - } - - function startNew() { - setForm({ ...emptyForm, folder: activeFolder ?? "" }); - setSelectedId(null); - setEditing(true); - setShowPw(false); - } - - function startEdit(entry: Entry) { - setForm(entry); - setEditing(true); - setShowPw(false); - } - - async function handleSave(e: FormEvent) { - e.preventDefault(); - if (!form.name.trim()) return notify("Name is required"); - if (!user) return notify("Not authenticated"); - const entry = { ...form, folder: form.folder.trim() }; - try { - if (entry.id) { - const blob = await invoke("update_entry", { entry }); - setEntries((prev) => prev.map((x) => (x.id === entry.id ? entry : x))); - setSelectedId(entry.id); - await uploadVault(user.id, blob); - } else { - const [created, blob] = await invoke<[Entry, string]>("add_entry", { - entry, - }); - setEntries((prev) => [...prev, created]); - setSelectedId(created.id); - await uploadVault(user.id, blob); - } - setEditing(false); - notify("Saved"); - } catch (err) { - notify(String(err)); - } - } - - async function handleDelete(entry: Entry) { - if (!window.confirm(`Delete "${entry.name}"?`)) return; - if (!user) return notify("Not authenticated"); - try { - const blob = await invoke("delete_entry", { id: entry.id }); - setEntries((prev) => prev.filter((x) => x.id !== entry.id)); - setSelectedId(null); - setEditing(false); - await uploadVault(user.id, blob); - notify("Deleted"); - } catch (err) { - notify(String(err)); - } - } - - async function handleMove() { - if (!moveEntry || !user) return; - const entry = { ...moveEntry, folder: moveFolder.trim() }; - try { - const blob = await invoke("update_entry", { entry }); - setEntries((prev) => prev.map((x) => (x.id === entry.id ? entry : x))); - await uploadVault(user.id, blob); - setMoveEntry(null); - notify(entry.folder ? `Moved to ${entry.folder}` : "Removed from folder"); - } catch (err) { - notify(String(err)); - } - } - - async function copy(text: string, label: string) { - await navigator.clipboard.writeText(text); - notify(`${label} copied`); - } - - async function handleExport() { - try { - const path = await save({ - defaultPath: "mocha-bitwarden.json", - filters: [{ name: "JSON", extensions: ["json"] }], - }); - if (!path) return; - await invoke("export_bitwarden", { path }); - notify("Vault exported"); - } catch (err) { - notify(String(err)); - } - } - - async function handleImport() { - setShowImportInfo(false); - if (!user) return notify("Not authenticated"); - try { - const path = await open({ - multiple: false, - filters: [{ name: "JSON", extensions: ["json"] }], - }); - if (!path) return; - const [list, blob] = await invoke<[Entry[], string]>("import_bitwarden", { - path, - }); - setEntries(list); - await uploadVault(user.id, blob); - notify("Vault imported"); - } catch (err) { - notify(String(err)); - } - } - - function generatePassword() { - const chars = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+"; - const bytes = new Uint32Array(20); - crypto.getRandomValues(bytes); - const pw = Array.from(bytes, (b) => chars[b % chars.length]).join(""); - setForm((f) => ({ ...f, password: pw })); - setShowPw(true); - } - - if (!supabaseReady) { - return ( -
- -
-
- - - -
-
-
-
- -
-

Setup required

-

- Mocha needs a Supabase project to store your vault. -

-
-
- 1 -
- Create a Supabase project -

Go to app.supabase.com and create a free project.

-
-
-
- 2 -
- Create a storage bucket -

In Storage, create a bucket named vaults (private).

-
-
-
- 3 -
- Get your API keys -

In Project Settings → API, copy the Project URL and anon key.

-
-
-
- 4 -
- Enter your credentials below -

Paste the values you just copied.

-
-
-
-
- - - {setupError &&
{setupError}
} -
- -
-
-

- Credentials are stored locally on your machine. You can also set them - in a .env file at the project root. -

-
-
- ); - } - - if (screen === "loading") { - return ( -
- -
-
- - - -
-
-
- -
-
- ); - } - - if (screen === "auth") { - const signing = authMode === "signin"; - return ( -
- -
-
- - - -
-
-
-
- -
-

Mocha

-

- {signing - ? "Sign in to access your vault." - : "Create an account to get started."} -

- setEmail(e.target.value)} - autoFocus - /> - setAuthPassword(e.target.value)} - /> - {error &&
{error}
} - - -
-
- ); - } - - if (screen === "create" || screen === "unlock") { - const creating = screen === "create"; - return ( -
- -
-
- - - -
-
-
-
- -
-

Mocha

-

- {creating - ? "Set a master password to encrypt your vault." - : "Enter your master password to unlock your vault."} -

- setMaster(e.target.value)} - autoFocus - /> - {creating && ( - setConfirm(e.target.value)} - /> - )} - {error &&
{error}
} - - -
-
- ); + if (!supabaseReady || loading || !user) { + return ; } return ( -
- -
-
- - - {" "} - Mocha - - {entries.length} {entries.length === 1 ? "entry" : "entries"} - -
-
- - - - -
e.stopPropagation()}> - - - -
-
-
- -
- - -
- {editing ? ( -
-

{form.id ? "Edit entry" : "New entry"}

- - - - - -