1import { createSignal, createMemo, createEffect, For, Show, onMount } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { sendMessage } from "../App";4import { parseOtpauthURI } from "../../crypto/totp";5import { NoteEditorInline, Toolbar } from "./NoteEditor";6import { GeneratorDrawer } from "../components/GeneratorDrawer";78interface AddProps {9 entry?: VaultEntry;10 entries?: VaultEntry[];11 autoOpenGenerator?: boolean;12 onBack: () => void;13 onSave: (entry?: VaultEntry) => void | Promise<void>;14}1516export function AddView(props: AddProps) {17 const [title, setTitle] = createSignal(props.entry?.title ?? "");18 const [username, setUsername] = createSignal(props.entry?.username ?? "");19 const [password, setPassword] = createSignal(props.entry?.password ?? "");20 const [url, setUrl] = createSignal(props.entry?.url ?? "");21 const [totp, setTotp] = createSignal(props.entry?.totp ?? "");22 const [notes, setNotes] = createSignal(props.entry?.notes ?? "");23 const [saving, setSaving] = createSignal(false);24 const [error, setError] = createSignal("");25 const [passwordStrength, setPasswordStrength] = createSignal<number | null>(null);26 const [zxcvbnFn, setZxcvbnFn] = createSignal<((pwd: string) => { score: number }) | null>(null);27 const [tags, setTags] = createSignal<string[]>(props.entry?.tags ?? []);28 const [tagInput, setTagInput] = createSignal("");29 const [showTagSuggestions, setShowTagSuggestions] = createSignal(false);30 const [showGenerator, setShowGenerator] = createSignal(props.autoOpenGenerator ?? false);31 const [showPassword, setShowPassword] = createSignal(false);32 const [showTotp, setShowTotp] = createSignal(!!(props.entry?.totp));33 const [showNotes, setShowNotes] = createSignal(!!(props.entry?.notes));3435 const isNoteEntry = () => props.entry?.type === "note";36 const [selectedType, setSelectedType] = createSignal<"login" | "note">(isNoteEntry() ? "note" : "login");37 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);3839 function noteExec(cmd: string, val?: string) {40 document.execCommand(cmd, false, val);41 noteEditorEl()?.focus();42 }4344 const canSave = () => {45 if (!title().trim()) return false;46 if (selectedType() === "login" && !password().trim()) return false;47 return true;48 };4950 const allTags = createMemo(() => {51 const tagSet = new Set<string>();52 for (const entry of props.entries ?? []) {53 for (const tag of entry.tags ?? []) tagSet.add(tag);54 }55 return Array.from(tagSet).sort();56 });5758 const tagSuggestions = createMemo(() => {59 const input = tagInput().toLowerCase().trim();60 if (!input) return [];61 return allTags().filter(t =>62 t.toLowerCase().includes(input) && !tags().includes(t)63 ).slice(0, 5);64 });6566 function addTag(tag: string) {67 const n = tag.toLowerCase().trim();68 if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;69 setTags([...tags(), n]);70 }7172 function removeTag(tag: string) {73 setTags(tags().filter(t => t !== tag));74 }7576 function handleTagKeyDown(e: KeyboardEvent) {77 if (e.key === "Enter" || e.key === ",") {78 e.preventDefault();79 if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }80 } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {81 removeTag(tags()[tags().length - 1]);82 }83 }8485 86 createEffect(async () => {87 const pwd = password();88 if (pwd.length < 4) { setPasswordStrength(null); return; }89 try {90 if (!zxcvbnFn()) {91 const mod = await import("zxcvbn");92 setZxcvbnFn(() => mod.default);93 }94 const fn = zxcvbnFn();95 if (fn) setPasswordStrength(fn(pwd).score);96 } catch {97 setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);98 }99 });100101 onMount(() => {102 const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;103 if (el) el.focus();104 });105106 function getNormalizedTotp(): string | undefined {107 const value = totp().trim();108 if (!value) return undefined;109 if (value.startsWith("otpauth://")) {110 const parsed = parseOtpauthURI(value);111 return parsed?.secret ?? value;112 }113 return value;114 }115116 async function handleSave() {117 if (!canSave()) {118 setError(selectedType() === "note" ? "title is required" : "title and password are required");119 return;120 }121122 setSaving(true);123 setError("");124125 const payload: Record<string, unknown> = {126 type: selectedType(),127 title: title().trim(),128 notes: notes().trim() || undefined,129 tags: tags().length > 0 ? tags() : undefined,130 };131132 if (selectedType() === "login") {133 payload.username = username().trim();134 payload.password = password();135 payload.url = url().trim();136 payload.totp = getNormalizedTotp();137 }138139 if (props.entry) {140 const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });141 const result = res as { ok: boolean; error?: string };142 if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }143 await props.onSave({ ...props.entry, ...payload } as VaultEntry);144 return;145 }146147 const res = await sendMessage({ type: "ADD_ENTRY", payload });148 const result = res as { ok: boolean; data?: VaultEntry; error?: string };149 if (!result.ok) { setError(result.error ?? "could not save entry"); setSaving(false); return; }150 await props.onSave(result.data);151 }152153 const strengthLabel = () => {154 const s = passwordStrength();155 if (s === null) return null;156 return ["weak", "weak", "fair", "good", "strong"][s] as string;157 };158159 return (160 <div class="peach-overlay-view ov-enter-right" style="display:flex">161 <div class="peach-overlay-header">162 <button163 class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"164 onClick={props.onBack}165 >166 cancel167 </button>168 <span class="peach-overlay-title">169 {props.entry ? "edit entry" : "new entry"}170 </span>171 <button172 class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"173 onClick={() => void handleSave()}174 disabled={saving() || !canSave()}175 >176 {saving() ? "saving…" : props.entry ? "save" : "create"}177 </button>178 </div>179180 {}181 <div class="peach-entry-body peach-entry-body--new">182183 {}184 <Show when={!props.entry || selectedType() === "note"}>185 <div class="peach-new-type-row">186 <Show when={!props.entry}>187 <button188 class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}189 onClick={() => setSelectedType("login")}190 >login</button>191 <button192 class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}193 onClick={() => setSelectedType("note")}194 >note</button>195 </Show>196 <Show when={selectedType() === "note"}>197 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />198 </Show>199 </div>200 </Show>201202 {}203 <div class="peach-new-title-wrap">204 <input205 class="peach-new-title-input"206 placeholder={selectedType() === "note" ? "note title" : "entry name"}207 autocomplete="off"208 value={title()}209 onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}210 onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}211 />212 </div>213214 {}215 <Show when={selectedType() === "login"}>216 <div class="peach-domain">217 <input218 class="peach-domain-value peach-domain-value--edit"219 placeholder="domain.com"220 autocomplete="off"221 value={url()}222 onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}223 />224 </div>225226 <section class="peach-fields">227 {}228 <div class="peach-field-card">229 <span class="peach-label">username</span>230 <div class="peach-field-value-row">231 <input232 class="peach-field-value peach-field-value--edit"233 placeholder="username or email"234 autocomplete="off"235 value={username()}236 onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}237 />238 </div>239 </div>240241 {}242 <div class="peach-field-card">243 <span class="peach-label">password</span>244 <div class="peach-password-edit-row">245 <input246 class="peach-field-value peach-field-value--edit"247 type={showPassword() ? "text" : "password"}248 placeholder="password"249 autocomplete="new-password"250 value={password()}251 onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}252 />253 <button254 class="peach-inline-action peach-password-toggle-btn"255 type="button"256 onClick={() => setShowPassword(!showPassword())}257 aria-label={showPassword() ? "Hide password" : "Show password"}258 >259 <Show260 when={showPassword()}261 fallback={262 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">263 <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />264 <circle cx="7" cy="5" r="1.75" />265 </svg>266 }267 >268 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">269 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />270 <path d="M3.5 5.5L2.5 7.5" />271 <path d="M7 6.5V8.5" />272 <path d="M10.5 5.5L11.5 7.5" />273 </svg>274 </Show>275 </button>276 </div>277278 {}279 <Show when={strengthLabel() !== null}>280 <div class="peach-strength-hairline">281 <div class="peach-strength-hairline-track">282 <div283 class="peach-strength-hairline-fill"284 style={{285 width: ["25%","40%","60%","80%","100%"][passwordStrength()!],286 background: ["rgba(220,80,60,0.65)","rgba(220,80,60,0.65)","rgba(200,140,60,0.6)","rgba(180,162,100,0.55)","rgba(150,175,110,0.6)"][passwordStrength()!],287 }}288 />289 </div>290 <span class="peach-strength-label">{strengthLabel()}</span>291 </div>292 </Show>293294 {}295 <button296 class="peach-generate-toggle-link"297 type="button"298 onClick={() => { setShowGenerator(!showGenerator()); }}299 >300 {showGenerator() ? "☰ hide generator" : "⚙ generate"}301 </button>302 <GeneratorDrawer303 compact304 open={showGenerator()}305 onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}306 />307 </div>308309 {}310 <Show when={showTotp() || totp()}>311 <div class="peach-field-card">312 <span class="peach-label">2fa secret</span>313 <div class="peach-field-value-row">314 <input315 class="peach-field-value peach-field-value--edit"316 placeholder="BASE32 or otpauth:// URI"317 autocomplete="off"318 value={totp()}319 onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}320 />321 </div>322 </div>323 </Show>324 </section>325 </Show>326327 {}328 <Show when={selectedType() === "note"}>329 <div class="peach-note-body">330 <NoteEditorInline331 initialContent={notes()}332 onContentChange={setNotes}333 onReady={(el) => setNoteEditorEl(el)}334 />335 </div>336 </Show>337338 {}339 <Show when={selectedType() === "login" && (showNotes() || notes())}>340 <div class="peach-field-card" style="margin:0 12px">341 <span class="peach-label">notes</span>342 <textarea343 class="peach-new-textarea"344 placeholder="anything worth remembering"345 value={notes()}346 onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}347 />348 </div>349 </Show>350351 {}352 <div class="peach-entry-add-links">353 <Show when={selectedType() === "login" && !showTotp() && !totp()}>354 <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>355 + add 2FA356 </button>357 </Show>358 <Show when={selectedType() === "login" && !showNotes() && !notes()}>359 <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>360 + add notes361 </button>362 </Show>363 </div>364365 {}366 <div class="peach-field-card" style="margin:4px 12px 12px">367 <span class="peach-label">tags</span>368 <div class="peach-tag-input-wrap">369 <For each={tags()}>370 {(tag) => (371 <span class="peach-tag-chip">372 {tag}373 <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>374 </span>375 )}376 </For>377 <input378 class="peach-tag-input"379 placeholder={tags().length === 0 ? "add tags…" : ""}380 value={tagInput()}381 onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}382 onKeyDown={handleTagKeyDown}383 onFocus={() => setShowTagSuggestions(true)}384 onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}385 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"386 />387 </div>388 <Show when={showTagSuggestions() && tagSuggestions().length > 0}>389 <div class="peach-tag-suggestions" style="margin-top:4px;">390 <For each={tagSuggestions()}>391 {(s) => (392 <button393 class="peach-tag-suggestion"394 onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}395 >{s}</button>396 )}397 </For>398 </div>399 </Show>400 </div>401402 <Show when={error()}>403 <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>404 </Show>405406 </div>407 </div>408 );409}