1import { createSignal, onMount, Show } from "solid-js";2import { generatePassword } from "../../shared/generate";3import type { PasswordPolicy } from "../../shared/types";4import { sendMessage, triggerPipPulse, triggerRing } from "../App";56interface GeneratorDrawerProps {7 open: boolean;8 policy?: PasswordPolicy;9 onUse: (password: string) => void;10 11 compact?: boolean;12}1314export function GeneratorDrawer(props: GeneratorDrawerProps) {15 const [password, setPassword] = createSignal("");16 const [length, setLength] = createSignal(20);17 const [upper, setUpper] = createSignal(true);18 const [lower, setLower] = createSignal(true);19 const [numbers, setNumbers] = createSignal(true);20 const [symbols, setSymbols] = createSignal(true);21 const [copied, setCopied] = createSignal(false);2223 function initFromPolicy(policy: PasswordPolicy) {24 let nextLength = length();25 let nextSymbols = symbols();2627 if (policy.minLength) nextLength = Math.max(nextLength, policy.minLength);28 if (policy.maxLength) nextLength = Math.min(nextLength, policy.maxLength);29 if (policy.forbidsSymbols) nextSymbols = false;30 if (policy.requiresSymbols) nextSymbols = true;3132 setLength(nextLength);33 setSymbols(nextSymbols);34 }3536 onMount(async () => {37 if (props.policy) {38 initFromPolicy(props.policy);39 } else {40 const res = await sendMessage({ type: "GET_SITE_PASSWORD_POLICY" });41 const data = (res as { ok: boolean; data?: PasswordPolicy }).data;42 if (data && Object.keys(data).length > 0) {43 initFromPolicy(data);44 }45 }46 regenerate();47 });4849 function regenerate(options?: {50 length?: number;51 upper?: boolean;52 lower?: boolean;53 numbers?: boolean;54 symbols?: boolean;55 }) {56 setPassword(57 generatePassword({58 length: options?.length ?? length(),59 includeUppercase: options?.upper ?? upper(),60 includeLowercase: options?.lower ?? lower(),61 includeNumbers: options?.numbers ?? numbers(),62 includeSymbols: options?.symbols ?? symbols(),63 })64 );65 }6667 async function copyPassword(button?: HTMLButtonElement) {68 const response = await sendMessage({69 type: "COPY",70 payload: { text: password(), label: "password" },71 });72 if ((response as { ok: boolean }).ok) {73 triggerRing();74 triggerPipPulse();75 setCopied(true);76 if (button) {77 button.classList.remove("is-retreat");78 const drain = document.createElement("span");79 drain.className = "peach-copy-drain";80 const bar = document.createElement("span");81 bar.className = "peach-copy-drain-bar";82 drain.appendChild(bar);83 button.appendChild(drain);84 window.setTimeout(() => {85 setCopied(false);86 button.classList.remove("is-copied");87 button.classList.add("is-retreat");88 drain.remove();89 window.setTimeout(() => button.classList.remove("is-retreat"), 160);90 }, 1400);91 return;92 }93 window.setTimeout(() => setCopied(false), 1400);94 }95 }9697 function toggleUppercase() {98 const next = !upper();99 setUpper(next);100 regenerate({ upper: next });101 }102103 function toggleLowercase() {104 const next = !lower();105 setLower(next);106 regenerate({ lower: next });107 }108109 function toggleNumbers() {110 const next = !numbers();111 setNumbers(next);112 regenerate({ numbers: next });113 }114115 function toggleSymbols() {116 const next = !symbols();117 setSymbols(next);118 regenerate({ symbols: next });119 }120121 const passwordFontSize = () => {122 const value = password();123 if (value.length > 48) return "16px";124 if (value.length > 24) return "20px";125 return "24px";126 };127128 const RefreshIcon = () => (129 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">130 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />131 <path d="M9.5 2.5H11V1" />132 </svg>133 );134135 const CopyIcon = () => (136 <span class="peach-copy-icon">137 <svg class="peach-copy-icon-svg" width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">138 <rect x="4" y="4" width="8" height="9" rx="1.5" />139 <path d="M10 4V2.5A1.5 1.5 0 0 0 8.5 1h-6A1.5 1.5 0 0 0 1 2.5v7A1.5 1.5 0 0 0 2.5 11H4" />140 </svg>141 </span>142 );143144 return (145 <div class={`peach-generator-drawer ${props.open ? "is-open" : ""}`}>146 <div class="peach-generator-drawer-inner">147 <div class={`peach-generator-drawer-content${props.compact ? " peach-generator-drawer-content--compact" : ""}`}>148149 {}150 <Show when={props.compact}>151 <div class="peach-pwgen-compact-display-row">152 <span class="peach-pwgen-compact-output">{password()}</span>153 <button154 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"155 onClick={() => regenerate()}156 aria-label="Regenerate"157 >158 <RefreshIcon />159 </button>160 <button161 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}162 onClick={(e) => copyPassword(e.currentTarget as HTMLButtonElement)}163 aria-label="Copy"164 >165 <CopyIcon /><span class="peach-copy-sheen" />166 </button>167 </div>168 <div class="peach-pwgen-compact-controls">169 <input170 type="range"171 class="peach-pwgen-slider peach-pwgen-compact-slider"172 min="8"173 max="64"174 value={length()}175 onInput={(e) => {176 const n = parseInt((e.target as HTMLInputElement).value, 10);177 setLength(n);178 regenerate({ length: n });179 }}180 />181 <span class="peach-pwgen-len-num">{length()}</span>182 <div class="peach-pwgen-compact-pills">183 <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>184 <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>185 <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>186 <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>187 </div>188 <button189 class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-compact-use"190 onClick={() => props.onUse(password())}191 >192 use193 </button>194 </div>195 </Show>196197 {}198 <Show when={!props.compact}>199 <div class="peach-pwgen-display-row">200 <p201 class="peach-pwgen-output-display"202 style={{ "font-size": passwordFontSize() }}203 >204 {password()}205 </p>206 <div class="peach-pwgen-display-actions">207 <button208 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"209 onClick={() => regenerate()}210 aria-label="Regenerate password"211 >212 <RefreshIcon />213 </button>214 <button215 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}216 onClick={(event) => copyPassword(event.currentTarget as HTMLButtonElement)}217 aria-label="Copy password"218 >219 <CopyIcon /><span class="peach-copy-sheen" />220 </button>221 </div>222 </div>223224 <div class="peach-pwgen-length-row">225 <div class="peach-pwgen-length-labels">226 <span class="peach-label">length</span>227 <span class="peach-pwgen-len-num">{length()}</span>228 </div>229 <input230 type="range"231 class="peach-pwgen-slider"232 min="8"233 max="128"234 value={length()}235 onInput={(event) => {236 const nextLength = parseInt(237 (event.target as HTMLInputElement).value,238 10239 );240 setLength(nextLength);241 regenerate({ length: nextLength });242 }}243 />244 </div>245246 <div class="peach-pwgen-charset-pills">247 <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>248 <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>249 <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>250 <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>251 </div>252253 <button254 class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-use-btn"255 onClick={() => props.onUse(password())}256 >257 use this password258 </button>259 </Show>260261 </div>262 </div>263 </div>264 );265}