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 { GeneratorDrawer } from "../components/GeneratorDrawer";67interface AddProps {8 entry?: VaultEntry;9 entries?: VaultEntry[];10 autoOpenGenerator?: boolean;11 onBack: () => void;12 onSave: (entry?: VaultEntry) => void | Promise<void>;13}1415export function AddView(props: AddProps) {16 const [title, setTitle] = createSignal(props.entry?.title ?? "");17 const [username, setUsername] = createSignal(props.entry?.username ?? "");18 const [password, setPassword] = createSignal(props.entry?.password ?? "");19 const [url, setUrl] = createSignal(props.entry?.url ?? "");20 const [totp, setTotp] = createSignal(props.entry?.totp ?? "");21 const [notes, setNotes] = createSignal(props.entry?.notes ?? "");22 const [saving, setSaving] = createSignal(false);23 const [error, setError] = createSignal("");24 const [passwordStrength, setPasswordStrength] = createSignal<number | null>(null);25 const [zxcvbnFn, setZxcvbnFn] = createSignal<((pwd: string) => { score: number }) | null>(null);26 const [tags, setTags] = createSignal<string[]>(props.entry?.tags ?? []);27 const [tagInput, setTagInput] = createSignal("");28 const [showTagSuggestions, setShowTagSuggestions] = createSignal(false);29 const [showGenerator, setShowGenerator] = createSignal(props.autoOpenGenerator ?? false);30 const [showPassword, setShowPassword] = createSignal(false);31 const [showTotp, setShowTotp] = createSignal(!!(props.entry?.totp));32 const [showNotes, setShowNotes] = createSignal(!!(props.entry?.notes));3334 const isNoteEntry = () => props.entry?.type === "note";35 const [selectedType, setSelectedType] = createSignal<"login" | "note">(isNoteEntry() ? "note" : "login");3637 const canSave = () => {38 if (!title().trim()) return false;39 if (selectedType() === "login" && !password().trim()) return false;40 return true;41 };4243 const allTags = createMemo(() => {44 const tagSet = new Set<string>();45 for (const entry of props.entries ?? []) {46 for (const tag of entry.tags ?? []) tagSet.add(tag);47 }48 return Array.from(tagSet).sort();49 });5051 const tagSuggestions = createMemo(() => {52 const input = tagInput().toLowerCase().trim();53 if (!input) return [];54 return allTags().filter(t =>55 t.toLowerCase().includes(input) && !tags().includes(t)56 ).slice(0, 5);57 });5859 function addTag(tag: string) {60 const n = tag.toLowerCase().trim();61 if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;62 setTags([...tags(), n]);63 }6465 function removeTag(tag: string) {66 setTags(tags().filter(t => t !== tag));67 }6869 function handleTagKeyDown(e: KeyboardEvent) {70 if (e.key === "Enter" || e.key === ",") {71 e.preventDefault();72 if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }73 } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {74 removeTag(tags()[tags().length - 1]);75 }76 }7778 79 createEffect(async () => {80 const pwd = password();81 if (pwd.length < 4) { setPasswordStrength(null); return; }82 try {83 if (!zxcvbnFn()) {84 const mod = await import("zxcvbn");85 setZxcvbnFn(() => mod.default);86 }87 const fn = zxcvbnFn();88 if (fn) setPasswordStrength(fn(pwd).score);89 } catch {90 setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);91 }92 });9394 onMount(() => {95 const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;96 if (el) el.focus();97 });9899 function getNormalizedTotp(): string | undefined {100 const value = totp().trim();101 if (!value) return undefined;102 if (value.startsWith("otpauth://")) {103 const parsed = parseOtpauthURI(value);104 return parsed?.secret ?? value;105 }106 return value;107 }108109 async function handleSave() {110 if (!canSave()) {111 setError(selectedType() === "note" ? "title is required" : "title and password are required");112 return;113 }114115 setSaving(true);116 setError("");117118 const payload: Record<string, unknown> = {119 type: selectedType(),120 title: title().trim(),121 notes: notes().trim() || undefined,122 tags: tags().length > 0 ? tags() : undefined,123 };124125 if (selectedType() === "login") {126 payload.username = username().trim();127 payload.password = password();128 payload.url = url().trim();129 payload.totp = getNormalizedTotp();130 }131132 if (props.entry) {133 const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });134 const result = res as { ok: boolean; error?: string };135 if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }136 await props.onSave({ ...props.entry, ...payload } as VaultEntry);137 return;138 }139140 const res = await sendMessage({ type: "ADD_ENTRY", payload });141 const result = res as { ok: boolean; data?: VaultEntry; error?: string };142 if (!result.ok) { setError(result.error ?? "could not save entry"); setSaving(false); return; }143 await props.onSave(result.data);144 }145146 const strengthLabel = () => {147 const s = passwordStrength();148 if (s === null) return null;149 return ["weak", "weak", "fair", "good", "strong"][s] as string;150 };151152 return (153 <div class="peach-overlay-view ov-enter-right" style="display:flex">154 <div class="peach-overlay-header">155 <button156 class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"157 onClick={props.onBack}158 >159 cancel160 </button>161 <span class="peach-overlay-title">162 {props.entry ? "edit entry" : "new entry"}163 </span>164 <button165 class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"166 onClick={() => void handleSave()}167 disabled={saving() || !canSave()}168 >169 {saving() ? "saving…" : props.entry ? "save" : "create"}170 </button>171 </div>172173 {}174 <div class="peach-entry-body peach-entry-body--new">175176 {}177 <Show when={!props.entry}>178 <div class="peach-new-type-row">179 <button180 class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}181 onClick={() => setSelectedType("login")}182 >login</button>183 <button184 class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}185 onClick={() => setSelectedType("note")}186 >note</button>187 </div>188 </Show>189190 {}191 <div class="peach-new-title-wrap">192 <input193 class="peach-new-title-input"194 placeholder={selectedType() === "note" ? "note title" : "entry name"}195 autocomplete="off"196 value={title()}197 onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}198 onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}199 />200 </div>201202 {}203 <Show when={selectedType() === "login"}>204 <div class="peach-domain">205 <input206 class="peach-domain-value peach-domain-value--edit"207 placeholder="domain.com"208 autocomplete="off"209 value={url()}210 onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}211 />212 </div>213214 <section class="peach-fields">215 {}216 <div class="peach-field-card">217 <span class="peach-label">username</span>218 <div class="peach-field-value-row">219 <input220 class="peach-field-value peach-field-value--edit"221 placeholder="username or email"222 autocomplete="off"223 value={username()}224 onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}225 />226 </div>227 </div>228229 {}230 <div class="peach-field-card">231 <span class="peach-label">password</span>232 <div class="peach-password-edit-row">233 <input234 class="peach-field-value peach-field-value--edit"235 type={showPassword() ? "text" : "password"}236 placeholder="password"237 autocomplete="new-password"238 value={password()}239 onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}240 />241 <button242 class="peach-inline-action peach-password-toggle-btn"243 type="button"244 onClick={() => setShowPassword(!showPassword())}245 aria-label={showPassword() ? "Hide password" : "Show password"}246 >247 <Show248 when={showPassword()}249 fallback={250 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">251 <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" />252 <circle cx="7" cy="5" r="1.75" />253 </svg>254 }255 >256 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">257 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />258 <path d="M3.5 5.5L2.5 7.5" />259 <path d="M7 6.5V8.5" />260 <path d="M10.5 5.5L11.5 7.5" />261 </svg>262 </Show>263 </button>264 </div>265266 {}267 <Show when={strengthLabel() !== null}>268 <div class="peach-strength-hairline">269 <div class="peach-strength-hairline-track">270 <div271 class="peach-strength-hairline-fill"272 style={{273 width: ["25%","40%","60%","80%","100%"][passwordStrength()!],274 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()!],275 }}276 />277 </div>278 <span class="peach-strength-label">{strengthLabel()}</span>279 </div>280 </Show>281282 {}283 <button284 class="peach-generate-toggle-link"285 type="button"286 onClick={() => { setShowGenerator(!showGenerator()); }}287 >288 {showGenerator() ? "☰ hide generator" : "⚙ generate"}289 </button>290 <GeneratorDrawer291 compact292 open={showGenerator()}293 onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}294 />295 </div>296297 {}298 <Show when={showTotp() || totp()}>299 <div class="peach-field-card">300 <span class="peach-label">2fa secret</span>301 <div class="peach-field-value-row">302 <input303 class="peach-field-value peach-field-value--edit"304 placeholder="BASE32 or otpauth:// URI"305 autocomplete="off"306 value={totp()}307 onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}308 />309 </div>310 </div>311 </Show>312 </section>313 </Show>314315 {}316 <Show when={selectedType() === "note"}>317 <div class="peach-field-card" style="margin:4px 12px 0">318 <span class="peach-label">content</span>319 <textarea320 class="peach-new-textarea"321 placeholder="write your note here…"322 value={notes()}323 onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}324 />325 </div>326 </Show>327328 {}329 <Show when={selectedType() === "login" && (showNotes() || notes())}>330 <div class="peach-field-card" style="margin:0 12px">331 <span class="peach-label">notes</span>332 <textarea333 class="peach-new-textarea"334 placeholder="anything worth remembering"335 value={notes()}336 onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}337 />338 </div>339 </Show>340341 {}342 <div class="peach-entry-add-links">343 <Show when={selectedType() === "login" && !showTotp() && !totp()}>344 <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>345 + add 2FA346 </button>347 </Show>348 <Show when={selectedType() === "login" && !showNotes() && !notes()}>349 <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>350 + add notes351 </button>352 </Show>353 </div>354355 {}356 <div class="peach-field-card" style="margin:4px 12px 12px">357 <span class="peach-label">tags</span>358 <div class="peach-tag-input-wrap">359 <For each={tags()}>360 {(tag) => (361 <span class="peach-tag-chip">362 {tag}363 <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>364 </span>365 )}366 </For>367 <input368 class="peach-tag-input"369 placeholder={tags().length === 0 ? "add tags…" : ""}370 value={tagInput()}371 onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}372 onKeyDown={handleTagKeyDown}373 onFocus={() => setShowTagSuggestions(true)}374 onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}375 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"376 />377 </div>378 <Show when={showTagSuggestions() && tagSuggestions().length > 0}>379 <div class="peach-tag-suggestions" style="margin-top:4px;">380 <For each={tagSuggestions()}>381 {(s) => (382 <button383 class="peach-tag-suggestion"384 onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}385 >{s}</button>386 )}387 </For>388 </div>389 </Show>390 </div>391392 <Show when={error()}>393 <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>394 </Show>395396 </div>397 </div>398 );399}