GeneratorDrawer.tsx

11.8 KB · raw

1import { createSignal, onMount, Show } from "solid-js";2import { generatePassword } from "../../shared/generate";3import type { PasswordPolicy } from "../../shared/types";4import { sendMessage, triggerPipPulse, triggerRing } from "../App";5import { popupCopy } from "../copy";67interface GeneratorDrawerProps {8  open: boolean;9  policy?: PasswordPolicy;10  onUse: (password: string) => void;11  /** Compact two-row layout for use inside entry edit mode */12  compact?: boolean;13}1415export function GeneratorDrawer(props: GeneratorDrawerProps) {16  const [password, setPassword] = createSignal("");17  const [length, setLength] = createSignal(64);18  const [upper, setUpper] = createSignal(true);19  const [lower, setLower] = createSignal(true);20  const [numbers, setNumbers] = createSignal(true);21  const [symbols, setSymbols] = createSignal(true);22  const [forbiddenChars, setForbiddenChars] = createSignal("");23  const [copied, setCopied] = createSignal(false);24  const [editingLength, setEditingLength] = createSignal(false);25  const [lengthDraft, setLengthDraft] = createSignal("");2627  function initFromPolicy(policy: PasswordPolicy) {28    let nextLength = length();29    let nextSymbols = symbols();3031    if (policy.minLength) nextLength = Math.max(nextLength, policy.minLength);32    if (policy.maxLength) nextLength = Math.min(nextLength, policy.maxLength);33    if (policy.forbidsSymbols) nextSymbols = false;34    if (policy.requiresSymbols) nextSymbols = true;3536    setLength(nextLength);37    setSymbols(nextSymbols);38    setForbiddenChars(policy.forbiddenChars ?? "");39  }4041  onMount(async () => {42    if (props.policy) {43      initFromPolicy(props.policy);44    } else {45      const res = await sendMessage({ type: "GET_SITE_PASSWORD_POLICY" });46      const data = (res as { ok: boolean; data?: PasswordPolicy }).data;47      if (data && Object.keys(data).length > 0) {48        initFromPolicy(data);49      }50    }51    regenerate();52  });5354  function regenerate(options?: {55    length?: number;56    upper?: boolean;57    lower?: boolean;58    numbers?: boolean;59    symbols?: boolean;60  }) {61    setPassword(62      generatePassword({63        length: options?.length ?? length(),64        includeUppercase: options?.upper ?? upper(),65        includeLowercase: options?.lower ?? lower(),66        includeNumbers: options?.numbers ?? numbers(),67        includeSymbols: options?.symbols ?? symbols(),68        forbiddenChars: forbiddenChars(),69      })70    );71  }7273  async function copyPassword(button?: HTMLButtonElement) {74    const ok = await popupCopy(password(), "password");75    if (ok) {76      triggerRing();77      triggerPipPulse();78      setCopied(true);79      if (button) {80        button.classList.remove("is-retreat");81        const drain = document.createElement("span");82        drain.className = "peach-copy-drain";83        const bar = document.createElement("span");84        bar.className = "peach-copy-drain-bar";85        drain.appendChild(bar);86        button.appendChild(drain);87        window.setTimeout(() => {88          setCopied(false);89          button.classList.remove("is-copied");90          button.classList.add("is-retreat");91          drain.remove();92          window.setTimeout(() => button.classList.remove("is-retreat"), 160);93        }, 1400);94        return;95      }96      window.setTimeout(() => setCopied(false), 1400);97    }98  }99100  function toggleUppercase() {101    const next = !upper();102    setUpper(next);103    regenerate({ upper: next });104  }105106  function toggleLowercase() {107    const next = !lower();108    setLower(next);109    regenerate({ lower: next });110  }111112  function toggleNumbers() {113    const next = !numbers();114    setNumbers(next);115    regenerate({ numbers: next });116  }117118  function toggleSymbols() {119    const next = !symbols();120    setSymbols(next);121    regenerate({ symbols: next });122  }123124  function startEditingLength() {125    setLengthDraft(String(length()));126    setEditingLength(true);127  }128129  function commitLength() {130    const n = parseInt(lengthDraft(), 10);131    if (!isNaN(n)) {132      const clamped = Math.max(8, Math.min(128, n));133      setLength(clamped);134      regenerate({ length: clamped });135    }136    setEditingLength(false);137  }138139  const passwordFontSize = () => {140    const value = password();141    if (value.length > 48) return "16px";142    if (value.length > 24) return "20px";143    return "24px";144  };145146  const RefreshIcon = () => (147    <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">148      <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />149      <path d="M9.5 2.5H11V1" />150    </svg>151  );152153  const CopyIcon = () => (154    <span class="peach-copy-icon">155      <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">156        <rect x="4" y="4" width="8" height="9" rx="1.5" />157        <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" />158      </svg>159    </span>160  );161162  return (163    <div class={`peach-generator-drawer ${props.open ? "is-open" : ""}`}>164      <div class="peach-generator-drawer-inner">165        <div class={`peach-generator-drawer-content${props.compact ? " peach-generator-drawer-content--compact" : ""}`}>166167          {/* ── Compact layout ──────────────────────────────── */}168          <Show when={props.compact}>169            <div class="peach-pwgen-compact-display-row">170              <span class="peach-pwgen-compact-output">{password()}</span>171              <button172                class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"173                onClick={() => regenerate()}174                aria-label="Regenerate"175              >176                <RefreshIcon />177              </button>178              <button179                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}180                onClick={(e) => copyPassword(e.currentTarget as HTMLButtonElement)}181                aria-label="Copy"182              >183                <CopyIcon /><span class="peach-copy-sheen" />184              </button>185            </div>186            <div class="peach-pwgen-compact-controls">187              <input188                type="range"189                class="peach-pwgen-slider peach-pwgen-compact-slider"190                min="8"191                max="128"192                value={length()}193                onInput={(e) => {194                  const n = parseInt((e.target as HTMLInputElement).value, 10);195                  setLength(n);196                  regenerate({ length: n });197                }}198              />199              <Show200                when={editingLength()}201                fallback={202                  <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{length()}</span>203                }204              >205                <input206                  class="peach-pwgen-len-input"207                  type="text"208                  inputmode="numeric"209                  value={lengthDraft()}210                  ref={(el) => requestAnimationFrame(() => el.select())}211                  onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}212                  onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}213                  onBlur={commitLength}214                />215              </Show>216              <div class="peach-pwgen-compact-pills">217                <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>218                <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>219                <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>220                <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>221              </div>222              <button223                class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-compact-use"224                onClick={() => props.onUse(password())}225              >226                use227              </button>228            </div>229          </Show>230231          {/* ── Full layout ─────────────────────────────────── */}232          <Show when={!props.compact}>233            <div class="peach-pwgen-display-row">234              <p235                class="peach-pwgen-output-display"236                style={{ "font-size": passwordFontSize() }}237              >238                {password()}239              </p>240              <div class="peach-pwgen-display-actions">241                <button242                  class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"243                  onClick={() => regenerate()}244                  aria-label="Regenerate password"245                >246                  <RefreshIcon />247                </button>248                <button249                  class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}250                  onClick={(event) => copyPassword(event.currentTarget as HTMLButtonElement)}251                  aria-label="Copy password"252                >253                  <CopyIcon /><span class="peach-copy-sheen" />254                </button>255              </div>256            </div>257258            <div class="peach-pwgen-length-row">259              <div class="peach-pwgen-length-labels">260                <span class="peach-label">length</span>261                <Show262                  when={editingLength()}263                  fallback={264                    <span class="peach-pwgen-len-num peach-pwgen-len-num--edit" onClick={startEditingLength}>{length()}</span>265                  }266                >267                  <input268                    class="peach-pwgen-len-input"269                    type="text"270                    inputmode="numeric"271                    value={lengthDraft()}272                    ref={(el) => requestAnimationFrame(() => el.select())}273                    onInput={(e) => setLengthDraft((e.currentTarget as HTMLInputElement).value)}274                    onKeyDown={(e) => { if (e.key === "Enter") commitLength(); else if (e.key === "Escape") setEditingLength(false); }}275                    onBlur={commitLength}276                  />277                </Show>278              </div>279              <input280                type="range"281                class="peach-pwgen-slider"282                min="8"283                max="128"284                value={length()}285                onInput={(event) => {286                  const nextLength = parseInt(287                    (event.target as HTMLInputElement).value,288                    10289                  );290                  setLength(nextLength);291                  regenerate({ length: nextLength });292                }}293              />294            </div>295296            <div class="peach-pwgen-charset-pills">297              <button class={`peach-pwgen-pill ${upper() ? "is-on" : ""}`} onClick={toggleUppercase} type="button">A-Z</button>298              <button class={`peach-pwgen-pill ${lower() ? "is-on" : ""}`} onClick={toggleLowercase} type="button">a-z</button>299              <button class={`peach-pwgen-pill ${numbers() ? "is-on" : ""}`} onClick={toggleNumbers} type="button">0-9</button>300              <button class={`peach-pwgen-pill ${symbols() ? "is-on" : ""}`} onClick={toggleSymbols} type="button">!@#</button>301            </div>302303            <button304              class="peach-btn peach-btn--accent peach-btn--sm peach-pwgen-use-btn"305              onClick={() => props.onUse(password())}306            >307              use this password308            </button>309          </Show>310311        </div>312      </div>313    </div>314  );315}