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 } 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");3738 const canSave = () => {39 if (!title().trim()) return false;40 if (selectedType() === "login" && !password().trim()) return false;41 return true;42 };4344 const allTags = createMemo(() => {45 const tagSet = new Set<string>();46 for (const entry of props.entries ?? []) {47 for (const tag of entry.tags ?? []) tagSet.add(tag);48 }49 return Array.from(tagSet).sort();50 });5152 const tagSuggestions = createMemo(() => {53 const input = tagInput().toLowerCase().trim();54 if (!input) return [];55 return allTags().filter(t =>56 t.toLowerCase().includes(input) && !tags().includes(t)57 ).slice(0, 5);58 });5960 function addTag(tag: string) {61 const n = tag.toLowerCase().trim();62 if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;63 setTags([...tags(), n]);64 }6566 function removeTag(tag: string) {67 setTags(tags().filter(t => t !== tag));68 }6970 function handleTagKeyDown(e: KeyboardEvent) {71 if (e.key === "Enter" || e.key === ",") {72 e.preventDefault();73 if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }74 } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {75 removeTag(tags()[tags().length - 1]);76 }77 }7879 80 createEffect(async () => {81 const pwd = password();82 if (pwd.length < 4) { setPasswordStrength(null); return; }83 try {84 if (!zxcvbnFn()) {85 const mod = await import("zxcvbn");86 setZxcvbnFn(() => mod.default);87 }88 const fn = zxcvbnFn();89 if (fn) setPasswordStrength(fn(pwd).score);90 } catch {91 setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);92 }93 });9495 onMount(() => {96 const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;97 if (el) el.focus();98 });99100 function getNormalizedTotp(): string | undefined {101 const value = totp().trim();102 if (!value) return undefined;103 if (value.startsWith("otpauth://")) {104 const parsed = parseOtpauthURI(value);105 return parsed?.secret ?? value;106 }107 return value;108 }109110 async function handleSave() {111 if (!canSave()) {112 setError(selectedType() === "note" ? "title is required" : "title and password are required");113 return;114 }115116 setSaving(true);117 setError("");118119 const payload: Record<string, unknown> = {120 type: selectedType(),121 title: title().trim(),122 notes: notes().trim() || undefined,123 tags: tags().length > 0 ? tags() : undefined,124 };125126 if (selectedType() === "login") {127 payload.username = username().trim();128 payload.password = password();129 payload.url = url().trim();130 payload.totp = getNormalizedTotp();131 }132133 if (props.entry) {134 const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });135 const result = res as { ok: boolean; error?: string };136 if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }137 await props.onSave({ ...props.entry, ...payload } as VaultEntry);138 return;139 }140141 const res = await sendMessage({ type: "ADD_ENTRY", payload });142 const result = res as { ok: boolean; data?: VaultEntry; error?: string };143 if (!result.ok) { setError(result.error ?? "could not save entry"); setSaving(false); return; }144 await props.onSave(result.data);145 }146147 const strengthLabel = () => {148 const s = passwordStrength();149 if (s === null) return null;150 return ["weak", "weak", "fair", "good", "strong"][s] as string;151 };152153 return (154 <div class="peach-overlay-view ov-enter-right" style="display:flex">155 <div class="peach-overlay-header">156 <button157 class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"158 onClick={props.onBack}159 >160 cancel161 </button>162 <span class="peach-overlay-title">163 {props.entry ? "edit entry" : "new entry"}164 </span>165 <button166 class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"167 onClick={() => void handleSave()}168 disabled={saving() || !canSave()}169 >170 {saving() ? "saving…" : props.entry ? "save" : "create"}171 </button>172 </div>173174 {}175 <div class="peach-entry-body peach-entry-body--new">176177 {}178 <Show when={!props.entry}>179 <div class="peach-new-type-row">180 <button181 class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}182 onClick={() => setSelectedType("login")}183 >login</button>184 <button185 class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}186 onClick={() => setSelectedType("note")}187 >note</button>188 </div>189 </Show>190191 {}192 <div class="peach-new-title-wrap">193 <input194 class="peach-new-title-input"195 placeholder={selectedType() === "note" ? "note title" : "entry name"}196 autocomplete="off"197 value={title()}198 onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}199 onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}200 />201 </div>202203 {}204 <Show when={selectedType() === "login"}>205 <div class="peach-domain">206 <input207 class="peach-domain-value peach-domain-value--edit"208 placeholder="domain.com"209 autocomplete="off"210 value={url()}211 onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}212 />213 </div>214215 <section class="peach-fields">216 {}217 <div class="peach-field-card">218 <span class="peach-label">username</span>219 <div class="peach-field-value-row">220 <input221 class="peach-field-value peach-field-value--edit"222 placeholder="username or email"223 autocomplete="off"224 value={username()}225 onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}226 />227 </div>228 </div>229230 {}231 <div class="peach-field-card">232 <span class="peach-label">password</span>233 <div class="peach-password-edit-row">234 <input235 class="peach-field-value peach-field-value--edit"236 type={showPassword() ? "text" : "password"}237 placeholder="password"238 autocomplete="new-password"239 value={password()}240 onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}241 />242 <button243 class="peach-inline-action peach-password-toggle-btn"244 type="button"245 onClick={() => setShowPassword(!showPassword())}246 aria-label={showPassword() ? "Hide password" : "Show password"}247 >248 <Show249 when={showPassword()}250 fallback={251 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">252 <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" />253 <circle cx="7" cy="5" r="1.75" />254 </svg>255 }256 >257 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">258 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />259 <path d="M3.5 5.5L2.5 7.5" />260 <path d="M7 6.5V8.5" />261 <path d="M10.5 5.5L11.5 7.5" />262 </svg>263 </Show>264 </button>265 </div>266267 {}268 <Show when={strengthLabel() !== null}>269 <div class="peach-strength-hairline">270 <div class="peach-strength-hairline-track">271 <div272 class="peach-strength-hairline-fill"273 style={{274 width: ["25%","40%","60%","80%","100%"][passwordStrength()!],275 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()!],276 }}277 />278 </div>279 <span class="peach-strength-label">{strengthLabel()}</span>280 </div>281 </Show>282283 {}284 <button285 class="peach-generate-toggle-link"286 type="button"287 onClick={() => { setShowGenerator(!showGenerator()); }}288 >289 {showGenerator() ? "☰ hide generator" : "⚙ generate"}290 </button>291 <GeneratorDrawer292 compact293 open={showGenerator()}294 onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}295 />296 </div>297298 {}299 <Show when={showTotp() || totp()}>300 <div class="peach-field-card">301 <span class="peach-label">2fa secret</span>302 <div class="peach-field-value-row">303 <input304 class="peach-field-value peach-field-value--edit"305 placeholder="BASE32 or otpauth:// URI"306 autocomplete="off"307 value={totp()}308 onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}309 />310 </div>311 </div>312 </Show>313 </section>314 </Show>315316 {}317 <Show when={selectedType() === "note"}>318 <div style="margin:4px 12px 0">319 <NoteEditorInline320 initialContent={notes()}321 onContentChange={setNotes}322 />323 </div>324 </Show>325326 {}327 <Show when={selectedType() === "login" && (showNotes() || notes())}>328 <div class="peach-field-card" style="margin:0 12px">329 <span class="peach-label">notes</span>330 <textarea331 class="peach-new-textarea"332 placeholder="anything worth remembering"333 value={notes()}334 onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}335 />336 </div>337 </Show>338339 {}340 <div class="peach-entry-add-links">341 <Show when={selectedType() === "login" && !showTotp() && !totp()}>342 <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>343 + add 2FA344 </button>345 </Show>346 <Show when={selectedType() === "login" && !showNotes() && !notes()}>347 <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>348 + add notes349 </button>350 </Show>351 </div>352353 {}354 <div class="peach-field-card" style="margin:4px 12px 12px">355 <span class="peach-label">tags</span>356 <div class="peach-tag-input-wrap">357 <For each={tags()}>358 {(tag) => (359 <span class="peach-tag-chip">360 {tag}361 <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>362 </span>363 )}364 </For>365 <input366 class="peach-tag-input"367 placeholder={tags().length === 0 ? "add tags…" : ""}368 value={tagInput()}369 onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}370 onKeyDown={handleTagKeyDown}371 onFocus={() => setShowTagSuggestions(true)}372 onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}373 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"374 />375 </div>376 <Show when={showTagSuggestions() && tagSuggestions().length > 0}>377 <div class="peach-tag-suggestions" style="margin-top:4px;">378 <For each={tagSuggestions()}>379 {(s) => (380 <button381 class="peach-tag-suggestion"382 onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}383 >{s}</button>384 )}385 </For>386 </div>387 </Show>388 </div>389390 <Show when={error()}>391 <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>392 </Show>393394 </div>395 </div>396 );397}