GeneratorDrawer.tsx

9.7 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";56interface GeneratorDrawerProps {7  open: boolean;8  policy?: PasswordPolicy;9  onUse: (password: string) => void;10  /** Compact two-row layout for use inside entry edit mode */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          {/* ── Compact layout ──────────────────────────────── */}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          {/* ── Full layout ─────────────────────────────────── */}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}